Skip to content

Commit d38b24c

Browse files
feat(ws): backend api to create wsk with YAML (#434)
* feat(ws): Notebooks 2.0 // Backend // API that allows frontend to upload a YAML file containing a full new WorkspaceKind definition Signed-off-by: Asaad Balum <[email protected]> * mathew: 1 Signed-off-by: Mathew Wicks <[email protected]> --------- Signed-off-by: Asaad Balum <[email protected]> Signed-off-by: Mathew Wicks <[email protected]> Co-authored-by: Mathew Wicks <[email protected]>
1 parent f90ee78 commit d38b24c

File tree

11 files changed

+627
-16
lines changed

11 files changed

+627
-16
lines changed

workspaces/backend/api/app.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ limitations under the License.
1717
package api
1818

1919
import (
20+
"fmt"
2021
"log/slog"
2122
"net/http"
2223

2324
"github.com/julienschmidt/httprouter"
2425
"k8s.io/apimachinery/pkg/runtime"
26+
"k8s.io/apimachinery/pkg/runtime/serializer"
2527
"k8s.io/apiserver/pkg/authentication/authenticator"
2628
"k8s.io/apiserver/pkg/authorization/authorizer"
2729
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -35,6 +37,9 @@ const (
3537
Version = "1.0.0"
3638
PathPrefix = "/api/v1"
3739

40+
MediaTypeJson = "application/json"
41+
MediaTypeYaml = "application/yaml"
42+
3843
NamespacePathParam = "namespace"
3944
ResourceNamePathParam = "name"
4045

@@ -59,26 +64,35 @@ const (
5964
)
6065

6166
type App struct {
62-
Config *config.EnvConfig
63-
logger *slog.Logger
64-
repositories *repositories.Repositories
65-
Scheme *runtime.Scheme
66-
RequestAuthN authenticator.Request
67-
RequestAuthZ authorizer.Authorizer
67+
Config *config.EnvConfig
68+
logger *slog.Logger
69+
repositories *repositories.Repositories
70+
Scheme *runtime.Scheme
71+
StrictYamlSerializer runtime.Serializer
72+
RequestAuthN authenticator.Request
73+
RequestAuthZ authorizer.Authorizer
6874
}
6975

7076
// NewApp creates a new instance of the app
7177
func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) {
7278

7379
// TODO: log the configuration on startup
7480

81+
// get a serializer for Kubernetes YAML
82+
codecFactory := serializer.NewCodecFactory(scheme)
83+
yamlSerializerInfo, found := runtime.SerializerInfoForMediaType(codecFactory.SupportedMediaTypes(), runtime.ContentTypeYAML)
84+
if !found {
85+
return nil, fmt.Errorf("unable to find Kubernetes serializer for media type: %s", runtime.ContentTypeYAML)
86+
}
87+
7588
app := &App{
76-
Config: cfg,
77-
logger: logger,
78-
repositories: repositories.NewRepositories(cl),
79-
Scheme: scheme,
80-
RequestAuthN: reqAuthN,
81-
RequestAuthZ: reqAuthZ,
89+
Config: cfg,
90+
logger: logger,
91+
repositories: repositories.NewRepositories(cl),
92+
Scheme: scheme,
93+
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
94+
RequestAuthN: reqAuthN,
95+
RequestAuthZ: reqAuthZ,
8296
}
8397
return app, nil
8498
}
@@ -106,6 +120,7 @@ func (a *App) Routes() http.Handler {
106120
// workspacekinds
107121
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
108122
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
123+
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
109124

110125
// swagger
111126
router.GET(SwaggerPath, a.GetSwaggerHandler)

workspaces/backend/api/helpers.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package api
1818

1919
import (
2020
"encoding/json"
21+
"errors"
2122
"fmt"
2223
"mime"
2324
"net/http"
@@ -46,7 +47,7 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
4647
w.Header()[key] = value
4748
}
4849

49-
w.Header().Set("Content-Type", "application/json")
50+
w.Header().Set("Content-Type", MediaTypeJson)
5051
w.WriteHeader(status)
5152
_, err = w.Write(js)
5253
if err != nil {
@@ -61,11 +62,21 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
6162
decoder := json.NewDecoder(r.Body)
6263
decoder.DisallowUnknownFields()
6364
if err := decoder.Decode(v); err != nil {
65+
// NOTE: we don't wrap this error so we can unpack it in the caller
66+
if a.IsMaxBytesError(err) {
67+
return err
68+
}
6469
return fmt.Errorf("error decoding JSON: %w", err)
6570
}
6671
return nil
6772
}
6873

74+
// IsMaxBytesError checks if the error is an instance of http.MaxBytesError.
75+
func (a *App) IsMaxBytesError(err error) bool {
76+
var maxBytesError *http.MaxBytesError
77+
return errors.As(err, &maxBytesError)
78+
}
79+
6980
// ValidateContentType validates the Content-Type header of the request.
7081
// If this method returns false, the request has been handled and the caller should return immediately.
7182
// If this method returns true, the request has the correct Content-Type.
@@ -94,3 +105,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
94105
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
95106
return path
96107
}
108+
109+
// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource.
110+
func (a *App) LocationGetWorkspaceKind(name string) string {
111+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1)
112+
return path
113+
}

workspaces/backend/api/response_errors.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error
156156
a.errorResponse(w, r, httpError)
157157
}
158158

