From e46633be34d048295d71d81f8a0c8741ffb35f5e Mon Sep 17 00:00:00 2001 From: Eder Ignatowicz Date: Thu, 10 Oct 2024 15:37:10 -0400 Subject: [PATCH] feat(ws): add CRUD operations to backend (#66) * feat(ws): Notebooks 2.0 // Backend // List Workspaces API - II In this PR: - FUP for Notebooks 2.0 // Backend // List Workspaces API (#60) review - Create /api/v1/workspaces to return all workspaces - Review API endpoints as requested Signed-off-by: Eder Ignatowicz * Notebooks 2.0 // Backend // CRUD Workspaces API In this PR: - Created handlers and repositories for create, get and delete workspace - Improved the type of our json response Signed-off-by: Eder Ignatowicz * feat(ws): Notebooks 2.0 // Backend // List WorkspaceKinds This PR builds on top of: https://github.com/kubeflow/notebooks/pull/61 and https://github.com/kubeflow/notebooks/pull/65 In this PR: - Created handlers and repositories for get workspacekinds This PR closes https://github.com/kubeflow/notebooks/issues/51 Signed-off-by: Eder Ignatowicz * mathew: fix linting Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> --------- Signed-off-by: Eder Ignatowicz Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> Co-authored-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> --- workspaces/backend/.golangci.yml | 40 ++ workspaces/backend/README.md | 82 +++- workspaces/backend/api/app.go | 35 +- workspaces/backend/api/errors.go | 12 +- .../backend/api/healthcheck__handler_test.go | 7 +- workspaces/backend/api/healthcheck_handler.go | 1 - workspaces/backend/api/helpers.go | 6 +- .../backend/api/workspacekinds_handler.go | 78 ++++ workspaces/backend/api/workspaces_handler.go | 117 +++++- .../backend/api/workspaces_handler_test.go | 365 ++++++++++++++++-- .../api/workspaceskind_handler_test.go | 267 +++++++++++++ .../backend/internal/models/workspacekinds.go | 99 +++++ .../backend/internal/models/workspaces.go | 54 ++- .../internal/repositories/repositories.go | 10 +- .../internal/repositories/workspacekinds.go | 73 ++++ .../internal/repositories/workspaces.go | 116 +++++- workspaces/backend/internal/server/server.go | 6 +- 17 files changed, 1255 insertions(+), 113 deletions(-) create mode 100644 workspaces/backend/.golangci.yml create mode 100644 workspaces/backend/api/workspacekinds_handler.go create mode 100644 workspaces/backend/api/workspaceskind_handler_test.go create mode 100644 workspaces/backend/internal/models/workspacekinds.go create mode 100644 workspaces/backend/internal/repositories/workspacekinds.go diff --git a/workspaces/backend/.golangci.yml b/workspaces/backend/.golangci.yml new file mode 100644 index 00000000..ca69a11f --- /dev/null +++ b/workspaces/backend/.golangci.yml @@ -0,0 +1,40 @@ +run: + timeout: 5m + allow-parallel-runners: true + +issues: + # don't skip warning about doc comments + # don't exclude the default set of lint + exclude-use-default: false + # restore some of the defaults + # (fill in the rest as needed) + exclude-rules: + - path: "api/*" + linters: + - lll + - path: "internal/*" + linters: + - dupl + - lll +linters: + disable-all: true + enable: + - dupl + - errcheck + - exportloopref + - goconst + - gocyclo + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - staticcheck + - typecheck + - unconvert + - unparam + - unused diff --git a/workspaces/backend/README.md b/workspaces/backend/README.md index 191fd68b..c684acc3 100644 --- a/workspaces/backend/README.md +++ b/workspaces/backend/README.md @@ -24,28 +24,70 @@ make run PORT=8000 ``` ### Endpoints -| URL Pattern | Handler | Action | -|------------------------------------------------------|----------------------|-------------------------------| -| GET /v1/healthcheck | HealthcheckHandler | Show application information. | -| GET /v1/spawner/{namespace}/workspaces | GetWorkspacesHandler | Get all Workspaces | -| POST /v1/spawner/{namespace}/workspaces | TBD | Create a Workspace | -| GET /v1/spawner/{namespace}/workspaces/{name} | TBD | Get a Workspace entity | -| PATCH /v1/spawner/{namespace}/workspaces/{name} | TBD | Patch a Workspace entity | -| PUT /v1/spawner/{namespace}/workspaces/{name} | TBD | Update a Workspace entity | -| DELETE /v1/spawner/{namespace}/workspaces/{name} | TBD | Delete a Workspace entity | -| GET /v1/spawner/{namespace}/workspacekinds | TDB | Get all WorkspaceKind | -| POST /v1/spawner/{namespace}/workspacekinds | TDB | Create a WorkspaceKind | -| GET /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Get a WorkspaceKind entity | -| PATCH /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity | -| PUT /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Update a WorkspaceKind entity | -| DELETE /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity | +| URL Pattern | Handler | Action | +|----------------------------------------------|------------------------|-----------------------------------------| +| GET /api/v1/healthcheck | healthcheck_handler | Show application information. | +| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces | +| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace | +| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace | +| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity | +| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity | +| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity | +| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity | +| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind | +| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind | +| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity | +| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity | +| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity | +| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity | ### Sample local calls ``` -# GET /v1/healthcheck -curl -i localhost:4000/api/v1/healthcheck/ +# GET /api/v1/healthcheck +curl -i localhost:4000/api/v1/healthcheck ``` -`````` -# GET /v1/spawner/{namespace}/workspace -curl -i localhost:4000/api/v1/spawner/{namespace}/workspaces +``` +# GET /api/v1/workspaces/ +curl -i localhost:4000/api/v1/workspaces +``` +``` +# GET /api/v1/workspaces/{namespace} +curl -i localhost:4000/api/v1/workspaces/default +``` +``` +# POST /api/v1/workspaces/{namespace} +curl -X POST http://localhost:4000/api/v1/workspaces/default \ + -H "Content-Type: application/json" \ + -d '{ + "name": "dora", + "paused": false, + "defer_updates": false, + "kind": "jupyterlab", + "image_config": "jupyterlab_scipy_190", + "pod_config": "tiny_cpu", + "home_volume": "workspace-home-bella", + "data_volumes": [ + { + "pvc_name": "workspace-data-bella", + "mount_path": "/data/my-data", + "read_only": false + } + ] + }' +``` +``` +# GET /api/v1/workspaces/{namespace}/{name} +curl -i localhost:4000/api/v1/workspaces/default/dora +``` +``` +# DELETE /api/v1/workspaces/{namespace}/{name} +curl -X DELETE localhost:4000/api/v1/workspaces/workspace-test/dora +``` +``` +# GET /api/v1/workspacekinds +curl -i localhost:4000/api/v1/workspacekinds +``` +``` +# GET /api/v1/workspacekinds/{name} +curl -i localhost:4000/api/v1/workspacekinds/jupyterlab ``` diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 2091a2e5..235ab45c 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -17,7 +17,6 @@ limitations under the License. package api import ( - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" "log/slog" "net/http" @@ -26,14 +25,27 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) const ( - Version = "1.0.0" - HealthCheckPath = "/api/v1/healthcheck/" - NamespacePathParam = "namespace" - PathPrefix = "/api/v1/spawner/:namespace" - WorkspacesPath = PathPrefix + "/workspaces" + Version = "1.0.0" + PathPrefix = "/api/v1" + + // healthcheck + HealthCheckPath = PathPrefix + "/healthcheck" + + // workspaces + AllWorkspacesPath = PathPrefix + "/workspaces" + NamespacePathParam = "namespace" + WorkspaceNamePathParam = "name" + WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam + WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + WorkspaceNamePathParam + + // workspacekinds + AllWorkspaceKindsPath = PathPrefix + "/workspacekinds" + WorkspaceKindNamePathParam = "name" + WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + WorkspaceNamePathParam ) type App struct { @@ -63,7 +75,16 @@ func (a *App) Routes() http.Handler { router.MethodNotAllowed = http.HandlerFunc(a.methodNotAllowedResponse) router.GET(HealthCheckPath, a.HealthcheckHandler) - router.GET(WorkspacesPath, a.GetWorkspacesHandler) + + router.GET(AllWorkspacesPath, a.GetWorkspacesHandler) + router.GET(WorkspacesByNamespacePath, a.GetWorkspacesHandler) + + router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler) + router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler) + router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler) + + router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) + router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) return a.RecoverPanic(a.enableCORS(router)) } diff --git a/workspaces/backend/api/errors.go b/workspaces/backend/api/errors.go index 5987b2c3..491d7ecf 100644 --- a/workspaces/backend/api/errors.go +++ b/workspaces/backend/api/errors.go @@ -33,6 +33,10 @@ type ErrorResponse struct { Message string `json:"message"` } +type ErrorEnvelope struct { + Error *HTTPError `json:"error"` +} + func (a *App) LogError(r *http.Request, err error) { var ( method = r.Method @@ -55,11 +59,9 @@ func (a *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err err } func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, error *HTTPError) { - - env := Envelope{"error": error} + env := ErrorEnvelope{Error: error} err := a.WriteJSON(w, error.StatusCode, env, nil) - if err != nil { a.LogError(r, err) w.WriteHeader(error.StatusCode) @@ -80,7 +82,6 @@ func (a *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err er } func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) { - httpError := &HTTPError{ StatusCode: http.StatusNotFound, ErrorResponse: ErrorResponse{ @@ -92,7 +93,6 @@ func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) { } func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { - httpError := &HTTPError{ StatusCode: http.StatusMethodNotAllowed, ErrorResponse: ErrorResponse{ @@ -105,11 +105,11 @@ func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { // nolint:unused func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { - message, err := json.Marshal(errors) if err != nil { message = []byte("{}") } + httpError := &HTTPError{ StatusCode: http.StatusUnprocessableEntity, ErrorResponse: ErrorResponse{ diff --git a/workspaces/backend/api/healthcheck__handler_test.go b/workspaces/backend/api/healthcheck__handler_test.go index a095d55e..0885bcfb 100644 --- a/workspaces/backend/api/healthcheck__handler_test.go +++ b/workspaces/backend/api/healthcheck__handler_test.go @@ -18,8 +18,6 @@ package api import ( "encoding/json" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" "io" "net/http" "net/http/httptest" @@ -28,6 +26,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) func TestHealthCheckHandler(t *testing.T) { @@ -46,8 +46,7 @@ func TestHealthCheckHandler(t *testing.T) { app.HealthcheckHandler(rr, req, nil) rs := rr.Result() - - defer rs.Body.Close() + defer rs.Body.Close() // nolint: errcheck body, err := io.ReadAll(rs.Body) if err != nil { diff --git a/workspaces/backend/api/healthcheck_handler.go b/workspaces/backend/api/healthcheck_handler.go index 2f2e72e8..5ed1a2a6 100644 --- a/workspaces/backend/api/healthcheck_handler.go +++ b/workspaces/backend/api/healthcheck_handler.go @@ -31,7 +31,6 @@ func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps http } err = a.WriteJSON(w, http.StatusOK, healthCheck, nil) - if err != nil { a.serverErrorResponse(w, r, err) } diff --git a/workspaces/backend/api/helpers.go b/workspaces/backend/api/helpers.go index 03382b37..238427fa 100644 --- a/workspaces/backend/api/helpers.go +++ b/workspaces/backend/api/helpers.go @@ -25,12 +25,13 @@ import ( "strings" ) -type Envelope map[string]interface{} +type Envelope[D any] struct { + Data D `json:"data"` +} func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error { js, err := json.MarshalIndent(data, "", "\t") - if err != nil { return err } @@ -60,7 +61,6 @@ func (a *App) ReadJSON(w http.ResponseWriter, r *http.Request, dst any) error { dec.DisallowUnknownFields() err := dec.Decode(dst) - if err != nil { var syntaxError *json.SyntaxError var unmarshalTypeError *json.UnmarshalTypeError diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go new file mode 100644 index 00000000..dc98f26d --- /dev/null +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -0,0 +1,78 @@ +/* + * + * Copyright 2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +package api + +import ( + "errors" + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" +) + +type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKindModel] +type WorkspaceKindEnvelope Envelope[models.WorkspaceKindModel] + +func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + name := ps.ByName("name") + + if name == "" { + a.serverErrorResponse(w, r, fmt.Errorf("workspace kind name is missing")) + return + } + + workspaceKind, err := a.repositories.WorkspaceKind.GetWorkspaceKind(r.Context(), name) + if err != nil { + if errors.Is(err, repositories.ErrWorkspaceKindNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + workspaceKindEnvelope := WorkspaceKindEnvelope{ + Data: workspaceKind, + } + + err = a.WriteJSON(w, http.StatusOK, workspaceKindEnvelope, nil) + if err != nil { + a.serverErrorResponse(w, r, err) + } +} + +func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + workspaceKinds, err := a.repositories.WorkspaceKind.GetWorkspaceKinds(r.Context()) + if err != nil { + a.serverErrorResponse(w, r, err) + return + } + + workspaceKindsEnvelope := WorkspaceKindsEnvelope{ + Data: workspaceKinds, + } + + err = a.WriteJSON(w, http.StatusOK, workspaceKindsEnvelope, nil) + if err != nil { + a.serverErrorResponse(w, r, err) + } +} diff --git a/workspaces/backend/api/workspaces_handler.go b/workspaces/backend/api/workspaces_handler.go index 4494dabd..cb1c42bd 100644 --- a/workspaces/backend/api/workspaces_handler.go +++ b/workspaces/backend/api/workspaces_handler.go @@ -17,29 +17,138 @@ limitations under the License. package api import ( + "encoding/json" + "errors" + "fmt" "net/http" "github.com/julienschmidt/httprouter" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) -func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { +type WorkspacesEnvelope Envelope[[]models.WorkspaceModel] +type WorkspaceEnvelope Envelope[models.WorkspaceModel] +func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { namespace := ps.ByName(NamespacePathParam) + workspaceName := ps.ByName(WorkspaceNamePathParam) + + var workspace models.WorkspaceModel + var err error + if namespace == "" { + a.serverErrorResponse(w, r, fmt.Errorf("namespace is nil")) + return + } + if workspaceName == "" { + a.serverErrorResponse(w, r, fmt.Errorf("workspaceName is nil")) + return + } - workspaces, err := a.repositories.Workspace.GetWorkspaces(r.Context(), namespace) + workspace, err = a.repositories.Workspace.GetWorkspace(r.Context(), namespace, workspaceName) if err != nil { + if errors.Is(err, repositories.ErrWorkspaceNotFound) { + a.notFoundResponse(w, r) + return + } a.serverErrorResponse(w, r, err) return } - modelRegistryRes := Envelope{ - "workspaces": workspaces, + modelRegistryRes := WorkspaceEnvelope{ + Data: workspace, } err = a.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) + if err != nil { + a.serverErrorResponse(w, r, err) + } + +} + +func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + + var workspaces []models.WorkspaceModel + var err error + if namespace == "" { + workspaces, err = a.repositories.Workspace.GetAllWorkspaces(r.Context()) + } else { + workspaces, err = a.repositories.Workspace.GetWorkspaces(r.Context(), namespace) + } + if err != nil { + a.serverErrorResponse(w, r, err) + return + } + modelRegistryRes := WorkspacesEnvelope{ + Data: workspaces, + } + + err = a.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) if err != nil { a.serverErrorResponse(w, r, err) } +} + +func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName("namespace") + + if namespace == "" { + a.serverErrorResponse(w, r, fmt.Errorf("namespace is missing")) + return + } + + var workspaceModel models.WorkspaceModel + if err := json.NewDecoder(r.Body).Decode(&workspaceModel); err != nil { + a.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON: %v", err)) + return + } + + workspaceModel.Namespace = namespace + + createdWorkspace, err := a.repositories.Workspace.CreateWorkspace(r.Context(), workspaceModel) + if err != nil { + a.serverErrorResponse(w, r, fmt.Errorf("error creating workspace: %v", err)) + return + } + + // Return created workspace as JSON + workspaceEnvelope := WorkspaceEnvelope{ + Data: createdWorkspace, + } + + w.Header().Set("Location", r.URL.Path) + err = a.WriteJSON(w, http.StatusCreated, workspaceEnvelope, nil) + if err != nil { + a.serverErrorResponse(w, r, fmt.Errorf("error writing JSON: %v", err)) + } +} + +func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName("namespace") + workspaceName := ps.ByName("name") + + if namespace == "" { + a.serverErrorResponse(w, r, fmt.Errorf("namespace is missing")) + return + } + + if workspaceName == "" { + a.serverErrorResponse(w, r, fmt.Errorf("workspace name is missing")) + return + } + + err := a.repositories.Workspace.DeleteWorkspace(r.Context(), namespace, workspaceName) + if err != nil { + if errors.Is(err, repositories.ErrWorkspaceNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) } diff --git a/workspaces/backend/api/workspaces_handler_test.go b/workspaces/backend/api/workspaces_handler_test.go index 480fe8e5..88802075 100644 --- a/workspaces/backend/api/workspaces_handler_test.go +++ b/workspaces/backend/api/workspaces_handler_test.go @@ -18,25 +18,29 @@ package api import ( "encoding/json" "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "github.com/julienschmidt/httprouter" - "github.com/kubeflow/notebooks/workspaces/backend/internal/config" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "io" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "net/http" - "net/http/httptest" - "strings" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) var _ = Describe("Workspaces Handler", func() { - Context("with existing workspaces", Serial, Ordered, func() { + Context("with existing workspaces", Ordered, func() { - const namespaceName = "default" + const namespaceName1 = "namespace1" + const namespaceName2 = "namespace2" var ( a App @@ -44,6 +48,8 @@ var _ = Describe("Workspaces Handler", func() { workspaceKey1 types.NamespacedName workspaceName2 string workspaceKey2 types.NamespacedName + workspaceName3 string + workspaceKey3 types.NamespacedName workspaceKindName string ) @@ -51,6 +57,7 @@ var _ = Describe("Workspaces Handler", func() { uniqueName := "wsk-update-test" workspaceName1 = fmt.Sprintf("workspace1-%s", uniqueName) workspaceName2 = fmt.Sprintf("workspace2-%s", uniqueName) + workspaceName3 = fmt.Sprintf("workspace3-%s", uniqueName) workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) repos := repositories.NewRepositories(k8sClient) @@ -61,40 +68,69 @@ var _ = Describe("Workspaces Handler", func() { repositories: repos, } + By("creating namespaces") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) + + namespace2 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName2, + }, + } + Expect(k8sClient.Create(ctx, namespace2)).To(Succeed()) + By("creating a WorkspaceKind") workspaceKind := NewExampleWorkspaceKind(workspaceKindName) Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) - By("creating the Workspace1") - workspace1 := NewExampleWorkspace(workspaceName1, namespaceName, workspaceKindName) + By("creating the Workspace1 at namespaceName1") + workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName) Expect(k8sClient.Create(ctx, workspace1)).To(Succeed()) - workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName} + workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1} - By("creating the Workspace2") - workspace2 := NewExampleWorkspace(workspaceName2, namespaceName, workspaceKindName) + By("creating the Workspace2 at namespaceName1") + workspace2 := NewExampleWorkspace(workspaceName2, namespaceName1, workspaceKindName) Expect(k8sClient.Create(ctx, workspace2)).To(Succeed()) - workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName} + workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName1} + + By("creating the Workspace3 at namespaceName2") + workspace3 := NewExampleWorkspace(workspaceName3, namespaceName2, workspaceKindName) + Expect(k8sClient.Create(ctx, workspace3)).To(Succeed()) + workspaceKey3 = types.NamespacedName{Name: workspaceName3, Namespace: namespaceName2} }) AfterAll(func() { - By("deleting the Workspace1") + By("deleting the Workspace1 at namespaceName1") workspace1 := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName1, - Namespace: namespaceName, + Namespace: namespaceName1, }, } Expect(k8sClient.Delete(ctx, workspace1)).To(Succeed()) - By("deleting the Workspace2") + By("deleting the Workspace2 at namespaceName1") workspace2 := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName2, - Namespace: namespaceName, + Namespace: namespaceName1, }, } Expect(k8sClient.Delete(ctx, workspace2)).To(Succeed()) + By("deleting the Workspace3 at namespaceName2") + workspace3 := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName3, + Namespace: namespaceName2, + }, + } + Expect(k8sClient.Delete(ctx, workspace3)).To(Succeed()) + By("deleting the WorkspaceKind") workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ @@ -102,12 +138,81 @@ var _ = Describe("Workspaces Handler", func() { }, } Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) + + By("deleting the namespace1") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed()) + + By("deleting the namespace2") + namespace2 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName2, + }, + } + Expect(k8sClient.Delete(ctx, namespace2)).To(Succeed()) + }) + + It("should retrieve the workspaces from all namespaces successfully", func() { + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + + By("executing GetWorkspacesHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.GetWorkspacesHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + + By("unmarshalling the response JSON") + var response WorkspacesEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + + By("checking if 'workspaces' key exists in the response") + workspacesData := response.Data + + By("converting workspacesData to JSON and back to []WorkspaceModel") + workspacesJSON, err := json.Marshal(workspacesData) + Expect(err).NotTo(HaveOccurred(), "Error marshalling workspaces repositories") + + var workspaces []models.WorkspaceModel + err = json.Unmarshal(workspacesJSON, &workspaces) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON") + + By("asserting that the retrieved workspaces match the expected workspaces") + workspace1 := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace1)).To(Succeed()) + workspace2 := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey2, workspace2)).To(Succeed()) + workspace3 := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey3, workspace3)).To(Succeed()) + + expectedWorkspaces := []models.WorkspaceModel{ + models.NewWorkspaceModelFromWorkspace(workspace1), + models.NewWorkspaceModelFromWorkspace(workspace2), + models.NewWorkspaceModelFromWorkspace(workspace3), + } + Expect(workspaces).To(ConsistOf(expectedWorkspaces)) + }) - It("should retrieve the workspaces successfully", func() { + It("should retrieve the workspaces from namespaceName1 successfully", func() { By("creating the HTTP request") - path := strings.Replace(WorkspacesPath, ":"+NamespacePathParam, namespaceName, 1) + path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceName1, 1) req, err := http.NewRequest(http.MethodGet, path, nil) Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") @@ -115,13 +220,13 @@ var _ = Describe("Workspaces Handler", func() { ps := httprouter.Params{ httprouter.Param{ Key: NamespacePathParam, - Value: namespaceName, + Value: namespaceName1, }, } rr := httptest.NewRecorder() a.GetWorkspacesHandler(rr, req, ps) rs := rr.Result() - defer rs.Body.Close() + defer rs.Body.Close() // nolint: errcheck By("verifying the HTTP response status code") Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") @@ -131,16 +236,12 @@ var _ = Describe("Workspaces Handler", func() { Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") By("unmarshalling the response JSON") - var response Envelope + var response WorkspacesEnvelope err = json.Unmarshal(body, &response) Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") - By("checking if 'workspaces' key exists in the response") - workspacesData, ok := response["workspaces"] - Expect(ok).To(BeTrue(), "Response does not contain 'workspaces' key") - - By("converting workspacesData to JSON and back to []WorkspaceModel") - workspacesJSON, err := json.Marshal(workspacesData) + By("converting workspaces Data to JSON and back to []WorkspaceModel") + workspacesJSON, err := json.Marshal(response.Data) Expect(err).NotTo(HaveOccurred(), "Error marshalling workspaces repositories") var workspaces []models.WorkspaceModel @@ -164,7 +265,7 @@ var _ = Describe("Workspaces Handler", func() { }) Context("when there are no workspaces", func() { - const namespace = "default" + const otherNamespace = "otherNamespace" var a App BeforeEach(func() { @@ -176,10 +277,9 @@ var _ = Describe("Workspaces Handler", func() { repositories: repos, } }) - It("should return an empty list of workspaces", func() { By("creating the HTTP request") - path := strings.Replace(WorkspacesPath, ":"+NamespacePathParam, namespace, 1) + path := strings.Replace(AllWorkspacesPath, ":"+NamespacePathParam, otherNamespace, 1) req, err := http.NewRequest(http.MethodGet, path, nil) Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") @@ -187,13 +287,13 @@ var _ = Describe("Workspaces Handler", func() { ps := httprouter.Params{ httprouter.Param{ Key: NamespacePathParam, - Value: namespace, + Value: otherNamespace, }, } rr := httptest.NewRecorder() a.GetWorkspacesHandler(rr, req, ps) rs := rr.Result() - defer rs.Body.Close() + defer rs.Body.Close() // nolint: errcheck By("verifying the HTTP response status code") Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") @@ -203,16 +303,12 @@ var _ = Describe("Workspaces Handler", func() { Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") By("unmarshalling the response JSON") - var response Envelope + var response WorkspacesEnvelope err = json.Unmarshal(body, &response) Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") - By("checking if 'workspaces' key exists in the response") - workspacesData, ok := response["workspaces"] - Expect(ok).To(BeTrue(), "Response does not contain 'workspaces' key") - By("asserting that the 'workspaces' list is empty") - workspacesJSON, err := json.Marshal(workspacesData) + workspacesJSON, err := json.Marshal(response.Data) Expect(err).NotTo(HaveOccurred(), "Error marshalling workspaces data") var workspaces []models.WorkspaceModel @@ -221,4 +317,191 @@ var _ = Describe("Workspaces Handler", func() { Expect(workspaces).To(BeEmpty(), "Expected no workspaces in the response") }) }) + + Context("CRUD workspace", Ordered, func() { + + const namespaceNameCrud = "namespace-crud" + + var ( + a App + workspaceKindName string + ) + + BeforeAll(func() { + uniqueName := "wsk-update-test" + workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + + By("creating namespace") + namespaceA := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceNameCrud, + }, + } + Expect(k8sClient.Create(ctx, namespaceA)).To(Succeed()) + + By("creating a WorkspaceKind") + workspaceKind := NewExampleWorkspaceKind(workspaceKindName) + Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) + + }) + + AfterAll(func() { + + By("deleting the WorkspaceKind") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceKindName, + }, + } + Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) + + By("deleting the namespace") + namespaceA := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceNameCrud, + }, + } + Expect(k8sClient.Delete(ctx, namespaceA)).To(Succeed()) + + }) + + It("should create, retrieve and delete workspace successfully", func() { + + By("creating the workspace via the API") + workspaceName := "dora" + workspaceModel := models.WorkspaceModel{ + Name: workspaceName, + Namespace: namespaceNameCrud, + Paused: false, + DeferUpdates: false, + Kind: "jupyterlab", + ImageConfig: "jupyterlab_scipy_190", + PodConfig: "tiny_cpu", + HomeVolume: "workspace-home-bella", + DataVolumes: []models.DataVolumeModel{ + { + PvcName: "workspace-data-bella", + MountPath: "/data/my-data", + ReadOnly: false, + }, + }, + Labels: map[string]string{ + "app": "jupyter", + }, + Annotations: map[string]string{ + "environment": "dev", + }, + } + + workspaceJSON, err := json.Marshal(workspaceModel) + Expect(err).NotTo(HaveOccurred(), "Failed to marshal WorkspaceModel to JSON") + path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1) + + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(workspaceJSON))) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + ps := httprouter.Params{ + httprouter.Param{ + Key: NamespacePathParam, + Value: namespaceNameCrud, + }, + } + + a.CreateWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code for creation") + Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Expected HTTP status 201 Created") + + By("retrieving the created workspace via the API") + path = strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceNameCrud, 1) + path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName, 1) + + ps = httprouter.Params{ + httprouter.Param{ + Key: NamespacePathParam, + Value: namespaceNameCrud, + }, + httprouter.Param{ + Key: WorkspaceNamePathParam, + Value: workspaceName, + }, + } + + req, err = http.NewRequest(http.MethodGet, path, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + rr = httptest.NewRecorder() + + a.GetWorkspaceHandler(rr, req, ps) + rs = rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code for retrieval") + Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + + By("reading the HTTP response body for retrieval") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + + By("unmarshalling the response JSON for retrieval") + var response WorkspaceEnvelope + + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + + //remove auto generated fields from comparison + response.Data.LastActivity = "" + + By("checking if the retrieved workspace matches the expected workspace") + retrievedWorkspaceJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "Failed to marshal retrieved workspace to JSON") + + originalWorkspaceJSON, err := json.Marshal(workspaceModel) + Expect(err).NotTo(HaveOccurred(), "Failed to marshal original workspace to JSON") + + Expect(retrievedWorkspaceJSON).To(MatchJSON(originalWorkspaceJSON), "The retrieved workspace does not match the created one") + + By("deleting the workspace via the API") + req, err = http.NewRequest(http.MethodDelete, path, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request for deletion") + + rr = httptest.NewRecorder() + a.DeleteWorkspaceHandler(rr, req, ps) + rs = rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code for deletion") + Expect(rs.StatusCode).To(Equal(http.StatusNoContent), "Expected HTTP status 204 No Content") + + By("verifying the workspace has been deleted") + req, err = http.NewRequest(http.MethodGet, path, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + rr = httptest.NewRecorder() + + a.GetWorkspaceHandler(rr, req, ps) + rs = rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code for not found") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), "Expected HTTP status 200 OK") + + By("double check via k9client") + workspace := &kubefloworgv1beta1.Workspace{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "dora", Namespace: namespaceNameCrud}, workspace) + Expect(err).To(HaveOccurred(), "Expected error when retrieving the deleted workspace") + Expect(err).To(MatchError(`workspaces.kubeflow.org "dora" not found`)) + + }) + }) }) diff --git a/workspaces/backend/api/workspaceskind_handler_test.go b/workspaces/backend/api/workspaceskind_handler_test.go new file mode 100644 index 00000000..f0b661cb --- /dev/null +++ b/workspaces/backend/api/workspaceskind_handler_test.go @@ -0,0 +1,267 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/julienschmidt/httprouter" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" +) + +var _ = Describe("WorkspaceKinds Handler", func() { + Context("with existing workspacekinds", Ordered, func() { + + const namespaceName1 = "namespace-kind" + + var ( + a App + workspaceKind1Name string + workspaceKind2Name string + workspaceKind1Key types.NamespacedName + workspaceKind2Key types.NamespacedName + ) + + BeforeAll(func() { + uniqueName := "wskind-update-test" + workspaceKind1Name = fmt.Sprintf("workspacekind1-%s", uniqueName) + workspaceKind1Key = types.NamespacedName{Name: workspaceKind1Name} + workspaceKind2Name = fmt.Sprintf("workspacekind2-%s", uniqueName) + workspaceKind2Key = types.NamespacedName{Name: workspaceKind2Name} + + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + + By("creating namespaces") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) + + By("creating a WorkspaceKind1") + workspaceKind1 := NewExampleWorkspaceKind(workspaceKind1Name) + Expect(k8sClient.Create(ctx, workspaceKind1)).To(Succeed()) + + By("creating a WorkspaceKind1") + workspaceKind2 := NewExampleWorkspaceKind(workspaceKind2Name) + Expect(k8sClient.Create(ctx, workspaceKind2)).To(Succeed()) + + }) + + AfterAll(func() { + + By("deleting the WorkspaceKind1") + workspaceKind1 := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceKind1Name, + }, + } + Expect(k8sClient.Delete(ctx, workspaceKind1)).To(Succeed()) + + By("deleting the WorkspaceKind2") + workspaceKind2 := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceKind2Name, + }, + } + Expect(k8sClient.Delete(ctx, workspaceKind2)).To(Succeed()) + + By("deleting the namespace1") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed()) + + }) + + It("should retrieve the all workspacekinds successfully", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + + By("executing GetWorkspaceKindsHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.GetWorkspaceKindsHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + + By("unmarshalling the response JSON") + var response WorkspaceKindsEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + + By("retrieving workspaceKindsData in the response") + workspaceKindsData := response.Data + + By("converting workspaceKindsData to JSON and back to []WorkspaceKindsModel") + workspaceKindsJSON, err := json.Marshal(workspaceKindsData) + Expect(err).NotTo(HaveOccurred(), "Error marshalling workspaces repositories") + + var workspaceKinds []models.WorkspaceKindModel + err = json.Unmarshal(workspaceKindsJSON, &workspaceKinds) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON") + + By("asserting that the retrieved workspaces kinds match the expected workspacekinds") + workspacekind1 := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed()) + workspacekind2 := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKind2Key, workspacekind2)).To(Succeed()) + + expectedWorkspaceKinds := []models.WorkspaceKindModel{ + models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1), + models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind2), + } + Expect(workspaceKinds).To(ConsistOf(expectedWorkspaceKinds)) + }) + + It("should retrieve a single workspacekind successfully", func() { + By("creating the HTTP request") + path := strings.Replace(WorkspaceKindsByNamePath, ":name", workspaceKind1Name, 1) + req, err := http.NewRequest(http.MethodGet, path, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + + By("executing GetWorkspaceKindHandler") + ps := httprouter.Params{ + httprouter.Param{Key: "name", Value: workspaceKind1Name}, + } + rr := httptest.NewRecorder() + a.GetWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + + By("unmarshalling the response JSON") + var response WorkspaceKindEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + + By("retrieving workspaceKindData in the response") + workspaceKindData := response.Data + + By("comparing the retrieved workspacekind with the expected") + workspacekind1 := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed()) + + expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1) + Expect(workspaceKindData).To(Equal(expectedWorkspaceKind)) + }) + + }) + + Context("when there are no workspacekinds ", func() { + + var a App + + BeforeEach(func() { + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + }) + It("should return an empty list of workspacekinds", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, AllWorkspaceKindsPath, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + + By("executing GetWorkspacesHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.GetWorkspaceKindsHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + + By("unmarshalling the response JSON") + var response WorkspaceKindsEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + + By("asserting that the 'workspaces' list is empty") + workspaceskindsJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "Error marshalling workspaces data") + + var workspaceKinds []models.WorkspaceKindModel + err = json.Unmarshal(workspaceskindsJSON, &workspaceKinds) + Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON") + Expect(workspaceKinds).To(BeEmpty(), "Expected no workspaces in the response") + }) + + It("should return 404 for a non-existent workspacekind", func() { + By("creating the HTTP request for a non-existent workspacekind") + path := strings.Replace(WorkspaceKindsByNamePath, ":name", "non-existent-workspacekind", 1) + req, err := http.NewRequest(http.MethodGet, path, nil) + Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + + By("executing GetWorkspaceKindHandler") + ps := httprouter.Params{ + httprouter.Param{Key: "name", Value: "non-existent-workspacekind"}, + } + rr := httptest.NewRecorder() + a.GetWorkspaceKindHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() // nolint: errcheck + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), "Expected HTTP status 404 Not Found") + }) + }) +}) diff --git a/workspaces/backend/internal/models/workspacekinds.go b/workspaces/backend/internal/models/workspacekinds.go new file mode 100644 index 00000000..9a00f93e --- /dev/null +++ b/workspaces/backend/internal/models/workspacekinds.go @@ -0,0 +1,99 @@ +/* + * + * Copyright 2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +package models + +import ( + "strings" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" +) + +type WorkspaceKindModel struct { + Name string `json:"name"` + PodTemplate PodTemplateModel `json:"pod_template"` + Spawner SpawnerModel `json:"spawner"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type PodTemplateModel struct { + ImageConfig string `json:"image_config"` + PodConfig string `json:"pod_config"` + Resources ResourceModel `json:"resources"` +} + +type ResourceModel struct { + Cpu string `json:"cpu"` + Memory string `json:"memory"` +} + +type SpawnerModel struct { + DisplayName string `json:"display_name"` + Description string `json:"description"` + Deprecated bool `json:"deprecated"` + DeprecationMessage string `json:"deprecation_message"` + Hidden bool `json:"hidden"` +} + +func NewWorkspaceKindModelFromWorkspaceKind(item *kubefloworgv1beta1.WorkspaceKind) WorkspaceKindModel { + deprecated := false + if item.Spec.Spawner.Deprecated != nil { + deprecated = *item.Spec.Spawner.Deprecated + } + + hidden := false + if item.Spec.Spawner.Hidden != nil { + hidden = *item.Spec.Spawner.Hidden + } + + deprecationMessage := "" + if item.Spec.Spawner.DeprecationMessage != nil { + deprecationMessage = *item.Spec.Spawner.DeprecationMessage + } + + cpuValues := make([]string, len(item.Spec.PodTemplate.Options.PodConfig.Values)) + memoryValues := make([]string, len(item.Spec.PodTemplate.Options.PodConfig.Values)) + for i, value := range item.Spec.PodTemplate.Options.PodConfig.Values { + cpuValues[i] = value.Spec.Resources.Requests.Cpu().String() + memoryValues[i] = value.Spec.Resources.Requests.Memory().String() + } + + workspaceKindModel := WorkspaceKindModel{ + Name: item.Name, + Labels: item.Labels, + Annotations: item.Annotations, + Spawner: SpawnerModel{ + DisplayName: item.Spec.Spawner.DisplayName, + Description: item.Spec.Spawner.Description, + Deprecated: deprecated, + DeprecationMessage: deprecationMessage, + Hidden: hidden, + }, + PodTemplate: PodTemplateModel{ + ImageConfig: item.Spec.PodTemplate.Options.ImageConfig.Spawner.Default, + PodConfig: strings.Join(cpuValues, ",") + "|" + strings.Join(memoryValues, ","), + Resources: ResourceModel{ + Cpu: strings.Join(cpuValues, ", "), + Memory: strings.Join(memoryValues, ", "), + }, + }, + } + + return workspaceKindModel +} diff --git a/workspaces/backend/internal/models/workspaces.go b/workspaces/backend/internal/models/workspaces.go index fee2daaf..9ab4280f 100644 --- a/workspaces/backend/internal/models/workspaces.go +++ b/workspaces/backend/internal/models/workspaces.go @@ -19,39 +19,59 @@ package models import ( - kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" - "strings" "time" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" ) type WorkspaceModel struct { - Name string `json:"name"` - Kind string `json:"kind"` - Image string `json:"image"` - Config string `json:"config"` - Status string `json:"status"` - HomeVolume string `json:"home_volume"` - DataVolume string `json:"data_volume"` - LastActivity string `json:"last_activity"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Paused bool `json:"paused"` + DeferUpdates bool `json:"defer_updates"` + Kind string `json:"kind"` + ImageConfig string `json:"image_config"` + PodConfig string `json:"pod_config"` + HomeVolume string `json:"home_volume"` + DataVolumes []DataVolumeModel `json:"data_volumes"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Status string `json:"status"` + LastActivity string `json:"last_activity"` +} + +type DataVolumeModel struct { + PvcName string `json:"pvc_name"` + MountPath string `json:"mount_path"` + ReadOnly bool `json:"read_only"` } func NewWorkspaceModelFromWorkspace(item *kubefloworgv1beta1.Workspace) WorkspaceModel { t := time.Unix(item.Status.Activity.LastActivity, 0) formattedLastActivity := t.Format("2006-01-02 15:04:05 MST") - mountPaths := make([]string, 0, len(item.Spec.PodTemplate.Volumes.Data)) - for _, volume := range item.Spec.PodTemplate.Volumes.Data { - mountPaths = append(mountPaths, volume.MountPath) + dataVolumes := make([]DataVolumeModel, len(item.Spec.PodTemplate.Volumes.Data)) + for i, volume := range item.Spec.PodTemplate.Volumes.Data { + dataVolumes[i] = DataVolumeModel{ + PvcName: volume.PVCName, + MountPath: volume.MountPath, + ReadOnly: *volume.ReadOnly, + } } - + // TODO: review all fields workspaceModel := WorkspaceModel{ + Namespace: item.Namespace, Name: item.ObjectMeta.Name, + Paused: *item.Spec.Paused, + DeferUpdates: *item.Spec.DeferUpdates, Kind: item.Spec.Kind, - Image: item.Spec.PodTemplate.Options.ImageConfig, - Config: item.Spec.PodTemplate.Options.PodConfig, + ImageConfig: item.Spec.PodTemplate.Options.ImageConfig, + PodConfig: item.Spec.PodTemplate.Options.PodConfig, HomeVolume: *item.Spec.PodTemplate.Volumes.Home, + DataVolumes: dataVolumes, + Labels: item.ObjectMeta.Labels, + Annotations: item.ObjectMeta.Annotations, Status: string(item.Status.State), - DataVolume: strings.Join(mountPaths, ","), LastActivity: formattedLastActivity, } return workspaceModel diff --git a/workspaces/backend/internal/repositories/repositories.go b/workspaces/backend/internal/repositories/repositories.go index f39c7a2f..f3dccbc8 100644 --- a/workspaces/backend/internal/repositories/repositories.go +++ b/workspaces/backend/internal/repositories/repositories.go @@ -24,13 +24,15 @@ import ( // Models struct is a single convenient container to hold and represent all our repositories. type Repositories struct { - HealthCheck *HealthCheckRepository - Workspace *WorkspaceRepository + HealthCheck *HealthCheckRepository + Workspace *WorkspaceRepository + WorkspaceKind *WorkspaceKindRepository } func NewRepositories(client client.Client) *Repositories { return &Repositories{ - HealthCheck: NewHealthCheckRepository(), - Workspace: NewWorkspaceRepository(client), + HealthCheck: NewHealthCheckRepository(), + Workspace: NewWorkspaceRepository(client), + WorkspaceKind: NewWorkspaceKindRepository(client), } } diff --git a/workspaces/backend/internal/repositories/workspacekinds.go b/workspaces/backend/internal/repositories/workspacekinds.go new file mode 100644 index 00000000..3f591fd2 --- /dev/null +++ b/workspaces/backend/internal/repositories/workspacekinds.go @@ -0,0 +1,73 @@ +/* + * + * Copyright 2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +package repositories + +import ( + "context" + "errors" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" +) + +var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") + +type WorkspaceKindRepository struct { + client client.Client +} + +func NewWorkspaceKindRepository(client client.Client) *WorkspaceKindRepository { + return &WorkspaceKindRepository{ + client: client, + } +} + +func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name string) (models.WorkspaceKindModel, error) { + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + err := r.client.Get(ctx, client.ObjectKey{Name: name}, workspaceKind) + if err != nil { + if apierrors.IsNotFound(err) { + return models.WorkspaceKindModel{}, ErrWorkspaceKindNotFound + } + return models.WorkspaceKindModel{}, err + } + + workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) + return workspaceKindModel, nil +} + +func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKindModel, error) { + workspaceKindList := &kubefloworgv1beta1.WorkspaceKindList{} + + err := r.client.List(ctx, workspaceKindList) + if err != nil { + return nil, err + } + + workspaceKindsModels := make([]models.WorkspaceKindModel, len(workspaceKindList.Items)) + for i, item := range workspaceKindList.Items { + workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(&item) + workspaceKindsModels[i] = workspaceKindModel + } + + return workspaceKindsModels, nil +} diff --git a/workspaces/backend/internal/repositories/workspaces.go b/workspaces/backend/internal/repositories/workspaces.go index 81f523bf..330d83f1 100644 --- a/workspaces/backend/internal/repositories/workspaces.go +++ b/workspaces/backend/internal/repositories/workspaces.go @@ -18,11 +18,18 @@ package repositories import ( "context" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + "errors" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models" ) +var ErrWorkspaceNotFound = errors.New("workspace not found") + type WorkspaceRepository struct { client client.Client } @@ -33,6 +40,23 @@ func NewWorkspaceRepository(client client.Client) *WorkspaceRepository { } } +func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string, workspaceName string) (models.WorkspaceModel, error) { + workspace := &kubefloworgv1beta1.Workspace{} + err := r.client.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: workspaceName, + }, workspace) + if err != nil { + if apierrors.IsNotFound(err) { + return models.WorkspaceModel{}, ErrWorkspaceNotFound + } + return models.WorkspaceModel{}, err + } + + workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace) + return workspaceModel, nil +} + func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.WorkspaceModel, error) { workspaceList := &kubefloworgv1beta1.WorkspaceList{} listOptions := []client.ListOption{ @@ -43,12 +67,96 @@ func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace strin return nil, err } - workspacesModels := make([]models.WorkspaceModel, 0, len(workspaceList.Items)) + workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items)) + for i, item := range workspaceList.Items { + workspaceModel := models.NewWorkspaceModelFromWorkspace(&item) + workspacesModels[i] = workspaceModel + } + + return workspacesModels, nil +} + +func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.WorkspaceModel, error) { + workspaceList := &kubefloworgv1beta1.WorkspaceList{} + + err := r.client.List(ctx, workspaceList) + if err != nil { + return nil, err + } - for _, item := range workspaceList.Items { + workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items)) + for i, item := range workspaceList.Items { workspaceModel := models.NewWorkspaceModelFromWorkspace(&item) - workspacesModels = append(workspacesModels, workspaceModel) + workspacesModels[i] = workspaceModel } return workspacesModels, nil } + +func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceModel models.WorkspaceModel) (models.WorkspaceModel, error) { + // TODO: review all fields + workspace := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceModel.Name, + Namespace: workspaceModel.Namespace, + // TODO: the pod and workspace labels should be separated + Labels: workspaceModel.Labels, + Annotations: workspaceModel.Annotations, + }, + Spec: kubefloworgv1beta1.WorkspaceSpec{ + Paused: &workspaceModel.Paused, + DeferUpdates: &workspaceModel.DeferUpdates, + // TODO: verify if workspace kind exists on validation + Kind: workspaceModel.Kind, + PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{ + PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{ + Labels: workspaceModel.Labels, + Annotations: workspaceModel.Annotations, + }, + Volumes: kubefloworgv1beta1.WorkspacePodVolumes{ + Home: &workspaceModel.HomeVolume, + Data: []kubefloworgv1beta1.PodVolumeMount{}, + }, + Options: kubefloworgv1beta1.WorkspacePodOptions{ + ImageConfig: workspaceModel.ImageConfig, + PodConfig: workspaceModel.PodConfig, + }, + }, + }, + } + + // TODO: create data volumes if necessary + workspace.Spec.PodTemplate.Volumes.Data = make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.DataVolumes)) + for i, dataVolume := range workspaceModel.DataVolumes { + // make a copy of readOnly because dataVolume is reassigned each loop + readOnly := dataVolume.ReadOnly + workspace.Spec.PodTemplate.Volumes.Data[i] = kubefloworgv1beta1.PodVolumeMount{ + PVCName: dataVolume.PvcName, + MountPath: dataVolume.MountPath, + ReadOnly: &readOnly, + } + } + if err := r.client.Create(ctx, workspace); err != nil { + return models.WorkspaceModel{}, err + } + + return models.NewWorkspaceModelFromWorkspace(workspace), nil +} + +func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, workspaceName string) error { + workspace := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: workspaceName, + }, + } + + if err := r.client.Delete(ctx, workspace); err != nil { + if apierrors.IsNotFound(err) { + return ErrWorkspaceNotFound + } + return err + } + + return nil +} diff --git a/workspaces/backend/internal/server/server.go b/workspaces/backend/internal/server/server.go index 5f264f7e..e597b156 100644 --- a/workspaces/backend/internal/server/server.go +++ b/workspaces/backend/internal/server/server.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" - "github.com/kubeflow/notebooks/workspaces/backend/api" "log/slog" "net" "net/http" - ctrl "sigs.k8s.io/controller-runtime" "time" + + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/kubeflow/notebooks/workspaces/backend/api" ) type Server struct {