diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index ef3fe7db..fa98a8e4 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -56,6 +56,9 @@ const ( // swagger SwaggerPath = PathPrefix + "/swagger/*any" SwaggerDocPath = PathPrefix + "/swagger/doc.json" + + // YAML manifest content type + ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml" ) type App struct { @@ -106,6 +109,7 @@ func (a *App) Routes() http.Handler { // workspacekinds router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) + router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler) // swagger router.GET(SwaggerPath, a.GetSwaggerHandler) diff --git a/workspaces/backend/api/helpers.go b/workspaces/backend/api/helpers.go index 0076a060..7c497026 100644 --- a/workspaces/backend/api/helpers.go +++ b/workspaces/backend/api/helpers.go @@ -94,3 +94,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string { path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1) return path } + +// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource. +func (a *App) LocationGetWorkspaceKind(name string) string { + path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1) + return path +} diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go index c8e0b6af..3d0a220f 100644 --- a/workspaces/backend/api/workspacekinds_handler.go +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -18,11 +18,17 @@ package api import ( "errors" + "fmt" + "io" "net/http" "github.com/julienschmidt/httprouter" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/validation/field" "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" @@ -35,6 +41,74 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind] type WorkspaceKindEnvelope Envelope[models.WorkspaceKind] +// cachedRestrictedScheme holds the pre-built runtime scheme that only knows about WorkspaceKind. +var cachedRestrictedScheme *runtime.Scheme + +// cachedUniversalDeserializer holds the pre-built universal deserializer for the restricted scheme. +var cachedUniversalDeserializer runtime.Decoder + +// cachedExpectedGVK holds the expected GVK for WorkspaceKind, derived programmatically. +var cachedExpectedGVK schema.GroupVersionKind + +// init builds the restricted scheme and deserializer once at package initialization time. +func init() { + restrictedScheme := runtime.NewScheme() + if err := kubefloworgv1beta1.AddToScheme(restrictedScheme); err != nil { + panic(fmt.Sprintf("failed to add WorkspaceKind types to restricted scheme: %v", err)) + } + cachedRestrictedScheme = restrictedScheme + + codecs := serializer.NewCodecFactory(cachedRestrictedScheme) + cachedUniversalDeserializer = codecs.UniversalDeserializer() + + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + gvks, _, err := cachedRestrictedScheme.ObjectKinds(workspaceKind) + if err != nil || len(gvks) == 0 { + panic(fmt.Sprintf("failed to derive GVK from WorkspaceKind type: %v", err)) + } + cachedExpectedGVK = gvks[0] +} + +// ParseWorkspaceKindManifestBody reads and decodes a YAML request body into a WorkspaceKind object +// using Kubernetes runtime validation to ensure maximum security. +func (a *App) ParseWorkspaceKindManifestBody(w http.ResponseWriter, r *http.Request) (*kubefloworgv1beta1.WorkspaceKind, bool) { + // NOTE: A server-level middleware should enforce a max body size. + body, err := io.ReadAll(r.Body) + if err != nil { + a.badRequestResponse(w, r, fmt.Errorf("failed to read request body: %w", err)) + return nil, false + } + defer func() { + if err := r.Body.Close(); err != nil { + a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", err)) + } + }() + + if len(body) == 0 { + a.badRequestResponse(w, r, errors.New("request body is empty")) + return nil, false + } + + obj, gvk, err := cachedUniversalDeserializer.Decode(body, nil, nil) + if err != nil { + a.badRequestResponse(w, r, fmt.Errorf("failed to decode YAML manifest: %w", err)) + return nil, false + } + + if gvk.Kind != cachedExpectedGVK.Kind || gvk.Version != cachedExpectedGVK.Version { + a.badRequestResponse(w, r, fmt.Errorf("invalid GVK: expected %s, got %s", cachedExpectedGVK.Kind, gvk.Kind)) + return nil, false + } + + workspaceKind, ok := obj.(*kubefloworgv1beta1.WorkspaceKind) + if !ok { + a.badRequestResponse(w, r, fmt.Errorf("unexpected type: got %T, want *WorkspaceKind", obj)) + return nil, false + } + + return workspaceKind, true +} + // GetWorkspaceKindHandler retrieves a specific workspace kind by name. // // @Summary Get workspace kind @@ -123,3 +197,73 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds} a.dataResponse(w, r, responseEnvelope) } + +// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest. + +// @Summary Create workspace kind +// @Description Creates a new workspace kind from a raw YAML manifest. +// @Tags workspacekinds +// @Accept application/vnd.kubeflow-notebooks.manifest+yaml +// @Produce json +// @Param body body string true "Raw YAML manifest of the WorkspaceKind" +// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details." +// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing." +// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required." +// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind." +// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists." +// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct." +// @Failure 500 {object} ErrorEnvelope "Internal server error." +// @Router /workspacekinds [post] +func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + // === Content-Type check === + if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok { + return + } + + // === Read body and parse with secure parser === + newWsk, ok := a.ParseWorkspaceKindManifestBody(w, r) + if !ok { + return + } + + // === Validate name exists in YAML === + if newWsk.Name == "" { + a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest")) + return + } + + // === AUTH === + authPolicies := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbCreate, + &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name}, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + + // === Create === + createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), newWsk) + if err != nil { + if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) { + a.conflictResponse(w, r, err) + return + } + // This handles validation errors from the K8s API Server (webhook) + if apierrors.IsInvalid(err) { + causes := helper.StatusCausesFromAPIStatus(err) + a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes) + return + } + a.serverErrorResponse(w, r, err) + return + } + + // === Return created object in envelope === + location := a.LocationGetWorkspaceKind(createdModel.Name) + responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel} + a.createdResponse(w, r, responseEnvelope, location) +} diff --git a/workspaces/backend/api/workspacekinds_handler_test.go b/workspaces/backend/api/workspacekinds_handler_test.go index f37c58c9..ab266ed6 100644 --- a/workspaces/backend/api/workspacekinds_handler_test.go +++ b/workspaces/backend/api/workspacekinds_handler_test.go @@ -17,6 +17,7 @@ limitations under the License. package api import ( + "bytes" "encoding/json" "fmt" "io" @@ -195,8 +196,6 @@ var _ = Describe("WorkspaceKinds Handler", func() { }) }) - // NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests. - // therefore, we run them using the `Serial` Ginkgo decorators. Context("with no existing WorkspaceKinds", Serial, func() { It("should return an empty list of WorkspaceKinds", func() { @@ -254,4 +253,143 @@ var _ = Describe("WorkspaceKinds Handler", func() { Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) }) }) + + // NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel. + // therefore, we run them using the `Serial` Ginkgo decorator. + Context("when creating a WorkspaceKind", Serial, func() { + + var newWorkspaceKindName = "wsk-create-test" + var validYAML []byte + + // BeforeEach runs before each "It" block, ensuring the validYAML is always available. + BeforeEach(func() { + validYAML = []byte(fmt.Sprintf(` +apiVersion: kubeflow.org/v1beta1 +kind: WorkspaceKind +metadata: + name: %s +spec: + spawner: + displayName: "Test Jupyter Environment" + description: "A valid description for testing." + icon: + url: "https://example.com/icon.png" + logo: + url: "https://example.com/logo.svg" + podTemplate: + options: + imageConfig: + spawner: + default: "default-image" + values: + - id: "default-image" + name: "Jupyter Scipy" + path: "kubeflownotebooks/jupyter-scipy:v1.9.0" + spawner: + displayName: "Jupyter with SciPy v1.9.0" + spec: + image: "kubeflownotebooks/jupyter-scipy:v1.9.0" + ports: + - id: "notebook-port" + displayName: "Notebook Port" + port: 8888 + protocol: "HTTP" + podConfig: + spawner: + default: "default-pod-config" + values: + - id: "default-pod-config" + name: "Default Resources" + spawner: + displayName: "Small CPU/RAM" + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1" + memory: "2Gi" + volumeMounts: + home: "/home/jovyan" +`, newWorkspaceKindName)) + }) + + AfterEach(func() { + By("cleaning up the created WorkspaceKind") + wsk := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: newWorkspaceKindName, + }, + } + _ = k8sClient.Delete(ctx, wsk) + }) + + It("should succeed when creating a new WorkspaceKind with valid YAML", func() { + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", ContentTypeYAMLManifest) + req.Header.Set(userIdHeader, adminUser) + + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String()) + }) + + It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() { + By("creating the resource once successfully") + req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + req1.Header.Set("Content-Type", ContentTypeYAMLManifest) + req1.Header.Set(userIdHeader, adminUser) + rr1 := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{}) + Expect(rr1.Code).To(Equal(http.StatusCreated)) + + By("attempting to create the exact same resource a second time") + req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + req2.Header.Set("Content-Type", ContentTypeYAMLManifest) + req2.Header.Set(userIdHeader, adminUser) + rr2 := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{}) + + Expect(rr2.Code).To(Equal(http.StatusConflict)) + }) + + It("should fail with 400 Bad Request when the YAML has the wrong kind", func() { + wrongKindYAML := []byte(`apiVersion: v1 +kind: Pod +metadata: + name: i-am-the-wrong-kind`) + req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML)) + req.Header.Set("Content-Type", ContentTypeYAMLManifest) + req.Header.Set(userIdHeader, adminUser) + + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + // UPDATED: Check for the new, more specific error message + Expect(rr.Body.String()).To(ContainSubstring("no kind \\\"Pod\\\" is registered")) + }) + + It("should fail with 400 Bad Request for an empty YAML object", func() { + invalidYAML := []byte("{}") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", ContentTypeYAMLManifest) + req.Header.Set(userIdHeader, adminUser) + + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest)) + body, _ := io.ReadAll(rs.Body) + // UPDATED: Check for the new, more specific error message from the secure parser + Expect(string(body)).To(ContainSubstring("failed to decode YAML manifest")) + }) + }) }) diff --git a/workspaces/backend/internal/repositories/workspacekinds/repo.go b/workspaces/backend/internal/repositories/workspacekinds/repo.go index 3796e297..01b9de2d 100644 --- a/workspaces/backend/internal/repositories/workspacekinds/repo.go +++ b/workspaces/backend/internal/repositories/workspacekinds/repo.go @@ -28,6 +28,7 @@ import ( ) var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") +var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists") type WorkspaceKindRepository struct { client client.Client @@ -68,3 +69,21 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode return workspaceKindsModels, nil } + +func (r *WorkspaceKindRepository) Create(ctx context.Context, wk *kubefloworgv1beta1.WorkspaceKind) (models.WorkspaceKind, error) { + if err := r.client.Create(ctx, wk); err != nil { + if apierrors.IsAlreadyExists(err) { + return models.WorkspaceKind{}, ErrWorkspaceKindAlreadyExists + } + if apierrors.IsInvalid(err) { + // NOTE: we don't wrap this error so we can unpack it in the caller + return models.WorkspaceKind{}, err + } + return models.WorkspaceKind{}, err + } + + // Convert the created k8s object to our backend model before returning + createdModel := models.NewWorkspaceKindModelFromWorkspaceKind(wk) + + return createdModel, nil +} diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index e0fc73e4..a550ffa4 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -122,6 +122,74 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Creates a new workspace kind from a raw YAML manifest.", + "consumes": [ + "application/vnd.kubeflow-notebooks.manifest+yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Create workspace kind", + "parameters": [ + { + "description": "Raw YAML manifest of the WorkspaceKind", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Successful creation. Returns the newly created workspace kind details.", + "schema": { + "$ref": "#/definitions/api.WorkspaceKindEnvelope" + } + }, + "400": { + "description": "Bad Request. The YAML is invalid or a required field is missing.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to create the workspace kind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict. A WorkspaceKind with the same name already exists.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } } }, "/workspacekinds/{name}": { diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index 4f9fdeff..37a5084f 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -120,6 +120,74 @@ } } } + }, + "post": { + "description": "Creates a new workspace kind from a raw YAML manifest.", + "consumes": [ + "application/vnd.kubeflow-notebooks.manifest+yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Create workspace kind", + "parameters": [ + { + "description": "Raw YAML manifest of the WorkspaceKind", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Successful creation. Returns the newly created workspace kind details.", + "schema": { + "$ref": "#/definitions/api.WorkspaceKindEnvelope" + } + }, + "400": { + "description": "Bad Request. The YAML is invalid or a required field is missing.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to create the workspace kind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict. A WorkspaceKind with the same name already exists.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } } }, "/workspacekinds/{name}": {