From 710cbd4753c765ac5e136c9b7d06d8eb9826d4e9 Mon Sep 17 00:00:00 2001 From: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> Date: Tue, 4 Feb 2025 09:20:37 -0800 Subject: [PATCH] feat(ws): refactor backend models and repositories (#192) Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> --- workspaces/backend/api/app.go | 8 +- .../backend/api/healthcheck__handler_test.go | 70 -- workspaces/backend/api/healthcheck_handler.go | 3 +- .../backend/api/healthcheck_handler_test.go | 85 ++ workspaces/backend/api/namespaces_handler.go | 4 +- .../backend/api/namespaces_handler_test.go | 78 +- .../backend/api/workspacekinds_handler.go | 11 +- .../api/workspacekinds_handler_test.go | 148 ++-- workspaces/backend/api/workspaces_handler.go | 19 +- .../backend/api/workspaces_handler_test.go | 729 ++++++++++++------ .../types.go} | 17 +- .../internal/models/namespaces/funcs.go | 26 + .../{namespaces.go => namespaces/types.go} | 10 +- .../backend/internal/models/workspacekinds.go | 97 --- .../internal/models/workspacekinds/funcs.go | 155 ++++ .../internal/models/workspacekinds/types.go | 104 +++ .../backend/internal/models/workspaces.go | 86 --- .../internal/models/workspaces/funcs.go | 328 ++++++++ .../internal/models/workspaces/types.go | 143 ++++ .../{health_check.go => health_check/repo.go} | 13 +- .../{namespaces.go => namespaces/repo.go} | 13 +- .../internal/repositories/repositories.go | 24 +- .../repo.go} | 20 +- .../internal/repositories/workspaces.go | 259 ------- .../internal/repositories/workspaces/repo.go | 206 +++++ 25 files changed, 1739 insertions(+), 917 deletions(-) delete mode 100644 workspaces/backend/api/healthcheck__handler_test.go create mode 100644 workspaces/backend/api/healthcheck_handler_test.go rename workspaces/backend/internal/models/{health_check.go => health_check/types.go} (68%) create mode 100644 workspaces/backend/internal/models/namespaces/funcs.go rename workspaces/backend/internal/models/{namespaces.go => namespaces/types.go} (78%) delete mode 100644 workspaces/backend/internal/models/workspacekinds.go create mode 100644 workspaces/backend/internal/models/workspacekinds/funcs.go create mode 100644 workspaces/backend/internal/models/workspacekinds/types.go delete mode 100644 workspaces/backend/internal/models/workspaces.go create mode 100644 workspaces/backend/internal/models/workspaces/funcs.go create mode 100644 workspaces/backend/internal/models/workspaces/types.go rename workspaces/backend/internal/repositories/{health_check.go => health_check/repo.go} (75%) rename workspaces/backend/internal/repositories/{namespaces.go => namespaces/repo.go} (77%) rename workspaces/backend/internal/repositories/{workspacekinds.go => workspacekinds/repo.go} (74%) delete mode 100644 workspaces/backend/internal/repositories/workspaces.go create mode 100644 workspaces/backend/internal/repositories/workspaces/repo.go diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index e54bf580..bc38b5fa 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -77,16 +77,20 @@ func (a *App) Routes() http.Handler { router.NotFound = http.HandlerFunc(a.notFoundResponse) router.MethodNotAllowed = http.HandlerFunc(a.methodNotAllowedResponse) - router.GET(HealthCheckPath, a.HealthcheckHandler) + // healthcheck + router.GET(HealthCheckPath, a.GetHealthcheckHandler) + + // namespaces router.GET(AllNamespacesPath, a.GetNamespacesHandler) + // workspaces 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) + // workspacekinds router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) diff --git a/workspaces/backend/api/healthcheck__handler_test.go b/workspaces/backend/api/healthcheck__handler_test.go deleted file mode 100644 index 38ae2496..00000000 --- a/workspaces/backend/api/healthcheck__handler_test.go +++ /dev/null @@ -1,70 +0,0 @@ -/* -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" - "io" - "net/http" - "net/http/httptest" - "testing" - - "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) { - app := App{ - Config: config.EnvConfig{ - Port: 4000, - }, - repositories: repositories.NewRepositories(k8sClient), - } - - rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, HealthCheckPath, http.NoBody) - if err != nil { - t.Fatal(err) - } - - app.HealthcheckHandler(rr, req, nil) - rs := rr.Result() - defer rs.Body.Close() - - body, err := io.ReadAll(rs.Body) - if err != nil { - t.Fatal("Failed to read response body") - } - - var healthCheckRes models.HealthCheckModel - err = json.Unmarshal(body, &healthCheckRes) - if err != nil { - t.Fatalf("Error unmarshalling response JSON: %v", err) - } - - expected := models.HealthCheckModel{ - Status: "available", - SystemInfo: models.SystemInfo{ - Version: Version, - }, - } - - assert.Equal(t, expected, healthCheckRes) -} diff --git a/workspaces/backend/api/healthcheck_handler.go b/workspaces/backend/api/healthcheck_handler.go index 5ed1a2a6..dd61673e 100644 --- a/workspaces/backend/api/healthcheck_handler.go +++ b/workspaces/backend/api/healthcheck_handler.go @@ -22,7 +22,7 @@ import ( "github.com/julienschmidt/httprouter" ) -func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { +func (a *App) GetHealthcheckHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { healthCheck, err := a.repositories.HealthCheck.HealthCheck(Version) if err != nil { @@ -34,5 +34,4 @@ func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps http if err != nil { a.serverErrorResponse(w, r, err) } - } diff --git a/workspaces/backend/api/healthcheck_handler_test.go b/workspaces/backend/api/healthcheck_handler_test.go new file mode 100644 index 00000000..3df32e5f --- /dev/null +++ b/workspaces/backend/api/healthcheck_handler_test.go @@ -0,0 +1,85 @@ +/* +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" + "io" + "net/http" + "net/http/httptest" + + "github.com/julienschmidt/httprouter" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/health_check" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" +) + +var _ = Describe("HealthCheck Handler", func() { + var ( + a App + ) + + Context("when backend is healthy", func() { + + BeforeEach(func() { + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + }) + + It("should return a health check response", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodGet, HealthCheckPath, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetHealthCheckHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.GetHealthcheckHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to HealthCheck") + var response models.HealthCheck + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("ensuring that the health check is as expected") + expected := models.HealthCheck{ + Status: models.ServiceStatusHealthy, + SystemInfo: models.SystemInfo{ + Version: Version, + }, + } + Expect(response).To(BeComparableTo(expected)) + }) + }) +}) diff --git a/workspaces/backend/api/namespaces_handler.go b/workspaces/backend/api/namespaces_handler.go index ffb12d17..d178e927 100644 --- a/workspaces/backend/api/namespaces_handler.go +++ b/workspaces/backend/api/namespaces_handler.go @@ -21,10 +21,10 @@ import ( "github.com/julienschmidt/httprouter" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces" ) -type NamespacesEnvelope Envelope[[]models.NamespaceModel] +type NamespacesEnvelope Envelope[[]models.Namespace] func (a *App) GetNamespacesHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { diff --git a/workspaces/backend/api/namespaces_handler_test.go b/workspaces/backend/api/namespaces_handler_test.go index 1d2911ad..3d414d39 100644 --- a/workspaces/backend/api/namespaces_handler_test.go +++ b/workspaces/backend/api/namespaces_handler_test.go @@ -22,43 +22,40 @@ import ( "net/http" "net/http/httptest" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/julienschmidt/httprouter" . "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" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) var _ = Describe("Namespaces Handler", func() { var ( - a App - testRouter *httprouter.Router + a App ) - BeforeEach(func() { - repos := repositories.NewRepositories(k8sClient) - a = App{ - Config: config.EnvConfig{ - Port: 4000, - }, - repositories: repos, - } + // 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("when namespaces exist", Serial, func() { - testRouter = httprouter.New() - testRouter.GET("/api/namespaces", a.GetNamespacesHandler) - }) - - Context("when namespaces exist", func() { - const namespaceName1 = "namespaceone" - const namespaceName2 = "namespacetwo" + const namespaceName1 = "get-ns-test-ns1" + const namespaceName2 = "get-ns-test-ns2" BeforeEach(func() { - By("creating namespaces") + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + + By("creating Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -66,18 +63,17 @@ var _ = Describe("Namespaces Handler", func() { } Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) + By("creating Namespace 2") namespace2 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName2, }, } Expect(k8sClient.Create(ctx, namespace2)).To(Succeed()) - }) AfterEach(func() { - By("deleting namespaces") - By("deleting the namespace1") + By("deleting Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -85,7 +81,7 @@ var _ = Describe("Namespaces Handler", func() { } Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed()) - By("deleting the namespace2") + By("deleting Namespace 2") namespace2 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName2, @@ -96,32 +92,40 @@ var _ = Describe("Namespaces Handler", func() { It("should retrieve all namespaces successfully", func() { By("creating the HTTP request") - req, err := http.NewRequest(http.MethodGet, "/api/namespaces", http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + req, err := http.NewRequest(http.MethodGet, AllNamespacesPath, http.NoBody) + Expect(err).NotTo(HaveOccurred()) By("executing GetNamespacesHandler") + ps := httprouter.Params{} rr := httptest.NewRecorder() - testRouter.ServeHTTP(rr, req) + a.GetNamespacesHandler(rr, req, ps) rs := rr.Result() defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to NamespacesEnvelope") var response NamespacesEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("asserting that the created namespaces are in the response") + By("getting the Namespaces from the Kubernetes API") + namespace1 := &corev1.Namespace{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: namespaceName1}, namespace1)).To(Succeed()) + namespace2 := &corev1.Namespace{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: namespaceName2}, namespace2)).To(Succeed()) + + By("ensuring the response contains the expected Namespaces") + // NOTE: we use `ContainElements` instead of `ConsistOf` because envtest creates some namespaces by default Expect(response.Data).To(ContainElements( - models.NamespaceModel{Name: namespaceName1}, - models.NamespaceModel{Name: namespaceName2}, - ), "Expected created namespaces to be in the response") + models.NewNamespaceModelFromNamespace(namespace1), + models.NewNamespaceModelFromNamespace(namespace2), + )) }) }) }) diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go index ae4ad8ce..c5119440 100644 --- a/workspaces/backend/api/workspacekinds_handler.go +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -23,12 +23,13 @@ import ( "github.com/julienschmidt/httprouter" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" + repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds" ) -type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKindModel] -type WorkspaceKindEnvelope Envelope[models.WorkspaceKindModel] +type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKind] + +type WorkspaceKindEnvelope Envelope[models.WorkspaceKind] func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { name := ps.ByName("name") @@ -40,7 +41,7 @@ func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps workspaceKind, err := a.repositories.WorkspaceKind.GetWorkspaceKind(r.Context(), name) if err != nil { - if errors.Is(err, repositories.ErrWorkspaceKindNotFound) { + if errors.Is(err, repository.ErrWorkspaceKindNotFound) { a.notFoundResponse(w, r) return } diff --git a/workspaces/backend/api/workspacekinds_handler_test.go b/workspaces/backend/api/workspacekinds_handler_test.go index a9519178..8d19f261 100644 --- a/workspaces/backend/api/workspacekinds_handler_test.go +++ b/workspaces/backend/api/workspacekinds_handler_test.go @@ -33,28 +33,33 @@ import ( "k8s.io/apimachinery/pkg/types" "github.com/kubeflow/notebooks/workspaces/backend/internal/config" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) var _ = Describe("WorkspaceKinds Handler", func() { - Context("with existing workspacekinds", Ordered, func() { - const namespaceName1 = "namespace-kind" + // NOTE: the tests in this context work on the same resources, they must be run in order. + // also, they assume a specific state of the cluster, so cannot be run in parallel with other tests. + // therefore, we run them using the `Ordered` and `Serial` Ginkgo decorators. + Context("with existing WorkspaceKinds", Serial, Ordered, func() { + + const namespaceName1 = "wsk-exist-test-ns1" var ( - a App + a App + workspaceKind1Name string - workspaceKind2Name string workspaceKind1Key types.NamespacedName + workspaceKind2Name string workspaceKind2Key types.NamespacedName ) BeforeAll(func() { - uniqueName := "wskind-update-test" - workspaceKind1Name = fmt.Sprintf("workspacekind1-%s", uniqueName) + uniqueName := "wsk-exist-test" + workspaceKind1Name = fmt.Sprintf("workspacekind-1-%s", uniqueName) workspaceKind1Key = types.NamespacedName{Name: workspaceKind1Name} - workspaceKind2Name = fmt.Sprintf("workspacekind2-%s", uniqueName) + workspaceKind2Name = fmt.Sprintf("workspacekind-2-%s", uniqueName) workspaceKind2Key = types.NamespacedName{Name: workspaceKind2Name} repos := repositories.NewRepositories(k8sClient) @@ -65,7 +70,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { repositories: repos, } - By("creating namespaces") + By("creating Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -73,19 +78,17 @@ var _ = Describe("WorkspaceKinds Handler", func() { } Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) - By("creating a WorkspaceKind1") + By("creating WorkspaceKind 1") workspaceKind1 := NewExampleWorkspaceKind(workspaceKind1Name) Expect(k8sClient.Create(ctx, workspaceKind1)).To(Succeed()) - By("creating a WorkspaceKind1") + By("creating WorkspaceKind 2") workspaceKind2 := NewExampleWorkspaceKind(workspaceKind2Name) Expect(k8sClient.Create(ctx, workspaceKind2)).To(Succeed()) - }) AfterAll(func() { - - By("deleting the WorkspaceKind1") + By("deleting WorkspaceKind 1") workspaceKind1 := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceKind1Name, @@ -93,7 +96,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { } Expect(k8sClient.Delete(ctx, workspaceKind1)).To(Succeed()) - By("deleting the WorkspaceKind2") + By("deleting WorkspaceKind 2") workspaceKind2 := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceKind2Name, @@ -101,7 +104,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { } Expect(k8sClient.Delete(ctx, workspaceKind2)).To(Succeed()) - By("deleting the namespace1") + By("deleting Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -111,10 +114,10 @@ var _ = Describe("WorkspaceKinds Handler", func() { }) - It("should retrieve the all workspacekinds successfully", func() { + It("should retrieve the all WorkspaceKinds successfully", func() { By("creating the HTTP request") - req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + req, err := http.NewRequest(http.MethodGet, AllWorkspaceKindsPath, http.NoBody) + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspaceKindsHandler") ps := httprouter.Params{} @@ -124,50 +127,46 @@ var _ = Describe("WorkspaceKinds Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspaceKindsEnvelope") var response WorkspaceKindsEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - 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 marshaling 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") + By("getting the WorkspaceKinds from the Kubernetes API") 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{ + By("ensuring the response contains the expected WorkspaceKinds") + Expect(response.Data).To(ConsistOf( models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1), models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind2), - } - Expect(workspaceKinds).To(ConsistOf(expectedWorkspaceKinds)) + )) + + By("ensuring the wrapped data can be marshaled to JSON and back to []WorkspaceKind") + dataJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON") + var dataObject []models.WorkspaceKind + err = json.Unmarshal(dataJSON, &dataObject) + Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []WorkspaceKind") }) - It("should retrieve a single workspacekind successfully", func() { + It("should retrieve a single WorkspaceKind successfully", func() { By("creating the HTTP request") - path := strings.Replace(WorkspaceKindsByNamePath, ":name", workspaceKind1Name, 1) + path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceKindNamePathParam, workspaceKind1Name, 1) req, err := http.NewRequest(http.MethodGet, path, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspaceKindHandler") ps := httprouter.Params{ - httprouter.Param{Key: "name", Value: workspaceKind1Name}, + httprouter.Param{Key: WorkspaceKindNamePathParam, Value: workspaceKind1Name}, } rr := httptest.NewRecorder() a.GetWorkspaceKindHandler(rr, req, ps) @@ -175,31 +174,37 @@ var _ = Describe("WorkspaceKinds Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspaceKindEnvelope") var response WorkspaceKindEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("retrieving workspaceKindData in the response") - workspaceKindData := response.Data - - By("comparing the retrieved workspacekind with the expected") + By("getting the WorkspaceKind from the Kubernetes API") workspacekind1 := &kubefloworgv1beta1.WorkspaceKind{} Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed()) + By("ensuring the response matches the expected WorkspaceKind") expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1) - Expect(workspaceKindData).To(Equal(expectedWorkspaceKind)) - }) + Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind)) + By("ensuring the wrapped data can be marshaled to JSON and back") + dataJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON") + var dataObject models.WorkspaceKind + err = json.Unmarshal(dataJSON, &dataObject) + Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to WorkspaceKind") + }) }) - Context("when there are no workspacekinds ", 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() { var a App @@ -212,10 +217,11 @@ var _ = Describe("WorkspaceKinds Handler", func() { repositories: repos, } }) - It("should return an empty list of workspacekinds", func() { + + It("should return an empty list of WorkspaceKinds", func() { By("creating the HTTP request") req, err := http.NewRequest(http.MethodGet, AllWorkspaceKindsPath, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspacesHandler") ps := httprouter.Params{} @@ -225,36 +231,32 @@ var _ = Describe("WorkspaceKinds Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspaceKindsEnvelope") var response WorkspaceKindsEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("asserting that the 'workspaces' list is empty") - workspaceskindsJSON, err := json.Marshal(response.Data) - Expect(err).NotTo(HaveOccurred(), "Error marshaling 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") + By("ensuring that no WorkspaceKinds were returned") + Expect(response.Data).To(BeEmpty()) }) - 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) + It("should return 404 for a non-existent WorkspaceKind", func() { + missingWorkspaceKindName := "non-existent-workspacekind" + + By("creating the HTTP request") + path := strings.Replace(WorkspaceKindsByNamePath, ":"+WorkspaceNamePathParam, missingWorkspaceKindName, 1) req, err := http.NewRequest(http.MethodGet, path, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspaceKindHandler") ps := httprouter.Params{ - httprouter.Param{Key: "name", Value: "non-existent-workspacekind"}, + httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceKindName}, } rr := httptest.NewRecorder() a.GetWorkspaceKindHandler(rr, req, ps) @@ -262,7 +264,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusNotFound), "Expected HTTP status 404 Not Found") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound)) }) }) }) diff --git a/workspaces/backend/api/workspaces_handler.go b/workspaces/backend/api/workspaces_handler.go index 25c8d080..aa970f77 100644 --- a/workspaces/backend/api/workspaces_handler.go +++ b/workspaces/backend/api/workspaces_handler.go @@ -24,18 +24,19 @@ import ( "github.com/julienschmidt/httprouter" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" + repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" ) -type WorkspacesEnvelope Envelope[[]models.WorkspaceModel] -type WorkspaceEnvelope Envelope[models.WorkspaceModel] +type WorkspacesEnvelope Envelope[[]models.Workspace] + +type WorkspaceEnvelope Envelope[models.Workspace] 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 workspace models.Workspace var err error if namespace == "" { a.serverErrorResponse(w, r, fmt.Errorf("namespace is nil")) @@ -48,7 +49,7 @@ func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps htt workspace, err = a.repositories.Workspace.GetWorkspace(r.Context(), namespace, workspaceName) if err != nil { - if errors.Is(err, repositories.ErrWorkspaceNotFound) { + if errors.Is(err, repository.ErrWorkspaceNotFound) { a.notFoundResponse(w, r) return } @@ -70,7 +71,7 @@ func (a *App) GetWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps htt func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { namespace := ps.ByName(NamespacePathParam) - var workspaces []models.WorkspaceModel + var workspaces []models.Workspace var err error if namespace == "" { workspaces, err = a.repositories.Workspace.GetAllWorkspaces(r.Context()) @@ -100,7 +101,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps return } - workspaceModel := &models.WorkspaceModel{} + workspaceModel := &models.Workspace{} if err := json.NewDecoder(r.Body).Decode(workspaceModel); err != nil { a.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON: %w", err)) return @@ -142,7 +143,7 @@ func (a *App) DeleteWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps err := a.repositories.Workspace.DeleteWorkspace(r.Context(), namespace, workspaceName) if err != nil { - if errors.Is(err, repositories.ErrWorkspaceNotFound) { + if errors.Is(err, repository.ErrWorkspaceNotFound) { a.notFoundResponse(w, r) return } diff --git a/workspaces/backend/api/workspaces_handler_test.go b/workspaces/backend/api/workspaces_handler_test.go index 37830883..0f367988 100644 --- a/workspaces/backend/api/workspaces_handler_test.go +++ b/workspaces/backend/api/workspaces_handler_test.go @@ -29,37 +29,50 @@ 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/utils/ptr" "github.com/kubeflow/notebooks/workspaces/backend/internal/config" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) var _ = Describe("Workspaces Handler", func() { - Context("with existing workspaces", Ordered, func() { - const namespaceName1 = "namespace1" - const namespaceName2 = "namespace2" + // NOTE: the tests in this context work on the same resources, they must be run in order. + // also, they assume a specific state of the cluster, so cannot be run in parallel with other tests. + // therefore, we run them using the `Ordered` and `Serial` Ginkgo decorators. + Context("with existing Workspaces", Serial, Ordered, func() { + + const namespaceName1 = "ws-exist-ns1" + const namespaceName2 = "ws-exist-ns2" var ( - a App - workspaceName1 string - workspaceKey1 types.NamespacedName - workspaceName2 string - workspaceKey2 types.NamespacedName - workspaceName3 string - workspaceKey3 types.NamespacedName + a App + + workspaceName1 string + workspaceKey1 types.NamespacedName + workspaceName2 string + workspaceKey2 types.NamespacedName + workspaceName3 string + workspaceKey3 types.NamespacedName + workspaceKindName string + workspaceKindKey types.NamespacedName ) BeforeAll(func() { - uniqueName := "wsk-update-test" - workspaceName1 = fmt.Sprintf("workspace1-%s", uniqueName) - workspaceName2 = fmt.Sprintf("workspace2-%s", uniqueName) - workspaceName3 = fmt.Sprintf("workspace3-%s", uniqueName) + uniqueName := "ws-exist-test" + workspaceName1 = fmt.Sprintf("workspace-1-%s", uniqueName) + workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1} + workspaceName2 = fmt.Sprintf("workspace-2-%s", uniqueName) + workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName1} + workspaceName3 = fmt.Sprintf("workspace-3-%s", uniqueName) + workspaceKey3 = types.NamespacedName{Name: workspaceName3, Namespace: namespaceName2} workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + workspaceKindKey = types.NamespacedName{Name: workspaceKindName} repos := repositories.NewRepositories(k8sClient) a = App{ @@ -69,7 +82,7 @@ var _ = Describe("Workspaces Handler", func() { repositories: repos, } - By("creating namespaces") + By("creating Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -77,6 +90,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) + By("creating Namespace 2") namespace2 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName2, @@ -88,24 +102,21 @@ var _ = Describe("Workspaces Handler", func() { workspaceKind := NewExampleWorkspaceKind(workspaceKindName) Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) - By("creating the Workspace1 at namespaceName1") + By("creating Workspace 1 in Namespace 1") workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName) Expect(k8sClient.Create(ctx, workspace1)).To(Succeed()) - workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1} - By("creating the Workspace2 at namespaceName1") + By("creating Workspace 2 in Namespace 1") workspace2 := NewExampleWorkspace(workspaceName2, namespaceName1, workspaceKindName) Expect(k8sClient.Create(ctx, workspace2)).To(Succeed()) - workspaceKey2 = types.NamespacedName{Name: workspaceName2, Namespace: namespaceName1} - By("creating the Workspace3 at namespaceName2") + By("creating Workspace 3 in Namespace 2") 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 at namespaceName1") + By("deleting Workspace 1 from Namespace 1") workspace1 := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName1, @@ -114,7 +125,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, workspace1)).To(Succeed()) - By("deleting the Workspace2 at namespaceName1") + By("deleting Workspace 2 from Namespace 1") workspace2 := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName2, @@ -123,7 +134,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, workspace2)).To(Succeed()) - By("deleting the Workspace3 at namespaceName2") + By("deleting Workspace 3 from Namespace 2") workspace3 := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName3, @@ -132,7 +143,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, workspace3)).To(Succeed()) - By("deleting the WorkspaceKind") + By("deleting WorkspaceKind") workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceKindName, @@ -140,7 +151,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) - By("deleting the namespace1") + By("deleting Namespace 1") namespace1 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName1, @@ -148,7 +159,7 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed()) - By("deleting the namespace2") + By("deleting Namespace 2") namespace2 := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceName2, @@ -157,11 +168,10 @@ var _ = Describe("Workspaces Handler", func() { Expect(k8sClient.Delete(ctx, namespace2)).To(Succeed()) }) - It("should retrieve the workspaces from all namespaces successfully", func() { - + It("should retrieve Workspaces from all namespaces successfully", func() { By("creating the HTTP request") - req, err := http.NewRequest(http.MethodGet, WorkspacesByNamespacePath, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + req, err := http.NewRequest(http.MethodGet, AllWorkspacesPath, http.NoBody) + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspacesHandler") ps := httprouter.Params{} @@ -171,29 +181,22 @@ var _ = Describe("Workspaces Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspacesEnvelope") var response WorkspacesEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("checking if 'workspaces' key exists in the response") - workspacesData := response.Data + By("getting the WorkspaceKind from the Kubernetes API") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed()) - By("converting workspacesData to JSON and back to []WorkspaceModel") - workspacesJSON, err := json.Marshal(workspacesData) - Expect(err).NotTo(HaveOccurred(), "Error marshaling 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") + By("getting the Workspaces from the Kubernetes API") workspace1 := &kubefloworgv1beta1.Workspace{} Expect(k8sClient.Get(ctx, workspaceKey1, workspace1)).To(Succeed()) workspace2 := &kubefloworgv1beta1.Workspace{} @@ -201,31 +204,30 @@ var _ = Describe("Workspaces Handler", func() { workspace3 := &kubefloworgv1beta1.Workspace{} Expect(k8sClient.Get(ctx, workspaceKey3, workspace3)).To(Succeed()) - workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: workspaceKindName}, workspaceKind)).To(Succeed()) - - expectedWorkspaces := []models.WorkspaceModel{ - repositories.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind), - repositories.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind), - repositories.NewWorkspaceModelFromWorkspace(workspace3, workspaceKind), - } - Expect(workspaces).To(ConsistOf(expectedWorkspaces)) + By("ensuring the response contains the expected Workspaces") + Expect(response.Data).To(ConsistOf( + models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind), + models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind), + models.NewWorkspaceModelFromWorkspace(workspace3, workspaceKind), + )) + By("ensuring the response can be marshaled to JSON and back to []Workspace") + dataJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON") + var dataObject []models.Workspace + err = json.Unmarshal(dataJSON, &dataObject) + Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace") }) - It("should retrieve the workspaces from namespaceName1 successfully", func() { - + It("should retrieve Workspaces from Namespace 1 successfully", func() { By("creating the HTTP request") path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceName1, 1) req, err := http.NewRequest(http.MethodGet, path, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspacesHandler") ps := httprouter.Params{ - httprouter.Param{ - Key: NamespacePathParam, - Value: namespaceName1, - }, + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, } rr := httptest.NewRecorder() a.GetWorkspacesHandler(rr, req, ps) @@ -233,46 +235,313 @@ var _ = Describe("Workspaces Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspacesEnvelope") var response WorkspacesEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("converting workspaces Data to JSON and back to []WorkspaceModel") - workspacesJSON, err := json.Marshal(response.Data) - Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces repositories") + By("getting the WorkspaceKind from the Kubernetes API") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed()) - 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") + By("getting the Workspaces from the Kubernetes API") workspace1 := &kubefloworgv1beta1.Workspace{} Expect(k8sClient.Get(ctx, workspaceKey1, workspace1)).To(Succeed()) workspace2 := &kubefloworgv1beta1.Workspace{} Expect(k8sClient.Get(ctx, workspaceKey2, workspace2)).To(Succeed()) - workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: workspaceKindName}, workspaceKind)).To(Succeed()) - - expectedWorkspaces := []models.WorkspaceModel{ - repositories.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind), - repositories.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind), - } - Expect(workspaces).To(ConsistOf(expectedWorkspaces)) + By("ensuring the response contains the expected Workspaces") + Expect(response.Data).To(ConsistOf( + models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind), + models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind), + )) + By("ensuring the response can be marshaled to JSON and back to []Workspace") + dataJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON") + var dataObject []models.Workspace + err = json.Unmarshal(dataJSON, &dataObject) + Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace") }) + It("should retrieve a single Workspace successfully", func() { + By("creating the HTTP request") + path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodGet, path, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.GetWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to WorkspaceEnvelope") + var response WorkspaceEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("getting the WorkspaceKind from the Kubernetes API") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed()) + + By("getting the Workspace from the Kubernetes API") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + + By("ensuring the response matches the expected Workspace") + Expect(response.Data).To(BeComparableTo(models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind))) + + By("ensuring the response can be marshaled to JSON and back to Workspace") + dataJSON, err := json.Marshal(response.Data) + Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON") + var dataObject models.Workspace + err = json.Unmarshal(dataJSON, &dataObject) + Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to Workspace") + }) }) - Context("when there are no workspaces", func() { - const otherNamespace = "otherNamespace" + // NOTE: the tests in this context work on the same resources, they must be run in order. + // also, they assume a specific state of the cluster, so cannot be run in parallel with other tests. + // therefore, we run them using the `Ordered` and `Serial` Ginkgo decorators. + Context("with existing invalid Workspaces", Serial, Ordered, func() { + + const namespaceName1 = "ws-invalid-ns1" + + var ( + a App + + workspaceMissingWskName string + workspaceMissingWskKey types.NamespacedName + + workspaceInvalidPodConfig string + workspaceInvalidPodConfigKey types.NamespacedName + + workspaceInvalidImageConfig string + workspaceInvalidImageConfigKey types.NamespacedName + + workspaceKindName string + workspaceKindKey types.NamespacedName + ) + + BeforeAll(func() { + uniqueName := "ws-invalid-test" + workspaceMissingWskName = fmt.Sprintf("workspace-mising-wsk-%s", uniqueName) + workspaceMissingWskKey = types.NamespacedName{Name: workspaceMissingWskName, Namespace: namespaceName1} + workspaceInvalidPodConfig = fmt.Sprintf("workspace-invalid-pc-%s", uniqueName) + workspaceInvalidPodConfigKey = types.NamespacedName{Name: workspaceInvalidPodConfig, Namespace: namespaceName1} + workspaceInvalidImageConfig = fmt.Sprintf("workspace-invalid-ic-%s", uniqueName) + workspaceInvalidImageConfigKey = types.NamespacedName{Name: workspaceInvalidImageConfig, Namespace: namespaceName1} + workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + workspaceKindKey = types.NamespacedName{Name: workspaceKindName} + + repos := repositories.NewRepositories(k8sClient) + a = App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + } + + By("creating Namespace 1") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Create(ctx, namespace1)).To(Succeed()) + + By("creating a WorkspaceKind") + workspaceKind := NewExampleWorkspaceKind(workspaceKindName) + Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) + + By("creating Workspace with missing WorkspaceKind") + workspaceMissingWsk := NewExampleWorkspace(workspaceMissingWskName, namespaceName1, "bad-wsk") + Expect(k8sClient.Create(ctx, workspaceMissingWsk)).To(Succeed()) + + By("creating Workspace with invalid PodConfig") + workspaceInvalidPodConfig := NewExampleWorkspace(workspaceInvalidPodConfig, namespaceName1, workspaceKindName) + workspaceInvalidPodConfig.Spec.PodTemplate.Options.PodConfig = "bad-pc" + Expect(k8sClient.Create(ctx, workspaceInvalidPodConfig)).To(Succeed()) + + By("creating Workspace with invalid ImageConfig") + workspaceInvalidImageConfig := NewExampleWorkspace(workspaceInvalidImageConfig, namespaceName1, workspaceKindName) + workspaceInvalidImageConfig.Spec.PodTemplate.Options.ImageConfig = "bad-ic" + Expect(k8sClient.Create(ctx, workspaceInvalidImageConfig)).To(Succeed()) + }) + + AfterAll(func() { + By("deleting Workspace with missing WorkspaceKind") + workspaceMissingWsk := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceMissingWskName, + Namespace: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, workspaceMissingWsk)).To(Succeed()) + + By("deleting Workspace with invalid PodConfig") + workspaceInvalidPodConfig := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceInvalidPodConfig, + Namespace: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, workspaceInvalidPodConfig)).To(Succeed()) + + By("deleting Workspace with invalid ImageConfig") + workspaceInvalidImageConfig := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceInvalidImageConfig, + Namespace: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, workspaceInvalidImageConfig)).To(Succeed()) + + By("deleting WorkspaceKind") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceKindName, + }, + } + Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) + + By("deleting Namespace 1") + namespace1 := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed()) + }) + + It("should retrieve invalid Workspaces from Namespace 1 successfully", func() { + By("creating the HTTP request") + path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceName1, 1) + req, err := http.NewRequest(http.MethodGet, path, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetWorkspacesHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + } + rr := httptest.NewRecorder() + a.GetWorkspacesHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to WorkspacesEnvelope") + var response WorkspacesEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("getting the WorkspaceKind from the Kubernetes API") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed()) + + By("getting the Workspaces from the Kubernetes API") + workspaceMissingWsk := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceMissingWskKey, workspaceMissingWsk)).To(Succeed()) + workspaceInvalidPodConfig := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceInvalidPodConfigKey, workspaceInvalidPodConfig)).To(Succeed()) + workspaceInvalidImageConfig := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceInvalidImageConfigKey, workspaceInvalidImageConfig)).To(Succeed()) + + By("ensuring the model for Workspace with missing WorkspaceKind is as expected") + workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil) + Expect(workspaceMissingWskModel.WorkspaceKind.Missing).To(BeTrue()) + Expect(workspaceMissingWskModel.PodTemplate.Volumes.Home.MountPath).To(Equal(models.UnknownHomeMountPath)) + Expect(workspaceMissingWskModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig)) + Expect(workspaceMissingWskModel.PodTemplate.Options.PodConfig.Current.Description).To(Equal(models.UnknownPodConfig)) + Expect(workspaceMissingWskModel.PodTemplate.Options.ImageConfig.Current.DisplayName).To(Equal(models.UnknownImageConfig)) + Expect(workspaceMissingWskModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig)) + + By("ensuring the model for Workspace with invalid PodConfig is as expected") + workspaceInvalidPodConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidPodConfig, workspaceKind) + Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig)) + Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.Description).To(Equal(models.UnknownPodConfig)) + + By("ensuring the model for Workspace with invalid ImageConfig is as expected") + workspaceInvalidImageConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidImageConfig, workspaceKind) + Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.DisplayName).To(Equal(models.UnknownImageConfig)) + Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig)) + + By("ensuring the response contains the expected Workspaces") + Expect(response.Data).To(ConsistOf( + workspaceMissingWskModel, + workspaceInvalidPodConfigModel, + workspaceInvalidImageConfigModel, + )) + }) + + It("should retrieve a single invalid Workspace successfully", func() { + By("creating the HTTP request") + path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceMissingWskName, 1) + req, err := http.NewRequest(http.MethodGet, path, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: WorkspaceNamePathParam, Value: workspaceMissingWskName}, + } + rr := httptest.NewRecorder() + a.GetWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to WorkspaceEnvelope") + var response WorkspaceEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("getting the Workspace from the Kubernetes API") + workspaceMissingWsk := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceMissingWskKey, workspaceMissingWsk)).To(Succeed()) + + By("ensuring the response matches the expected Workspace") + workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil) + Expect(response.Data).To(BeComparableTo(workspaceMissingWskModel)) + }) + }) + + // 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 Workspaces", Serial, func() { + var a App BeforeEach(func() { @@ -284,18 +553,46 @@ var _ = Describe("Workspaces Handler", func() { repositories: repos, } }) - It("should return an empty list of workspaces", func() { + + It("should return an empty list of Workspaces for all namespaces", func() { By("creating the HTTP request") - path := strings.Replace(AllWorkspacesPath, ":"+NamespacePathParam, otherNamespace, 1) + req, err := http.NewRequest(http.MethodGet, AllWorkspacesPath, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetWorkspacesHandler") + ps := httprouter.Params{} + rr := httptest.NewRecorder() + a.GetWorkspacesHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("unmarshalling the response JSON to WorkspacesEnvelope") + var response WorkspacesEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + + By("ensuring that no Workspaces were returned") + Expect(response.Data).To(BeEmpty()) + }) + + It("should return an empty list of Workspaces for a non-existent namespace", func() { + missingNamespace := "non-existent-namespace" + + By("creating the HTTP request") + path := strings.Replace(AllWorkspacesPath, ":"+NamespacePathParam, missingNamespace, 1) req, err := http.NewRequest(http.MethodGet, path, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") + Expect(err).NotTo(HaveOccurred()) By("executing GetWorkspacesHandler") ps := httprouter.Params{ - httprouter.Param{ - Key: NamespacePathParam, - Value: otherNamespace, - }, + httprouter.Param{Key: NamespacePathParam, Value: missingNamespace}, } rr := httptest.NewRecorder() a.GetWorkspacesHandler(rr, req, ps) @@ -303,40 +600,66 @@ var _ = Describe("Workspaces Handler", func() { defer rs.Body.Close() By("verifying the HTTP response status code") - Expect(rs.StatusCode).To(Equal(http.StatusOK), "Expected HTTP status 200 OK") + Expect(rs.StatusCode).To(Equal(http.StatusOK)) By("reading the HTTP response body") body, err := io.ReadAll(rs.Body) - Expect(err).NotTo(HaveOccurred(), "Failed to read HTTP response body") + Expect(err).NotTo(HaveOccurred()) - By("unmarshalling the response JSON") + By("unmarshalling the response JSON to WorkspacesEnvelope") var response WorkspacesEnvelope err = json.Unmarshal(body, &response) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling response JSON") + Expect(err).NotTo(HaveOccurred()) - By("asserting that the 'workspaces' list is empty") - workspacesJSON, err := json.Marshal(response.Data) - Expect(err).NotTo(HaveOccurred(), "Error marshaling workspaces data") + By("ensuring that no Workspaces were returned") + Expect(response.Data).To(BeEmpty()) + }) - var workspaces []models.WorkspaceModel - err = json.Unmarshal(workspacesJSON, &workspaces) - Expect(err).NotTo(HaveOccurred(), "Error unmarshalling workspaces JSON") - Expect(workspaces).To(BeEmpty(), "Expected no workspaces in the response") + It("should return 404 for a non-existent Workspace", func() { + missingNamespace := "non-existent-namespace" + missingWorkspaceName := "non-existent-workspace" + + By("creating the HTTP request") + path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, missingNamespace, 1) + path = strings.Replace(path, ":"+WorkspaceNamePathParam, missingWorkspaceName, 1) + req, err := http.NewRequest(http.MethodGet, path, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + + By("executing GetWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: missingNamespace}, + httprouter.Param{Key: WorkspaceNamePathParam, Value: missingWorkspaceName}, + } + rr := httptest.NewRecorder() + a.GetWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound)) }) }) - Context("CRUD workspace", Ordered, func() { + // NOTE: the tests in this context work on the same resources, they must be run in order. + // therefore, we run them using the `Ordered` Ginkgo decorator. + Context("CRUD Workspaces", Ordered, func() { - const namespaceNameCrud = "namespace-crud" + const namespaceNameCrud = "ws-crud-ns" var ( a App + workspaceName string + workspaceKey types.NamespacedName workspaceKindName string + workspaceKindKey types.NamespacedName ) BeforeAll(func() { - uniqueName := "wsk-update-test" + uniqueName := "ws-crud-test" + workspaceName = fmt.Sprintf("workspace-%s", uniqueName) + workspaceKey = types.NamespacedName{Name: workspaceName, Namespace: namespaceNameCrud} workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + workspaceKindKey = types.NamespacedName{Name: workspaceKindName} repos := repositories.NewRepositories(k8sClient) a = App{ @@ -346,7 +669,7 @@ var _ = Describe("Workspaces Handler", func() { repositories: repos, } - By("creating namespace") + By("creating the Namespace") namespaceA := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: namespaceNameCrud, @@ -354,14 +677,13 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Create(ctx, namespaceA)).To(Succeed()) - By("creating a WorkspaceKind") + By("creating the WorkspaceKind") workspaceKind := NewExampleWorkspaceKind(workspaceKindName) Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) }) AfterAll(func() { - By("deleting the WorkspaceKind") workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ @@ -370,86 +692,70 @@ var _ = Describe("Workspaces Handler", func() { } Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) - By("deleting the namespace") + 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() { + It("should create and delete a Workspace successfully", func() { - By("creating the workspace via the API") - workspaceName := "dora" - workspaceModel := models.WorkspaceModel{ - Name: workspaceName, - Namespace: namespaceNameCrud, - WorkspaceKind: models.WorkspaceKind{ - Name: workspaceKindName, - Type: "POD_TEMPLATE", + By("getting the WorkspaceKind from the Kubernetes API") + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed()) + + By("defining the Workspace to create") + workspace := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName, + Namespace: namespaceNameCrud, }, - DeferUpdates: false, - Paused: false, - PausedTime: 0, - State: "", - StateMessage: "", - PodTemplate: models.PodTemplate{ - PodMetadata: &models.PodMetadata{ - Labels: map[string]string{ - "app": "dora", - }, - Annotations: map[string]string{ - "app": "dora", - }, - }, - Volumes: &models.Volumes{ - Home: &models.DataVolumeModel{ - PvcName: "my-data-pvc", - MountPath: "/home/jovyan", - ReadOnly: false, - }, - Data: []models.DataVolumeModel{ - { - PvcName: "my-data-pvc", - MountPath: "/data", - ReadOnly: false, + Spec: kubefloworgv1beta1.WorkspaceSpec{ + Kind: workspaceKindName, + Paused: ptr.To(false), + DeferUpdates: ptr.To(false), + PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{ + PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{ + Labels: map[string]string{ + "app": "dora", + }, + Annotations: map[string]string{ + "app": "dora", }, }, - }, - ImageConfig: &models.ImageConfig{ - Current: "WorkspaceKind", - Desired: "", // Status is coming with empty value - RedirectChain: []*models.RedirectChain{}, - }, - PodConfig: &models.PodConfig{ - Current: "WorkspaceKind", - Desired: "WorkspaceKind", - RedirectChain: []*models.RedirectChain{}, - }, - }, - Activity: models.Activity{ - LastActivity: 0, - LastUpdate: 0, - LastProbe: &models.Probe{ - StartTimeMs: 0, - EndTimeMs: 0, - Result: "default_result", - Message: "default_message", + Volumes: kubefloworgv1beta1.WorkspacePodVolumes{ + Home: ptr.To("my-home-pvc"), + Data: []kubefloworgv1beta1.PodVolumeMount{ + { + PVCName: "my-data-pvc", + MountPath: "/data", + ReadOnly: ptr.To(false), + }, + }, + }, + Options: kubefloworgv1beta1.WorkspacePodOptions{ + ImageConfig: "jupyterlab_scipy_180", + PodConfig: "tiny_cpu", + }, }, }, } + workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind) - workspaceJSON, err := json.Marshal(workspaceModel) - Expect(err).NotTo(HaveOccurred(), "Failed to marshal WorkspaceModel to JSON") + By("marshaling the Workspace model to JSON") + workspaceModelJSON, err := json.Marshal(workspaceModel) + Expect(err).NotTo(HaveOccurred()) + + By("creating an HTTP request to create the Workspace") 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, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(workspaceModelJSON))) + Expect(err).NotTo(HaveOccurred()) req.Header.Set("Content-Type", "application/json") + By("executing CreateWorkspaceHandler") rr := httptest.NewRecorder() ps := httprouter.Params{ httprouter.Param{ @@ -457,18 +763,28 @@ var _ = Describe("Workspaces Handler", func() { Value: namespaceNameCrud, }, } - a.CreateWorkspaceHandler(rr, req, ps) rs := rr.Result() defer rs.Body.Close() - By("verifying the HTTP response status code for creation") - Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Expected HTTP status 201 Created") + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusCreated)) - By("retrieving the created workspace via the API") + By("getting the created Workspace from the Kubernetes API") + createdWorkspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey, createdWorkspace)).To(Succeed()) + + By("ensuring the created Workspace matches the expected Workspace") + Expect(createdWorkspace.Spec).To(BeComparableTo(workspace.Spec)) + + By("creating an HTTP request to delete the Workspace") path = strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceNameCrud, 1) path = strings.Replace(path, ":"+WorkspaceNamePathParam, workspaceName, 1) + req, err = http.NewRequest(http.MethodDelete, path, http.NoBody) + Expect(err).NotTo(HaveOccurred()) + By("executing DeleteWorkspaceHandler") + rr = httptest.NewRecorder() ps = httprouter.Params{ httprouter.Param{ Key: NamespacePathParam, @@ -479,69 +795,18 @@ var _ = Describe("Workspaces Handler", func() { Value: workspaceName, }, } - - req, err = http.NewRequest(http.MethodGet, path, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") - rr = httptest.NewRecorder() - - a.GetWorkspaceHandler(rr, req, ps) - rs = rr.Result() - defer rs.Body.Close() - - 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") - - response.Data.Activity.LastActivity = 0 - - 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, http.NoBody) - 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() - 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, http.NoBody) - Expect(err).NotTo(HaveOccurred(), "Failed to create HTTP request") - rr = httptest.NewRecorder() - - a.GetWorkspaceHandler(rr, req, ps) - rs = rr.Result() - defer rs.Body.Close() - - 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`)) + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNoContent)) + By("ensuring the Workspace has been deleted") + deletedWorkspace := &kubefloworgv1beta1.Workspace{} + err = k8sClient.Get(ctx, workspaceKey, deletedWorkspace) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) }) }) }) diff --git a/workspaces/backend/internal/models/health_check.go b/workspaces/backend/internal/models/health_check/types.go similarity index 68% rename from workspaces/backend/internal/models/health_check.go rename to workspaces/backend/internal/models/health_check/types.go index 4fa652d1..0105a078 100644 --- a/workspaces/backend/internal/models/health_check.go +++ b/workspaces/backend/internal/models/health_check/types.go @@ -14,13 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package models +package health_check + +type HealthCheck struct { + Status ServiceStatus `json:"status"` + SystemInfo SystemInfo `json:"system_info"` +} type SystemInfo struct { Version string `json:"version"` } -type HealthCheckModel struct { - Status string `json:"status"` - SystemInfo SystemInfo `json:"system_info"` -} +type ServiceStatus string + +const ( + ServiceStatusHealthy ServiceStatus = "Healthy" + ServiceStatusUnhealthy ServiceStatus = "Unhealthy" +) diff --git a/workspaces/backend/internal/models/namespaces/funcs.go b/workspaces/backend/internal/models/namespaces/funcs.go new file mode 100644 index 00000000..efb6f1d0 --- /dev/null +++ b/workspaces/backend/internal/models/namespaces/funcs.go @@ -0,0 +1,26 @@ +/* +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 namespaces + +import corev1 "k8s.io/api/core/v1" + +// NewNamespaceModelFromNamespace creates a new Namespace model from a Namespace object. +func NewNamespaceModelFromNamespace(ns *corev1.Namespace) Namespace { + return Namespace{ + Name: ns.Name, + } +} diff --git a/workspaces/backend/internal/models/namespaces.go b/workspaces/backend/internal/models/namespaces/types.go similarity index 78% rename from workspaces/backend/internal/models/namespaces.go rename to workspaces/backend/internal/models/namespaces/types.go index 14c9c5b3..b71c4699 100644 --- a/workspaces/backend/internal/models/namespaces.go +++ b/workspaces/backend/internal/models/namespaces/types.go @@ -14,14 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -package models +package namespaces -type NamespaceModel struct { +type Namespace struct { Name string `json:"name"` } - -func NewNamespaceModelFromNamespace(name string) NamespaceModel { - return NamespaceModel{ - Name: name, - } -} diff --git a/workspaces/backend/internal/models/workspacekinds.go b/workspaces/backend/internal/models/workspacekinds.go deleted file mode 100644 index 8c87fe70..00000000 --- a/workspaces/backend/internal/models/workspacekinds.go +++ /dev/null @@ -1,97 +0,0 @@ -/* -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/workspacekinds/funcs.go b/workspaces/backend/internal/models/workspacekinds/funcs.go new file mode 100644 index 00000000..98c77f0f --- /dev/null +++ b/workspaces/backend/internal/models/workspacekinds/funcs.go @@ -0,0 +1,155 @@ +/* +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 workspacekinds + +import ( + "fmt" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + "k8s.io/utils/ptr" +) + +// NewWorkspaceKindModelFromWorkspaceKind creates a WorkspaceKind model from a WorkspaceKind object. +func NewWorkspaceKindModelFromWorkspaceKind(wsk *kubefloworgv1beta1.WorkspaceKind) WorkspaceKind { + podLabels := make(map[string]string) + podAnnotations := make(map[string]string) + if wsk.Spec.PodTemplate.PodMetadata != nil { + // NOTE: we copy the maps to avoid creating a reference to the original maps. + for k, v := range wsk.Spec.PodTemplate.PodMetadata.Labels { + podLabels[k] = v + } + for k, v := range wsk.Spec.PodTemplate.PodMetadata.Annotations { + podAnnotations[k] = v + } + } + + // TODO: icons can either be a remote URL or read from a ConfigMap. + // in BOTH cases, we should cache and serve the image under a path on the backend API: + // /api/v1/workspacekinds/{name}/assets/icon + iconRef := ImageRef{ + URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", wsk.Name), + } + + // TODO: logos can either be a remote URL or read from a ConfigMap. + // in BOTH cases, we should cache and serve the image under a path on the backend API: + // /api/v1/workspacekinds/{name}/assets/logo + logoRef := ImageRef{ + URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", wsk.Name), + } + + return WorkspaceKind{ + Name: wsk.Name, + DisplayName: wsk.Spec.Spawner.DisplayName, + Description: wsk.Spec.Spawner.Description, + Deprecated: ptr.Deref(wsk.Spec.Spawner.Deprecated, false), + DeprecationMessage: ptr.Deref(wsk.Spec.Spawner.DeprecationMessage, ""), + Hidden: ptr.Deref(wsk.Spec.Spawner.Hidden, false), + Icon: iconRef, + Logo: logoRef, + PodTemplate: PodTemplate{ + PodMetadata: PodMetadata{ + Labels: podLabels, + Annotations: podAnnotations, + }, + VolumeMounts: PodVolumeMounts{ + Home: wsk.Spec.PodTemplate.VolumeMounts.Home, + }, + Options: PodTemplateOptions{ + ImageConfig: ImageConfig{ + Default: wsk.Spec.PodTemplate.Options.ImageConfig.Spawner.Default, + Values: buildImageConfigValues(wsk.Spec.PodTemplate.Options.ImageConfig), + }, + PodConfig: PodConfig{ + Default: wsk.Spec.PodTemplate.Options.PodConfig.Spawner.Default, + Values: buildPodConfigValues(wsk.Spec.PodTemplate.Options.PodConfig), + }, + }, + }, + } +} + +func buildImageConfigValues(imageConfig kubefloworgv1beta1.ImageConfig) []ImageConfigValue { + imageConfigValues := make([]ImageConfigValue, len(imageConfig.Values)) + for i := range imageConfig.Values { + option := imageConfig.Values[i] + imageConfigValues[i] = ImageConfigValue{ + Id: option.Id, + DisplayName: option.Spawner.DisplayName, + Description: ptr.Deref(option.Spawner.Description, ""), + Labels: buildOptionLabels(option.Spawner.Labels), + Hidden: ptr.Deref(option.Spawner.Hidden, false), + Redirect: buildOptionRedirect(option.Redirect), + } + } + return imageConfigValues +} + +func buildPodConfigValues(podConfig kubefloworgv1beta1.PodConfig) []PodConfigValue { + podConfigValues := make([]PodConfigValue, len(podConfig.Values)) + for i := range podConfig.Values { + option := podConfig.Values[i] + podConfigValues[i] = PodConfigValue{ + Id: option.Id, + DisplayName: option.Spawner.DisplayName, + Description: ptr.Deref(option.Spawner.Description, ""), + Labels: buildOptionLabels(option.Spawner.Labels), + Hidden: ptr.Deref(option.Spawner.Hidden, false), + Redirect: buildOptionRedirect(option.Redirect), + } + } + return podConfigValues +} + +func buildOptionLabels(labels []kubefloworgv1beta1.OptionSpawnerLabel) []OptionLabel { + optionLabels := make([]OptionLabel, len(labels)) + for i := range labels { + optionLabels[i] = OptionLabel{ + Key: labels[i].Key, + Value: labels[i].Value, + } + } + return optionLabels +} + +func buildOptionRedirect(redirect *kubefloworgv1beta1.OptionRedirect) *OptionRedirect { + if redirect == nil { + return nil + } + + var message *RedirectMessage + if redirect.Message != nil { + messageLevel := RedirectMessageLevelInfo + switch redirect.Message.Level { + case kubefloworgv1beta1.RedirectMessageLevelInfo: + messageLevel = RedirectMessageLevelInfo + case kubefloworgv1beta1.RedirectMessageLevelWarning: + messageLevel = RedirectMessageLevelWarning + case kubefloworgv1beta1.RedirectMessageLevelDanger: + messageLevel = RedirectMessageLevelDanger + } + + message = &RedirectMessage{ + Text: redirect.Message.Text, + Level: messageLevel, + } + } + + return &OptionRedirect{ + To: redirect.To, + Message: message, + } +} diff --git a/workspaces/backend/internal/models/workspacekinds/types.go b/workspaces/backend/internal/models/workspacekinds/types.go new file mode 100644 index 00000000..ec30a7bf --- /dev/null +++ b/workspaces/backend/internal/models/workspacekinds/types.go @@ -0,0 +1,104 @@ +/* +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 workspacekinds + +type WorkspaceKind struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Deprecated bool `json:"deprecated"` + DeprecationMessage string `json:"deprecation_message"` + Hidden bool `json:"hidden"` + Icon ImageRef `json:"icon"` + Logo ImageRef `json:"logo"` + PodTemplate PodTemplate `json:"pod_template"` +} + +type ImageRef struct { + URL string `json:"url"` +} + +type PodTemplate struct { + PodMetadata PodMetadata `json:"pod_metadata"` + VolumeMounts PodVolumeMounts `json:"volume_mounts"` + Options PodTemplateOptions `json:"options"` +} + +type PodMetadata struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` +} + +type PodVolumeMounts struct { + Home string `json:"home"` +} + +type PodTemplateOptions struct { + ImageConfig ImageConfig `json:"image_config"` + PodConfig PodConfig `json:"pod_config"` +} + +type ImageConfig struct { + Default string `json:"default"` + Values []ImageConfigValue `json:"values"` +} + +type ImageConfigValue struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Labels []OptionLabel `json:"labels"` + Hidden bool `json:"hidden"` + Redirect *OptionRedirect `json:"redirect,omitempty"` +} + +type PodConfig struct { + Default string `json:"default"` + Values []PodConfigValue `json:"values"` +} + +type PodConfigValue struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Labels []OptionLabel `json:"labels"` + Hidden bool `json:"hidden"` + Redirect *OptionRedirect `json:"redirect,omitempty"` +} + +type OptionLabel struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type OptionRedirect struct { + To string `json:"to"` + Message *RedirectMessage `json:"message,omitempty"` +} + +type RedirectMessage struct { + Text string `json:"text"` + Level RedirectMessageLevel `json:"level"` +} + +type RedirectMessageLevel string + +const ( + RedirectMessageLevelInfo RedirectMessageLevel = "Info" + RedirectMessageLevelWarning RedirectMessageLevel = "Warning" + RedirectMessageLevelDanger RedirectMessageLevel = "Danger" +) diff --git a/workspaces/backend/internal/models/workspaces.go b/workspaces/backend/internal/models/workspaces.go deleted file mode 100644 index 2ac0cbf2..00000000 --- a/workspaces/backend/internal/models/workspaces.go +++ /dev/null @@ -1,86 +0,0 @@ -/* -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 - -type WorkspaceModel struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - WorkspaceKind WorkspaceKind `json:"workspace_kind"` - DeferUpdates bool `json:"defer_updates"` - Paused bool `json:"paused"` - PausedTime int64 `json:"paused_time"` - State string `json:"state"` - StateMessage string `json:"state_message"` - PodTemplate PodTemplate `json:"pod_template"` - Activity Activity `json:"activity"` -} -type PodTemplate struct { - PodMetadata *PodMetadata `json:"pod_metadata"` - Volumes *Volumes `json:"volumes"` - ImageConfig *ImageConfig `json:"image_config"` - PodConfig *PodConfig `json:"pod_config"` -} - -type PodMetadata struct { - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` -} -type Volumes struct { - Home *DataVolumeModel `json:"home"` - Data []DataVolumeModel `json:"data"` -} - -type ImageConfig struct { - Current string `json:"current"` - Desired string `json:"desired"` - RedirectChain []*RedirectChain `json:"redirect_chain"` -} - -type PodConfig struct { - Current string `json:"current"` - Desired string `json:"desired"` - RedirectChain []*RedirectChain `json:"redirect_chain"` -} - -type RedirectChain struct { - Source string `json:"source"` - Target string `json:"target"` -} - -type Activity struct { - LastActivity int64 `json:"last_activity"` // Unix Epoch time - LastUpdate int64 `json:"last_update"` // Unix Epoch time - LastProbe *Probe `json:"last_probe"` -} - -type Probe struct { - StartTimeMs int64 `json:"start_time_ms"` // Unix Epoch time in milliseconds - EndTimeMs int64 `json:"end_time_ms"` // Unix Epoch time in milliseconds - Result string `json:"result"` - Message string `json:"message"` -} - -type WorkspaceKind struct { - Name string `json:"name"` - Type string `json:"type"` -} - -type DataVolumeModel struct { - PvcName string `json:"pvc_name"` - MountPath string `json:"mount_path"` - ReadOnly bool `json:"read_only"` -} diff --git a/workspaces/backend/internal/models/workspaces/funcs.go b/workspaces/backend/internal/models/workspaces/funcs.go new file mode 100644 index 00000000..55cc5b0a --- /dev/null +++ b/workspaces/backend/internal/models/workspaces/funcs.go @@ -0,0 +1,328 @@ +/* +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 workspaces + +import ( + "fmt" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + "k8s.io/utils/ptr" +) + +const ( + UnknownHomeMountPath = "__UNKNOWN_HOME_MOUNT_PATH__" + UnknownImageConfig = "__UNKNOWN_IMAGE_CONFIG__" + UnknownPodConfig = "__UNKNOWN_POD_CONFIG__" +) + +// NewWorkspaceModelFromWorkspace creates a Workspace model from a Workspace and WorkspaceKind object. +// NOTE: the WorkspaceKind might not exist, so we handle the case where it is nil or has no UID. +func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) Workspace { + // ensure the provided WorkspaceKind matches the Workspace + if wskExists(wsk) && ws.Spec.Kind != wsk.Name { + panic("provided WorkspaceKind does not match the Workspace") + } + + // TODO: icons can either be a remote URL or read from a ConfigMap. + // in BOTH cases, we should cache and serve the image under a path on the backend API: + // /api/v1/workspacekinds/{name}/assets/icon + iconRef := ImageRef{ + URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", ws.Spec.Kind), + } + + // TODO: logos can either be a remote URL or read from a ConfigMap. + // in BOTH cases, we should cache and serve the image under a path on the backend API: + // /api/v1/workspacekinds/{name}/assets/logo + logoRef := ImageRef{ + URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", ws.Spec.Kind), + } + + wsState := WorkspaceStateUnknown + switch ws.Status.State { + case kubefloworgv1beta1.WorkspaceStateRunning: + wsState = WorkspaceStateRunning + case kubefloworgv1beta1.WorkspaceStateTerminating: + wsState = WorkspaceStateTerminating + case kubefloworgv1beta1.WorkspaceStatePaused: + wsState = WorkspaceStatePaused + case kubefloworgv1beta1.WorkspaceStatePending: + wsState = WorkspaceStatePending + case kubefloworgv1beta1.WorkspaceStateError: + wsState = WorkspaceStateError + case kubefloworgv1beta1.WorkspaceStateUnknown: + wsState = WorkspaceStateUnknown + } + + podLabels := make(map[string]string) + podAnnotations := make(map[string]string) + if ws.Spec.PodTemplate.PodMetadata != nil { + // NOTE: we copy the maps to avoid creating a reference to the original maps. + for k, v := range ws.Spec.PodTemplate.PodMetadata.Labels { + podLabels[k] = v + } + for k, v := range ws.Spec.PodTemplate.PodMetadata.Annotations { + podAnnotations[k] = v + } + } + + dataVolumes := make([]PodVolumeInfo, len(ws.Spec.PodTemplate.Volumes.Data)) + for i := range ws.Spec.PodTemplate.Volumes.Data { + volume := ws.Spec.PodTemplate.Volumes.Data[i] + readOnly := false + if volume.ReadOnly != nil { + readOnly = *volume.ReadOnly + } + dataVolumes[i] = PodVolumeInfo{ + PvcName: volume.PVCName, + MountPath: volume.MountPath, + ReadOnly: readOnly, + } + } + + workspaceModel := Workspace{ + Name: ws.Name, + Namespace: ws.Namespace, + WorkspaceKind: WorkspaceKindInfo{ + Name: ws.Spec.Kind, + Missing: !wskExists(wsk), + Icon: iconRef, + Logo: logoRef, + }, + DeferUpdates: ptr.Deref(ws.Spec.DeferUpdates, false), + Paused: ptr.Deref(ws.Spec.Paused, false), + PausedTime: ws.Status.PauseTime, + State: wsState, + StateMessage: ws.Status.StateMessage, + PodTemplate: PodTemplate{ + PodMetadata: PodMetadata{ + Labels: podLabels, + Annotations: podAnnotations, + }, + Volumes: PodVolumes{ + Home: buildHomeVolume(ws, wsk), + Data: dataVolumes, + }, + Options: PodTemplateOptions{ + ImageConfig: buildImageConfig(ws, wsk), + PodConfig: buildPodConfig(ws, wsk), + }, + }, + Activity: Activity{ + LastActivity: ws.Status.Activity.LastActivity, + LastUpdate: ws.Status.Activity.LastUpdate, + // TODO: populate LastProbe when culling is implemented: + // https://github.com/kubeflow/notebooks/issues/38 + LastProbe: nil, + }, + } + return workspaceModel +} + +func wskExists(wsk *kubefloworgv1beta1.WorkspaceKind) bool { + return wsk != nil && wsk.UID != "" +} + +func buildHomeVolume(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) *PodVolumeInfo { + if ws.Spec.PodTemplate.Volumes.Home == nil { + return nil + } + + // we only know the mount path if the WorkspaceKind exists + homeMountPath := UnknownHomeMountPath + if wskExists(wsk) { + homeMountPath = wsk.Spec.PodTemplate.VolumeMounts.Home + } + + return &PodVolumeInfo{ + PvcName: *ws.Spec.PodTemplate.Volumes.Home, + MountPath: homeMountPath, + // the home volume is ~always~ read-write + ReadOnly: false, + } +} + +func buildImageConfig(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) ImageConfig { + // create a map of image configs from the WorkspaceKind for easy lookup by ID + // NOTE: we can only build this map if the WorkspaceKind exists, otherwise it will be empty + imageConfigMap := make(map[string]kubefloworgv1beta1.ImageConfigValue) + if wskExists(wsk) { + imageConfigMap = make(map[string]kubefloworgv1beta1.ImageConfigValue, len(wsk.Spec.PodTemplate.Options.ImageConfig.Values)) + for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { + imageConfigMap[value.Id] = value + } + } + + // get the current image config + currentImageConfig := OptionInfo{ + Id: ws.Spec.PodTemplate.Options.ImageConfig, + DisplayName: UnknownImageConfig, + Description: UnknownImageConfig, + Labels: nil, + } + if cfg, ok := imageConfigMap[currentImageConfig.Id]; ok { + currentImageConfig.DisplayName = cfg.Spawner.DisplayName + currentImageConfig.Description = ptr.Deref(cfg.Spawner.Description, "") + currentImageConfig.Labels = buildOptionLabels(cfg.Spawner.Labels) + } + + // get the desired image config + // NOTE: the desired image config will be nil if it is the same as the current image config + var desiredImageConfig *OptionInfo + desiredImageConfigId := ws.Status.PodTemplateOptions.ImageConfig.Desired + if desiredImageConfigId != "" && desiredImageConfigId != currentImageConfig.Id { + desiredImageConfig = &OptionInfo{ + Id: desiredImageConfigId, + DisplayName: UnknownImageConfig, + Description: UnknownImageConfig, + Labels: nil, + } + if cfg, ok := imageConfigMap[desiredImageConfig.Id]; ok { + desiredImageConfig.DisplayName = cfg.Spawner.DisplayName + desiredImageConfig.Description = ptr.Deref(cfg.Spawner.Description, "") + desiredImageConfig.Labels = buildOptionLabels(cfg.Spawner.Labels) + } + } + + // build the redirect chain + // NOTE: the redirect chain will be nil (not an empty slice) if there are no redirects + var redirectChain []RedirectStep + numRedirects := len(ws.Status.PodTemplateOptions.ImageConfig.RedirectChain) + if numRedirects > 0 { + redirectChain = make([]RedirectStep, numRedirects) + } + for i := range ws.Status.PodTemplateOptions.ImageConfig.RedirectChain { + step := ws.Status.PodTemplateOptions.ImageConfig.RedirectChain[i] + redirectChain[i] = RedirectStep{ + SourceId: step.Source, + TargetId: step.Target, + } + if cfg, ok := imageConfigMap[step.Source]; ok { + // skip the redirect if it's not the target we expect + if cfg.Redirect != nil && cfg.Redirect.To == step.Target { + redirectChain[i].Message = buildRedirectMessage(cfg.Redirect.Message) + } + } + } + + return ImageConfig{ + Current: currentImageConfig, + Desired: desiredImageConfig, + RedirectChain: redirectChain, + } +} + +func buildPodConfig(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) PodConfig { + // create a map of pod configs from the WorkspaceKind for easy lookup by ID + // NOTE: we can only build this map if the WorkspaceKind exists, otherwise it will be empty + podConfigMap := make(map[string]kubefloworgv1beta1.PodConfigValue) + if wskExists(wsk) { + podConfigMap = make(map[string]kubefloworgv1beta1.PodConfigValue, len(wsk.Spec.PodTemplate.Options.PodConfig.Values)) + for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { + podConfigMap[value.Id] = value + } + } + + // get the current pod config + currentPodConfig := OptionInfo{ + Id: ws.Spec.PodTemplate.Options.PodConfig, + DisplayName: UnknownPodConfig, + Description: UnknownPodConfig, + Labels: nil, + } + if cfg, ok := podConfigMap[currentPodConfig.Id]; ok { + currentPodConfig.DisplayName = cfg.Spawner.DisplayName + currentPodConfig.Description = ptr.Deref(cfg.Spawner.Description, "") + currentPodConfig.Labels = buildOptionLabels(cfg.Spawner.Labels) + } + + // get the desired pod config + // NOTE: the desired pod config will be nil if it is the same as the current pod config + var desiredPodConfig *OptionInfo + desiredPodConfigId := ws.Status.PodTemplateOptions.PodConfig.Desired + if desiredPodConfigId != "" && desiredPodConfigId != currentPodConfig.Id { + desiredPodConfig = &OptionInfo{ + Id: desiredPodConfigId, + DisplayName: UnknownPodConfig, + Description: UnknownPodConfig, + Labels: nil, + } + if cfg, ok := podConfigMap[desiredPodConfig.Id]; ok { + desiredPodConfig.DisplayName = cfg.Spawner.DisplayName + desiredPodConfig.Description = ptr.Deref(cfg.Spawner.Description, "") + desiredPodConfig.Labels = buildOptionLabels(cfg.Spawner.Labels) + } + } + + // build the redirect chain + // NOTE: the redirect chain will be nil (not an empty slice) if there are no redirects + var redirectChain []RedirectStep + numRedirects := len(ws.Status.PodTemplateOptions.PodConfig.RedirectChain) + if numRedirects > 0 { + redirectChain = make([]RedirectStep, numRedirects) + } + for i := range ws.Status.PodTemplateOptions.PodConfig.RedirectChain { + step := ws.Status.PodTemplateOptions.PodConfig.RedirectChain[i] + redirectChain[i] = RedirectStep{ + SourceId: step.Source, + TargetId: step.Target, + } + if cfg, ok := podConfigMap[step.Source]; ok { + // skip the redirect if it's not the target we expect + if cfg.Redirect != nil && cfg.Redirect.To == step.Target { + redirectChain[i].Message = buildRedirectMessage(cfg.Redirect.Message) + } + } + } + + return PodConfig{ + Current: currentPodConfig, + Desired: desiredPodConfig, + RedirectChain: redirectChain, + } +} + +func buildOptionLabels(labels []kubefloworgv1beta1.OptionSpawnerLabel) []OptionLabel { + optionLabels := make([]OptionLabel, len(labels)) + for i := range labels { + optionLabels[i] = OptionLabel{ + Key: labels[i].Key, + Value: labels[i].Value, + } + } + return optionLabels +} + +func buildRedirectMessage(msg *kubefloworgv1beta1.RedirectMessage) *RedirectMessage { + if msg == nil { + return nil + } + + messageLevel := RedirectMessageLevelInfo + switch msg.Level { + case kubefloworgv1beta1.RedirectMessageLevelInfo: + messageLevel = RedirectMessageLevelInfo + case kubefloworgv1beta1.RedirectMessageLevelWarning: + messageLevel = RedirectMessageLevelWarning + case kubefloworgv1beta1.RedirectMessageLevelDanger: + messageLevel = RedirectMessageLevelDanger + } + + return &RedirectMessage{ + Text: msg.Text, + Level: messageLevel, + } +} diff --git a/workspaces/backend/internal/models/workspaces/types.go b/workspaces/backend/internal/models/workspaces/types.go new file mode 100644 index 00000000..e68f8821 --- /dev/null +++ b/workspaces/backend/internal/models/workspaces/types.go @@ -0,0 +1,143 @@ +/* +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 workspaces + +type Workspace struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + WorkspaceKind WorkspaceKindInfo `json:"workspace_kind"` + DeferUpdates bool `json:"defer_updates"` + Paused bool `json:"paused"` + PausedTime int64 `json:"paused_time"` + State WorkspaceState `json:"state"` + StateMessage string `json:"state_message"` + PodTemplate PodTemplate `json:"pod_template"` + Activity Activity `json:"activity"` +} + +type WorkspaceState string + +const ( + WorkspaceStateRunning WorkspaceState = "Running" + WorkspaceStateTerminating WorkspaceState = "Terminating" + WorkspaceStatePaused WorkspaceState = "Paused" + WorkspaceStatePending WorkspaceState = "Pending" + WorkspaceStateError WorkspaceState = "Error" + WorkspaceStateUnknown WorkspaceState = "Unknown" +) + +type WorkspaceKindInfo struct { + Name string `json:"name"` + Missing bool `json:"missing"` + Icon ImageRef `json:"icon"` + Logo ImageRef `json:"logo"` +} + +type ImageRef struct { + URL string `json:"url"` +} + +type PodTemplate struct { + PodMetadata PodMetadata `json:"pod_metadata"` + Volumes PodVolumes `json:"volumes"` + Options PodTemplateOptions `json:"options"` +} + +type PodMetadata struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` +} + +type PodVolumes struct { + Home *PodVolumeInfo `json:"home,omitempty"` + Data []PodVolumeInfo `json:"data"` +} + +type PodVolumeInfo struct { + PvcName string `json:"pvc_name"` + MountPath string `json:"mount_path"` + ReadOnly bool `json:"read_only"` +} + +type PodTemplateOptions struct { + ImageConfig ImageConfig `json:"image_config"` + PodConfig PodConfig `json:"pod_config"` +} + +type ImageConfig struct { + Current OptionInfo `json:"current"` + Desired *OptionInfo `json:"desired,omitempty"` + RedirectChain []RedirectStep `json:"redirect_chain,omitempty"` +} + +type PodConfig struct { + Current OptionInfo `json:"current"` + Desired *OptionInfo `json:"desired,omitempty"` + RedirectChain []RedirectStep `json:"redirect_chain,omitempty"` +} + +type OptionInfo struct { + Id string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Labels []OptionLabel `json:"labels"` +} + +type OptionLabel struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type RedirectStep struct { + SourceId string `json:"source_id"` + TargetId string `json:"target_id"` + Message *RedirectMessage `json:"message,omitempty"` +} + +type RedirectMessage struct { + Text string `json:"text"` + Level RedirectMessageLevel `json:"level"` +} + +type RedirectMessageLevel string + +const ( + RedirectMessageLevelInfo RedirectMessageLevel = "Info" + RedirectMessageLevelWarning RedirectMessageLevel = "Warning" + RedirectMessageLevelDanger RedirectMessageLevel = "Danger" +) + +type Activity struct { + LastActivity int64 `json:"last_activity"` // Unix Epoch time + LastUpdate int64 `json:"last_update"` // Unix Epoch time + LastProbe *LastProbeInfo `json:"last_probe,omitempty"` +} + +type LastProbeInfo struct { + StartTimeMs int64 `json:"start_time_ms"` // Unix Epoch time in milliseconds + EndTimeMs int64 `json:"end_time_ms"` // Unix Epoch time in milliseconds + Result ProbeResult `json:"result"` + Message string `json:"message"` +} + +type ProbeResult string + +const ( + ProbeResultSuccess ProbeResult = "Success" + ProbeResultFailure ProbeResult = "Failure" + ProbeResultTimeout ProbeResult = "Timeout" +) diff --git a/workspaces/backend/internal/repositories/health_check.go b/workspaces/backend/internal/repositories/health_check/repo.go similarity index 75% rename from workspaces/backend/internal/repositories/health_check.go rename to workspaces/backend/internal/repositories/health_check/repo.go index 8767d50c..3d77c0fa 100644 --- a/workspaces/backend/internal/repositories/health_check.go +++ b/workspaces/backend/internal/repositories/health_check/repo.go @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repositories +package health_check -import "github.com/kubeflow/notebooks/workspaces/backend/internal/models" +import ( + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/health_check" +) type HealthCheckRepository struct{} @@ -24,10 +26,11 @@ func NewHealthCheckRepository() *HealthCheckRepository { return &HealthCheckRepository{} } -func (r *HealthCheckRepository) HealthCheck(version string) (models.HealthCheckModel, error) { +func (r *HealthCheckRepository) HealthCheck(version string) (models.HealthCheck, error) { - var res = models.HealthCheckModel{ - Status: "available", + var res = models.HealthCheck{ + // TODO: implement actual health check logic + Status: models.ServiceStatusHealthy, SystemInfo: models.SystemInfo{ Version: version, }, diff --git a/workspaces/backend/internal/repositories/namespaces.go b/workspaces/backend/internal/repositories/namespaces/repo.go similarity index 77% rename from workspaces/backend/internal/repositories/namespaces.go rename to workspaces/backend/internal/repositories/namespaces/repo.go index 7410a871..0b390a6a 100644 --- a/workspaces/backend/internal/repositories/namespaces.go +++ b/workspaces/backend/internal/repositories/namespaces/repo.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repositories +package namespaces import ( "context" @@ -22,7 +22,7 @@ import ( v1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/namespaces" ) type NamespaceRepository struct { @@ -35,7 +35,7 @@ func NewNamespaceRepository(cl client.Client) *NamespaceRepository { } } -func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.NamespaceModel, error) { +func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Namespace, error) { // TODO(ederign): Implement subject access review here to fetch only // namespaces that "kubeflow-userid" has access to @@ -45,9 +45,10 @@ func (r *NamespaceRepository) GetNamespaces(ctx context.Context) ([]models.Names return nil, err } - namespaces := make([]models.NamespaceModel, len(namespaceList.Items)) - for i, ns := range namespaceList.Items { - namespaces[i] = models.NewNamespaceModelFromNamespace(ns.Name) + namespaces := make([]models.Namespace, len(namespaceList.Items)) + for i := range namespaceList.Items { + namespace := &namespaceList.Items[i] + namespaces[i] = models.NewNamespaceModelFromNamespace(namespace) } return namespaces, nil } diff --git a/workspaces/backend/internal/repositories/repositories.go b/workspaces/backend/internal/repositories/repositories.go index 6aed8d94..6e7429e2 100644 --- a/workspaces/backend/internal/repositories/repositories.go +++ b/workspaces/backend/internal/repositories/repositories.go @@ -18,21 +18,27 @@ package repositories import ( "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/health_check" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/namespaces" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" ) -// Models struct is a single convenient container to hold and represent all our repositories. +// Repositories is a single convenient container to hold and represent all our repositories. type Repositories struct { - HealthCheck *HealthCheckRepository - Workspace *WorkspaceRepository - WorkspaceKind *WorkspaceKindRepository - Namespace *NamespaceRepository + HealthCheck *health_check.HealthCheckRepository + Namespace *namespaces.NamespaceRepository + Workspace *workspaces.WorkspaceRepository + WorkspaceKind *workspacekinds.WorkspaceKindRepository } +// NewRepositories creates a new Repositories instance from a controller-runtime client. func NewRepositories(cl client.Client) *Repositories { return &Repositories{ - HealthCheck: NewHealthCheckRepository(), - Workspace: NewWorkspaceRepository(cl), - WorkspaceKind: NewWorkspaceKindRepository(cl), - Namespace: NewNamespaceRepository(cl), + HealthCheck: health_check.NewHealthCheckRepository(), + Namespace: namespaces.NewNamespaceRepository(cl), + Workspace: workspaces.NewWorkspaceRepository(cl), + WorkspaceKind: workspacekinds.NewWorkspaceKindRepository(cl), } } diff --git a/workspaces/backend/internal/repositories/workspacekinds.go b/workspaces/backend/internal/repositories/workspacekinds/repo.go similarity index 74% rename from workspaces/backend/internal/repositories/workspacekinds.go rename to workspaces/backend/internal/repositories/workspacekinds/repo.go index b16225f1..386862bd 100644 --- a/workspaces/backend/internal/repositories/workspacekinds.go +++ b/workspaces/backend/internal/repositories/workspacekinds/repo.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repositories +package workspacekinds import ( "context" @@ -24,7 +24,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/kubeflow/notebooks/workspaces/backend/internal/models" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" ) var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") @@ -39,21 +39,21 @@ func NewWorkspaceKindRepository(cl client.Client) *WorkspaceKindRepository { } } -func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name string) (models.WorkspaceKindModel, error) { +func (r *WorkspaceKindRepository) GetWorkspaceKind(ctx context.Context, name string) (models.WorkspaceKind, 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.WorkspaceKind{}, ErrWorkspaceKindNotFound } - return models.WorkspaceKindModel{}, err + return models.WorkspaceKind{}, err } workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) return workspaceKindModel, nil } -func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKindModel, error) { +func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]models.WorkspaceKind, error) { workspaceKindList := &kubefloworgv1beta1.WorkspaceKindList{} err := r.client.List(ctx, workspaceKindList) @@ -61,10 +61,10 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode return nil, err } - workspaceKindsModels := make([]models.WorkspaceKindModel, len(workspaceKindList.Items)) - for i, item := range workspaceKindList.Items { - workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(&item) - workspaceKindsModels[i] = workspaceKindModel + workspaceKindsModels := make([]models.WorkspaceKind, len(workspaceKindList.Items)) + for i := range workspaceKindList.Items { + workspaceKind := &workspaceKindList.Items[i] + workspaceKindsModels[i] = models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) } return workspaceKindsModels, nil diff --git a/workspaces/backend/internal/repositories/workspaces.go b/workspaces/backend/internal/repositories/workspaces.go deleted file mode 100644 index 8bf25b75..00000000 --- a/workspaces/backend/internal/repositories/workspaces.go +++ /dev/null @@ -1,259 +0,0 @@ -/* -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" - 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 -} - -func NewWorkspaceRepository(cl client.Client) *WorkspaceRepository { - return &WorkspaceRepository{ - client: cl, - } -} - -func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string, workspaceName string) (models.WorkspaceModel, error) { - workspace := &kubefloworgv1beta1.Workspace{} - if err := r.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: workspaceName}, workspace); err != nil { - if apierrors.IsNotFound(err) { - return models.WorkspaceModel{}, ErrWorkspaceNotFound - } - return models.WorkspaceModel{}, err - } - - kind := &kubefloworgv1beta1.WorkspaceKind{} - if err := r.client.Get(ctx, client.ObjectKey{Name: workspace.Spec.Kind}, kind); err != nil { - return models.WorkspaceModel{}, err - } - - return NewWorkspaceModelFromWorkspace(workspace, kind), nil -} - -func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.WorkspaceModel, error) { - workspaceList := &kubefloworgv1beta1.WorkspaceList{} - listOptions := []client.ListOption{ - client.InNamespace(namespace), - } - err := r.client.List(ctx, workspaceList, listOptions...) - if err != nil { - return nil, err - } - - workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items)) - for i, item := range workspaceList.Items { - kind := &kubefloworgv1beta1.WorkspaceKind{} - if err := r.client.Get(ctx, client.ObjectKey{Name: item.Spec.Kind}, kind); err != nil { - return nil, err - } - workspacesModels[i] = NewWorkspaceModelFromWorkspace(&item, kind) - } - - return workspacesModels, nil -} - -func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.WorkspaceModel, error) { - workspaceList := &kubefloworgv1beta1.WorkspaceList{} - if err := r.client.List(ctx, workspaceList); err != nil { - return nil, err - } - - workspacesModels := make([]models.WorkspaceModel, len(workspaceList.Items)) - for i, item := range workspaceList.Items { - kind := &kubefloworgv1beta1.WorkspaceKind{} - if err := r.client.Get(ctx, client.ObjectKey{Name: item.Spec.Kind}, kind); err != nil { - return nil, err - } - workspacesModels[i] = NewWorkspaceModelFromWorkspace(&item, kind) - } - - 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.PodTemplate.PodMetadata.Labels, - Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations, - }, - Spec: kubefloworgv1beta1.WorkspaceSpec{ - Paused: &workspaceModel.Paused, - DeferUpdates: &workspaceModel.DeferUpdates, - // TODO: verify if workspace kind exists on validation - Kind: workspaceModel.WorkspaceKind.Name, - PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{ - PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{ - Labels: workspaceModel.PodTemplate.PodMetadata.Labels, - Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations, - }, - Volumes: kubefloworgv1beta1.WorkspacePodVolumes{ - Home: &workspaceModel.PodTemplate.Volumes.Home.PvcName, - Data: []kubefloworgv1beta1.PodVolumeMount{}, - }, - Options: kubefloworgv1beta1.WorkspacePodOptions{ - ImageConfig: workspaceModel.PodTemplate.ImageConfig.Current, - PodConfig: workspaceModel.PodTemplate.PodConfig.Current, - }, - }, - }, - } - - // TODO: create data volumes if necessary - workspace.Spec.PodTemplate.Volumes.Data = make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.PodTemplate.Volumes.Data)) - for i, dataVolume := range workspaceModel.PodTemplate.Volumes.Data { - // 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 - } - - kind := &kubefloworgv1beta1.WorkspaceKind{} - if err := r.client.Get(ctx, client.ObjectKey{Name: workspace.Spec.Kind}, kind); err != nil { - return models.WorkspaceModel{}, err - } - - return NewWorkspaceModelFromWorkspace(workspace, kind), 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 -} - -func NewWorkspaceModelFromWorkspace(item *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) models.WorkspaceModel { - dataVolumes := make([]models.DataVolumeModel, len(item.Spec.PodTemplate.Volumes.Data)) - for i, volume := range item.Spec.PodTemplate.Volumes.Data { - dataVolumes[i] = models.DataVolumeModel{ - PvcName: volume.PVCName, - MountPath: volume.MountPath, - ReadOnly: *volume.ReadOnly, - } - } - - imageConfigRedirectChain := make([]*models.RedirectChain, len(item.Status.PodTemplateOptions.ImageConfig.RedirectChain)) - for i, chain := range item.Status.PodTemplateOptions.ImageConfig.RedirectChain { - imageConfigRedirectChain[i] = &models.RedirectChain{ - Source: chain.Source, - Target: chain.Target, - } - } - - podConfigRedirectChain := make([]*models.RedirectChain, len(item.Status.PodTemplateOptions.PodConfig.RedirectChain)) - - for i, chain := range item.Status.PodTemplateOptions.PodConfig.RedirectChain { - podConfigRedirectChain[i] = &models.RedirectChain{ - Source: chain.Source, - Target: chain.Target, - } - } - - podMetadataLabels := item.Spec.PodTemplate.PodMetadata.Labels - if podMetadataLabels == nil { - podMetadataLabels = map[string]string{} - } - - podMetadataAnnotations := item.Spec.PodTemplate.PodMetadata.Annotations - if podMetadataAnnotations == nil { - podMetadataAnnotations = map[string]string{} - } - - workspaceModel := models.WorkspaceModel{ - Name: item.ObjectMeta.Name, - Namespace: item.Namespace, - WorkspaceKind: models.WorkspaceKind{ - Name: item.Spec.Kind, - Type: "POD_TEMPLATE", - }, - DeferUpdates: *item.Spec.DeferUpdates, - Paused: *item.Spec.Paused, - PausedTime: item.Status.PauseTime, - State: string(item.Status.State), - StateMessage: item.Status.StateMessage, - PodTemplate: models.PodTemplate{ - PodMetadata: &models.PodMetadata{ - Labels: podMetadataLabels, - Annotations: podMetadataAnnotations, - }, - Volumes: &models.Volumes{ - Home: &models.DataVolumeModel{ - PvcName: *item.Spec.PodTemplate.Volumes.Home, - MountPath: wsk.Spec.PodTemplate.VolumeMounts.Home, - ReadOnly: false, // From where to get this value? - }, - Data: dataVolumes, - }, - ImageConfig: &models.ImageConfig{ - Current: item.Spec.PodTemplate.Options.ImageConfig, - Desired: item.Status.PodTemplateOptions.ImageConfig.Desired, - RedirectChain: imageConfigRedirectChain, - }, - PodConfig: &models.PodConfig{ - Current: item.Spec.PodTemplate.Options.PodConfig, - Desired: item.Spec.PodTemplate.Options.PodConfig, - RedirectChain: podConfigRedirectChain, - }, - }, - Activity: models.Activity{ - LastActivity: item.Status.Activity.LastActivity, - LastUpdate: item.Status.Activity.LastUpdate, - // TODO: update these fields when the last probe is implemented - LastProbe: &models.Probe{ - StartTimeMs: 0, - EndTimeMs: 0, - Result: "default_result", - Message: "default_message", - }, - }, - } - return workspaceModel -} diff --git a/workspaces/backend/internal/repositories/workspaces/repo.go b/workspaces/backend/internal/repositories/workspaces/repo.go new file mode 100644 index 00000000..cacfcd00 --- /dev/null +++ b/workspaces/backend/internal/repositories/workspaces/repo.go @@ -0,0 +1,206 @@ +/* +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 workspaces + +import ( + "context" + "fmt" + + 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/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" +) + +var ErrWorkspaceNotFound = fmt.Errorf("workspace not found") +var ErrRefWorkspaceKindNotExists = fmt.Errorf("referenced WorkspaceKind does not exist") + +type WorkspaceRepository struct { + client client.Client +} + +func NewWorkspaceRepository(cl client.Client) *WorkspaceRepository { + return &WorkspaceRepository{ + client: cl, + } +} + +func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string, workspaceName string) (models.Workspace, error) { + // get workspace + workspace := &kubefloworgv1beta1.Workspace{} + if err := r.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: workspaceName}, workspace); err != nil { + if apierrors.IsNotFound(err) { + return models.Workspace{}, ErrWorkspaceNotFound + } + return models.Workspace{}, err + } + + // get workspace kind, if it exists + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + workspaceKindName := workspace.Spec.Kind + if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil { + // ignore error if workspace kind does not exist, as we can still create a model without it + if !apierrors.IsNotFound(err) { + return models.Workspace{}, err + } + } + + // convert workspace to model + workspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind) + + return workspaceModel, nil +} + +func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.Workspace, error) { + // get all workspaces in the namespace + workspaceList := &kubefloworgv1beta1.WorkspaceList{} + listOptions := []client.ListOption{ + client.InNamespace(namespace), + } + err := r.client.List(ctx, workspaceList, listOptions...) + if err != nil { + return nil, err + } + + // convert workspaces to models + workspacesModels := make([]models.Workspace, len(workspaceList.Items)) + for i, workspace := range workspaceList.Items { + + // get workspace kind, if it exists + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + workspaceKindName := workspace.Spec.Kind + if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil { + // ignore error if workspace kind does not exist, as we can still create a model without it + if !apierrors.IsNotFound(err) { + return nil, err + } + } + + workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind) + } + + return workspacesModels, nil +} + +func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.Workspace, error) { + // get all workspaces in the cluster + workspaceList := &kubefloworgv1beta1.WorkspaceList{} + if err := r.client.List(ctx, workspaceList); err != nil { + return nil, err + } + + // convert workspaces to models + workspacesModels := make([]models.Workspace, len(workspaceList.Items)) + for i, workspace := range workspaceList.Items { + + // get workspace kind, if it exists + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + workspaceKindName := workspace.Spec.Kind + if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil { + // ignore error if workspace kind does not exist, as we can still create a model without it + if !apierrors.IsNotFound(err) { + return nil, err + } + } + + workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind) + } + + return workspacesModels, nil +} + +func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceModel *models.Workspace) (models.Workspace, error) { + // get workspace kind + // NOTE: if the referenced workspace kind does not exist, + // we throw an error because the api would reject the workspace creation + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + workspaceKindName := workspaceModel.WorkspaceKind.Name + if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil { + if apierrors.IsNotFound(err) { + return models.Workspace{}, fmt.Errorf("%w: %s", ErrRefWorkspaceKindNotExists, workspaceKindName) + } + return models.Workspace{}, err + } + + // get data volumes from workspace model + dataVolumeMounts := make([]kubefloworgv1beta1.PodVolumeMount, len(workspaceModel.PodTemplate.Volumes.Data)) + for i, dataVolume := range workspaceModel.PodTemplate.Volumes.Data { + dataVolumeMounts[i] = kubefloworgv1beta1.PodVolumeMount{ + PVCName: dataVolume.PvcName, + MountPath: dataVolume.MountPath, + ReadOnly: ptr.To(dataVolume.ReadOnly), + } + } + + // define workspace object from model + workspace := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceModel.Name, + Namespace: workspaceModel.Namespace, + }, + Spec: kubefloworgv1beta1.WorkspaceSpec{ + Paused: &workspaceModel.Paused, + DeferUpdates: &workspaceModel.DeferUpdates, + Kind: workspaceKindName, + PodTemplate: kubefloworgv1beta1.WorkspacePodTemplate{ + PodMetadata: &kubefloworgv1beta1.WorkspacePodMetadata{ + Labels: workspaceModel.PodTemplate.PodMetadata.Labels, + Annotations: workspaceModel.PodTemplate.PodMetadata.Annotations, + }, + Volumes: kubefloworgv1beta1.WorkspacePodVolumes{ + Home: &workspaceModel.PodTemplate.Volumes.Home.PvcName, + Data: dataVolumeMounts, + }, + Options: kubefloworgv1beta1.WorkspacePodOptions{ + ImageConfig: workspaceModel.PodTemplate.Options.ImageConfig.Current.Id, + PodConfig: workspaceModel.PodTemplate.Options.PodConfig.Current.Id, + }, + }, + }, + } + + // create workspace + if err := r.client.Create(ctx, workspace); err != nil { + return models.Workspace{}, err + } + + // convert the created workspace to a model + createdWorkspaceModel := models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind) + + return createdWorkspaceModel, 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 +}