From 105edd765a231ec5957eae1ec700c6e0a4c4eaef Mon Sep 17 00:00:00 2001 From: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:13:26 +0100 Subject: [PATCH 1/3] feat(ws): Add GET workspace yaml endpoint Signed-off-by: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> --- workspaces/backend/api/app.go | 4 + .../backend/api/workspace_yaml_handler.go | 53 ++++++++ .../api/workspace_yaml_handler_test.go | 125 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 workspaces/backend/api/workspace_yaml_handler.go create mode 100644 workspaces/backend/api/workspace_yaml_handler_test.go diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 235ab45c..c4c84dca 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -41,6 +41,8 @@ const ( WorkspaceNamePathParam = "name" WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + WorkspaceNamePathParam + WorkspaceDetailsPrefix = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + WorkspaceNamePathParam + "/details" + WorkspaceYAMLPath = WorkspaceDetailsPrefix + "/yaml" // workspacekinds AllWorkspaceKindsPath = PathPrefix + "/workspacekinds" @@ -86,5 +88,7 @@ func (a *App) Routes() http.Handler { router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) + router.GET(WorkspaceYAMLPath, a.GetWorkspaceYAMLHandler) + return a.RecoverPanic(a.enableCORS(router)) } diff --git a/workspaces/backend/api/workspace_yaml_handler.go b/workspaces/backend/api/workspace_yaml_handler.go new file mode 100644 index 00000000..b1dad403 --- /dev/null +++ b/workspaces/backend/api/workspace_yaml_handler.go @@ -0,0 +1,53 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + + "errors" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" + + "sigs.k8s.io/yaml" +) + +type WorkspaceYAMLEnvelope struct { + Data string `json:"data"` +} + +func (a *App) GetWorkspaceYAMLHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + workspaceName := ps.ByName(WorkspaceNamePathParam) + + if namespace == "" || workspaceName == "" { + a.serverErrorResponse(w, r, fmt.Errorf("namespace or workspace name is empty")) + return + } + + workspace, err := a.repositories.Workspace.GetWorkspace(r.Context(), namespace, workspaceName) + if err != nil { + if errors.Is(err, repositories.ErrWorkspaceNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + yamlBytes, err := yaml.Marshal(workspace) + if err != nil { + a.serverErrorResponse(w, r, err) + return + } + + response := WorkspaceYAMLEnvelope{ + Data: string(yamlBytes), + } + + err = a.WriteJSON(w, http.StatusOK, response, nil) + if err != nil { + a.serverErrorResponse(w, r, err) + } +} diff --git a/workspaces/backend/api/workspace_yaml_handler_test.go b/workspaces/backend/api/workspace_yaml_handler_test.go new file mode 100644 index 00000000..f3d3d351 --- /dev/null +++ b/workspaces/backend/api/workspace_yaml_handler_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + + "github.com/julienschmidt/httprouter" + "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("Workspace YAML Handler", Ordered, func() { + const namespaceName = "namespace-yaml" + + var ( + a *App + workspace *kubefloworgv1beta1.Workspace + workspaceKey types.NamespacedName + workspaceKindName string + ) + + BeforeAll(func() { + uniqueName := "wsk-yaml-test" + workspaceName := fmt.Sprintf("workspace-%s", uniqueName) + workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + repos := repositories.NewRepositories(k8sClient) + a = &App{ + Config: config.EnvConfig{ + Port: 4000, + }, + repositories: repos, + logger: logger, + } + + By("creating namespace") + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + + By("creating a WorkspaceKind") + workspaceKind := NewExampleWorkspaceKind(workspaceKindName) + Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed()) + + By("creating the Workspace") + workspace = NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName) + Expect(k8sClient.Create(ctx, workspace)).To(Succeed()) + workspaceKey = types.NamespacedName{Name: workspaceName, Namespace: namespaceName} + }) + + AfterAll(func() { + By("cleaning up resources") + workspace := &kubefloworgv1beta1.Workspace{} + if err := k8sClient.Get(ctx, workspaceKey, workspace); err == nil { + Expect(k8sClient.Delete(ctx, workspace)).To(Succeed()) + } + + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceKindName, + }, + } + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(workspaceKind), workspaceKind); err == nil { + Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed()) + } + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceName, + }, + } + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(namespace), namespace); err == nil { + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + } + }) + + It("should retrieve the workspace YAML successfully", func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/%s/details/yaml", namespaceName, workspaceKey.Name), nil) + rr := httptest.NewRecorder() + + ps := httprouter.Params{ + {Key: "namespace", Value: namespaceName}, + {Key: "name", Value: workspaceKey.Name}, + } + + a.GetWorkspaceYAMLHandler(rr, req, ps) + + Expect(rr.Code).To(Equal(http.StatusOK)) + + var response WorkspaceYAMLEnvelope + Expect(json.NewDecoder(rr.Body).Decode(&response)).To(Succeed()) + + Expect(response.Data).To(ContainSubstring(fmt.Sprintf("name: %s", workspaceKey.Name))) + Expect(response.Data).To(ContainSubstring(fmt.Sprintf("namespace: %s", namespaceName))) + }) + + It("should return 404 when workspace doesn't exist", func() { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/non-existent/details/yaml", namespaceName), nil) + rr := httptest.NewRecorder() + + ps := httprouter.Params{ + {Key: "namespace", Value: namespaceName}, + {Key: "name", Value: "non-existent"}, + } + + a.GetWorkspaceYAMLHandler(rr, req, ps) + + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) +}) From 42e3ef94cb213f1cced97aa0cdf4c66cbf1f8087 Mon Sep 17 00:00:00 2001 From: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:25:31 +0100 Subject: [PATCH 2/3] feat(ws): Add GET workspace yaml endpoint to README.md Signed-off-by: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> --- workspaces/backend/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/workspaces/backend/README.md b/workspaces/backend/README.md index 25d96513..68931034 100644 --- a/workspaces/backend/README.md +++ b/workspaces/backend/README.md @@ -35,6 +35,7 @@ make run PORT=8000 | PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity | | PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity | | DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity | +| GET /api/v1/workspaces/{namespace}/{name}/details/yaml | workspace_yaml_handler | Get the YAML details of a Workspace entity | | GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind | | POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind | | GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity | From 5752fb0c3102f738cc6f6f6d75dfcc8bbf034627 Mon Sep 17 00:00:00 2001 From: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:43:14 +0100 Subject: [PATCH 3/3] fix(ws): Fix lint errors and typo Signed-off-by: mohamedch7 <121046693+mohamedch7@users.noreply.github.com> --- workspaces/backend/api/app.go | 4 +-- .../backend/api/workspace_yaml_handler.go | 18 +++++++++++-- .../api/workspace_yaml_handler_test.go | 25 +++++++++++++++---- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 06918104..5c3d35e6 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -99,11 +99,11 @@ func (a *App) Routes() http.Handler { router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler) router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler) router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler) - router.GET(WorkspaceYAMLPath, a.GetWorkspaceYAMLHandler) + router.GET(WorkspaceYAMLPath, a.GetWorkspaceYAMLHandler) // workspacekinds router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) - return a.RecoverPanic(a.enableCORS(router)) + return a.recoverPanic(a.enableCORS(router)) } diff --git a/workspaces/backend/api/workspace_yaml_handler.go b/workspaces/backend/api/workspace_yaml_handler.go index b1dad403..40d85d5f 100644 --- a/workspaces/backend/api/workspace_yaml_handler.go +++ b/workspaces/backend/api/workspace_yaml_handler.go @@ -1,3 +1,17 @@ +// 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 ( @@ -8,7 +22,7 @@ import ( "errors" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" "sigs.k8s.io/yaml" ) @@ -28,7 +42,7 @@ func (a *App) GetWorkspaceYAMLHandler(w http.ResponseWriter, r *http.Request, ps workspace, err := a.repositories.Workspace.GetWorkspace(r.Context(), namespace, workspaceName) if err != nil { - if errors.Is(err, repositories.ErrWorkspaceNotFound) { + if errors.Is(err, workspaces.ErrWorkspaceNotFound) { a.notFoundResponse(w, r) return } diff --git a/workspaces/backend/api/workspace_yaml_handler_test.go b/workspaces/backend/api/workspace_yaml_handler_test.go index f3d3d351..bb34bd23 100644 --- a/workspaces/backend/api/workspace_yaml_handler_test.go +++ b/workspaces/backend/api/workspace_yaml_handler_test.go @@ -1,3 +1,17 @@ +// 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 ( @@ -9,8 +23,6 @@ import ( "os" "github.com/julienschmidt/httprouter" - "github.com/kubeflow/notebooks/workspaces/backend/internal/config" - "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,6 +30,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories" ) var _ = Describe("Workspace YAML Handler", Ordered, func() { @@ -38,7 +53,7 @@ var _ = Describe("Workspace YAML Handler", Ordered, func() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) repos := repositories.NewRepositories(k8sClient) a = &App{ - Config: config.EnvConfig{ + Config: &config.EnvConfig{ Port: 4000, }, repositories: repos, @@ -90,7 +105,7 @@ var _ = Describe("Workspace YAML Handler", Ordered, func() { }) It("should retrieve the workspace YAML successfully", func() { - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/%s/details/yaml", namespaceName, workspaceKey.Name), nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/%s/details/yaml", namespaceName, workspaceKey.Name), http.NoBody) rr := httptest.NewRecorder() ps := httprouter.Params{ @@ -110,7 +125,7 @@ var _ = Describe("Workspace YAML Handler", Ordered, func() { }) It("should return 404 when workspace doesn't exist", func() { - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/non-existent/details/yaml", namespaceName), nil) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/workspaces/%s/non-existent/details/yaml", namespaceName), http.NoBody) rr := httptest.NewRecorder() ps := httprouter.Params{