159+
// HTTP:413
160+
func (a *App) requestEntityTooLargeResponse(w http.ResponseWriter, r *http.Request, err error) {
161+
httpError := &HTTPError{
162+
StatusCode: http.StatusRequestEntityTooLarge,
163+
ErrorResponse: ErrorResponse{
164+
Code: strconv.Itoa(http.StatusRequestEntityTooLarge),
165+
Message: err.Error(),
166+
},
167+
}
168+
a.errorResponse(w, r, httpError)
169+
}
170+
159171
// HTTP:415
160172
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
161173
httpError := &HTTPError{

workspaces/backend/api/suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ var _ = BeforeSuite(func() {
150150
By("creating the application")
151151
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
152152
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
153+
Expect(err).NotTo(HaveOccurred())
153154

154155
go func() {
155156
defer GinkgoRecover()

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ package api
1818

1919
import (
2020
"errors"
21+
"fmt"
22+
"io"
2123
"net/http"
2224

2325
"github.com/julienschmidt/httprouter"
2426
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
27+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2528
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/runtime"
2630
"k8s.io/apimachinery/pkg/util/validation/field"
2731

2832
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
@@ -31,6 +35,9 @@ import (
3135
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
3236
)
3337

38+
// TODO: this should wrap the models.WorkspaceKindUpdate once we implement the update handler
39+
type WorkspaceKindCreateEnvelope Envelope[*models.WorkspaceKind]
40+
3441
type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
3542

3643
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
@@ -123,3 +130,95 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
123130
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
124131
a.dataResponse(w, r, responseEnvelope)
125132
}
133+
134+
// CreateWorkspaceKindHandler creates a new workspace kind.
135+
//
136+
// @Summary Create workspace kind
137+
// @Description Creates a new workspace kind.
138+
// @Tags workspacekinds
139+
// @Accept application/yaml
140+
// @Produce json
141+
// @Param body body string true "Kubernetes YAML manifest of a WorkspaceKind"
142+
// @Success 201 {object} WorkspaceKindEnvelope "WorkspaceKind created successfully"
143+
// @Failure 400 {object} ErrorEnvelope "Bad Request."
144+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
145+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create WorkspaceKind."
146+
// @Failure 409 {object} ErrorEnvelope "Conflict. WorkspaceKind with the same name already exists."
147+
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large.""
148+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
149+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
150+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
151+
// @Router /workspacekinds [post]
152+
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
153+
154+
// validate the Content-Type header
155+
if success := a.ValidateContentType(w, r, MediaTypeYaml); !success {
156+
return
157+
}
158+
159+
// decode the request body
160+
bodyBytes, err := io.ReadAll(r.Body)
161+
if err != nil {
162+
if a.IsMaxBytesError(err) {
163+
a.requestEntityTooLargeResponse(w, r, err)
164+
return
165+
}
166+
a.badRequestResponse(w, r, err)
167+
return
168+
}
169+
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
170+
err = runtime.DecodeInto(a.StrictYamlSerializer, bodyBytes, workspaceKind)
171+
if err != nil {
172+
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
173+
return
174+
}
175+
176+
// validate the workspace kind
177+
// NOTE: we only do basic validation so we know it's safe to send to the Kubernetes API server
178+
// comprehensive validation will be done by Kubernetes
179+
// NOTE: checking the name field is non-empty also verifies that the workspace kind is not nil/empty
180+
var valErrs field.ErrorList
181+
wskNamePath := field.NewPath("metadata", "name")
182+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(wskNamePath, workspaceKind.Name)...)
183+
if len(valErrs) > 0 {
184+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
185+
return
186+
}
187+
188+
// =========================== AUTH ===========================
189+
authPolicies := []*auth.ResourcePolicy{
190+
auth.NewResourcePolicy(
191+
auth.ResourceVerbCreate,
192+
&kubefloworgv1beta1.WorkspaceKind{
193+
ObjectMeta: metav1.ObjectMeta{
194+
Name: workspaceKind.Name,
195+
},
196+
},
197+
),
198+
}
199+
if success := a.requireAuth(w, r, authPolicies); !success {
200+
return
201+
}
202+
// ============================================================
203+
204+
createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind)
205+
if err != nil {
206+
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
207+
a.conflictResponse(w, r, err)
208+
return
209+
}
210+
if apierrors.IsInvalid(err) {
211+
causes := helper.StatusCausesFromAPIStatus(err)
212+
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
213+
return
214+
}
215+
a.serverErrorResponse(w, r, fmt.Errorf("error creating workspace kind: %w", err))
216+
return
217+
}
218+
219+
// calculate the GET location for the created workspace kind (for the Location header)
220+
location := a.LocationGetWorkspaceKind(createdWorkspaceKind.Name)
221+
222+
responseEnvelope := &WorkspaceKindCreateEnvelope{Data: createdWorkspaceKind}
223+
a.createdResponse(w, r, responseEnvelope, location)
224+
}

0 commit comments

Comments
 (0)