diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go index b995a400..0b6eebb5 100644 --- a/workspaces/backend/api/workspacekinds_handler.go +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/julienschmidt/httprouter" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" @@ -153,9 +154,17 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ // @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server." // @Router /workspacekinds [post] func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + // Parse dry-run query parameter + dryRun := r.URL.Query().Get("dry_run") + if dryRun != "" && dryRun != "true" && dryRun != "false" { + a.badRequestResponse(w, r, fmt.Errorf("Invalid dry_run value. Must be 'true' or 'false'")) + return + } + isDryRun := dryRun == "true" // validate the Content-Type header - if success := a.ValidateContentType(w, r, MediaTypeYaml); !success { + if !strings.EqualFold(r.Header.Get("Content-Type"), MediaTypeYaml) { + a.unsupportedMediaTypeResponse(w, r, fmt.Errorf("Only application/yaml is supported")) return } @@ -204,7 +213,7 @@ func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, } // ============================================================ - createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind) + createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind, isDryRun) if err != nil { if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) { a.conflictResponse(w, r, err) @@ -219,6 +228,16 @@ func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, return } + // Set response Content-Type header + w.Header().Set("Content-Type", "application/json") + + // Return appropriate response based on dry-run + if isDryRun { + responseEnvelope := &WorkspaceKindEnvelope{Data: *createdWorkspaceKind} + a.dataResponse(w, r, responseEnvelope) + return + } + // calculate the GET location for the created workspace kind (for the Location header) location := a.LocationGetWorkspaceKind(createdWorkspaceKind.Name) diff --git a/workspaces/backend/api/workspacekinds_handler_test.go b/workspaces/backend/api/workspacekinds_handler_test.go index 46465927..2a5164c1 100644 --- a/workspaces/backend/api/workspacekinds_handler_test.go +++ b/workspaces/backend/api/workspacekinds_handler_test.go @@ -30,6 +30,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" +apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" @@ -507,4 +508,204 @@ metadata: )) }) }) + + // 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(func() { + validYAML = []byte(fmt.Sprintf(` +apiVersion: kubeflow.org/v1beta1 +kind: WorkspaceKind +metadata: + name: %s +spec: + spawner: + displayName: "JupyterLab Notebook" + description: "A Workspace which runs JupyterLab in a Pod" + icon: + url: "https://jupyter.org/assets/favicons/apple-touch-icon.png" + logo: + url: "https://jupyter.org/assets/logos/jupyter/jupyter.png" + podTemplate: + serviceAccount: + name: default-editor + volumeMounts: + home: "/home/jovyan" + options: + imageConfig: + default: "jupyterlab_scipy_180" + values: + - id: "jupyterlab_scipy_180" + displayName: "JupyterLab SciPy 1.8.0" + description: "JupyterLab with SciPy 1.8.0" + spec: + image: "jupyter/scipy-notebook:2024.1.0" + podConfig: + default: "tiny_cpu" + values: + - id: "tiny_cpu" + displayName: "Tiny CPU" + description: "1 CPU core, 2GB RAM" + spec: + resources: + requests: + cpu: "100m" + memory: "512Mi" + limits: + cpu: "1" + memory: "2Gi" +`, newWorkspaceKindName)) + }) + + AfterEach(func() { + By("deleting the WorkspaceKind if it exists") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: newWorkspaceKindName, + }, + } + _ = k8sClient.Delete(ctx, workspaceKind) + }) + + It("should create a WorkspaceKind successfully without dry-run", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + + By("setting required headers") + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String()) + + By("verifying the Location header") + location := rs.Header.Get("Location") + Expect(location).To(Equal(fmt.Sprintf("/api/v1/workspacekinds/%s", newWorkspaceKindName))) + + By("reading and parsing the response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + var response WorkspaceKindCreateEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the created resource exists in the cluster") + createdWorkspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWorkspaceKind) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the response matches the created resource") + expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(createdWorkspaceKind) + Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind)) + }) + + It("should validate WorkspaceKind with dry-run=true without creating it", func() { + By("creating the HTTP request with dry-run=true") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath+"?dry_run=true", bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + + By("setting required headers") + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + + By("reading and parsing the response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + var response WorkspaceKindEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the resource was not created in the cluster") + notCreatedWorkspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, notCreatedWorkspaceKind) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("should return 400 for invalid YAML", func() { + invalidYAML := []byte("invalid: yaml: :") + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML)) + Expect(err).NotTo(HaveOccurred()) + + By("setting required headers") + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 415 for wrong content-type", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + + By("setting wrong content-type header") + req.Header.Set("Content-Type", MediaTypeJson) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusUnsupportedMediaType), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 400 for invalid dry-run value", func() { + By("creating the HTTP request with invalid dry-run value") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath+"?dry_run=invalid", bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + + By("setting required headers") + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String()) + }) + }) }) diff --git a/workspaces/backend/internal/repositories/workspacekinds/repo.go b/workspaces/backend/internal/repositories/workspacekinds/repo.go index 02cd208b..83063d53 100644 --- a/workspaces/backend/internal/repositories/workspacekinds/repo.go +++ b/workspaces/backend/internal/repositories/workspacekinds/repo.go @@ -70,24 +70,27 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode return workspaceKindsModels, nil } -func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) (*models.WorkspaceKind, error) { - // create workspace kind +func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind, dryRun bool) (*models.WorkspaceKind, error) { + if dryRun { + // For dry-run, just convert to model and return without creating + workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) + return &workspaceKindModel, nil + } + + // Create workspace kind only if not dry-run if err := r.client.Create(ctx, workspaceKind); err != nil { if apierrors.IsAlreadyExists(err) { return nil, ErrWorkspaceKindAlreadyExists } if apierrors.IsInvalid(err) { // NOTE: we don't wrap this error so we can unpack it in the caller - // and extract the validation errors returned by the Kubernetes API server + // and extract the validation errors returned by the Kubernetes API server return nil, err } return nil, err } - // convert the created workspace to a WorkspaceKindUpdate model - // - // TODO: this function should return the WorkspaceKindUpdate model, once the update WSK api is implemented - // + // Convert the created workspace to a WorkspaceKindUpdate model createdWorkspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) return &createdWorkspaceKindModel, nil