diff --git a/workspaces/backend/README.md b/workspaces/backend/README.md index f5cbd683f..b9517b601 100644 --- a/workspaces/backend/README.md +++ b/workspaces/backend/README.md @@ -27,29 +27,30 @@ make run If you want to use a different port: ```shell -make run PORT=8000 +make run PORT=8000 ``` ### Endpoints -| URL Pattern | Handler | Action | -|----------------------------------------------|------------------------|-----------------------------------------| -| GET /api/v1/healthcheck | healthcheck_handler | Show application information | -| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces | -| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation | -| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces | -| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace | -| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace | -| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity | -| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity | -| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity | -| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity | -| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind | -| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind | -| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity | -| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity | -| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity | -| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity | +| URL Pattern | Handler | Action | +|-----------------------------------------------------------|---------------------------|-----------------------------------------| +| GET /api/v1/healthcheck | healthcheck_handler | Show application information | +| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces | +| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation | +| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces | +| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace | +| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace | +| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity | +| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity | +| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity | +| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity | +| POST /api/v1/workspaces/{namespace}/{name}/actions/pause | workspace_actions_handler | Set paused state of a workspace | +| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind | +| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind | +| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity | +| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity | +| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity | +| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity | ### Sample local calls @@ -128,6 +129,32 @@ Get a Workspace: curl -i localhost:4000/api/v1/workspaces/default/dora ``` +Pause a Workspace: + +```shell +# POST /api/v1/workspaces/{namespace}/{name}/actions/pause +curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \ + -H "Content-Type: application/json" \ + -d '{ + "data": { + "paused": true + } +}' +``` + +Start a Workspace: + +```shell +# POST /api/v1/workspaces/{namespace}/{name}/actions/pause +curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \ + -H "Content-Type: application/json" \ + -d '{ + "data": { + "paused": false + } +}' +``` + Delete a Workspace: ```shell diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 2f76c2527..e533eac8c 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -50,6 +50,8 @@ const ( AllWorkspacesPath = PathPrefix + "/workspaces" WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam + WorkspaceActionsPath = WorkspacesByNamePath + "/actions" + PauseWorkspacePath = WorkspaceActionsPath + "/pause" // workspacekinds AllWorkspaceKindsPath = PathPrefix + "/workspacekinds" @@ -116,6 +118,7 @@ func (a *App) Routes() http.Handler { router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler) router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler) router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler) + router.POST(PauseWorkspacePath, a.PauseActionWorkspaceHandler) // workspacekinds router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) diff --git a/workspaces/backend/api/workspace_actions_handler.go b/workspaces/backend/api/workspace_actions_handler.go new file mode 100644 index 000000000..75e1001d0 --- /dev/null +++ b/workspaces/backend/api/workspace_actions_handler.go @@ -0,0 +1,128 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "errors" + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions" + repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces" +) + +type WorkspaceActionPauseEnvelope Envelope[*models.WorkspaceActionPause] + +// PauseActionWorkspaceHandler handles setting the paused state of a workspace. +// +// @Summary Pause or unpause a workspace +// @Description Pauses or unpauses a workspace, stopping or resuming all associated pods. +// @Tags workspaces +// @Accept json +// @Produce json +// @Param namespace path string true "Namespace of the workspace" extensions(x-example=default) +// @Param workspaceName path string true "Name of the workspace" extensions(x-example=my-workspace) +// @Param body body WorkspaceActionPauseEnvelope true "Intended pause state of the workspace" +// @Success 200 {object} WorkspaceActionPauseEnvelope "Successful action. Returns the current pause state." +// @Failure 400 {object} ErrorEnvelope "Bad Request." +// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required." +// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace." +// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist." +// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large." +// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct." +// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Workspace is not in appropriate state." +// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server." +// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post] +func (a *App) PauseActionWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace := ps.ByName(NamespacePathParam) + workspaceName := ps.ByName(ResourceNamePathParam) + + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...) + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), workspaceName)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + if success := a.ValidateContentType(w, r, "application/json"); !success { + return + } + + bodyEnvelope := &WorkspaceActionPauseEnvelope{} + err := a.DecodeJSON(r, bodyEnvelope) + if err != nil { + if a.IsMaxBytesError(err) { + a.requestEntityTooLargeResponse(w, r, err) + return + } + a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err)) + return + } + + dataPath := field.NewPath("data") + if bodyEnvelope.Data == nil { + valErrs = field.ErrorList{field.Required(dataPath, "data is required")} + a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil) + return + } + + workspaceActionPause := bodyEnvelope.Data + + // =========================== AUTH =========================== + authPolicies := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbUpdate, + &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: workspaceName, + }, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + workspaceActionPauseState, err := a.repositories.Workspace.HandlePauseAction(r.Context(), namespace, workspaceName, workspaceActionPause) + if err != nil { + if errors.Is(err, repository.ErrWorkspaceNotFound) { + a.notFoundResponse(w, r) + return + } + if errors.Is(err, repository.ErrWorkspaceInvalidState) { + a.failedValidationResponse(w, r, err.Error(), nil, nil) + return + } + a.serverErrorResponse(w, r, err) + return + } + + responseEnvelope := &WorkspaceActionPauseEnvelope{ + Data: workspaceActionPauseState, + } + a.dataResponse(w, r, responseEnvelope) +} diff --git a/workspaces/backend/api/workspace_actions_handler_test.go b/workspaces/backend/api/workspace_actions_handler_test.go new file mode 100644 index 000000000..57b709585 --- /dev/null +++ b/workspaces/backend/api/workspace_actions_handler_test.go @@ -0,0 +1,496 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/julienschmidt/httprouter" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions" +) + +var _ = Describe("Workspace Actions Handler", func() { + + // 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-ops-ns1" + + var ( + workspaceName1 string + workspaceKey1 types.NamespacedName + workspaceKindName string + ) + + BeforeAll(func() { + uniqueName := "ws-ops-test" + workspaceName1 = fmt.Sprintf("workspace-1-%s", uniqueName) + workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1} + workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName) + + 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 1 in Namespace 1") + workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName) + Expect(k8sClient.Create(ctx, workspace1)).To(Succeed()) + }) + + AfterAll(func() { + By("deleting Workspace 1 from Namespace 1") + workspace1 := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: workspaceName1, + Namespace: namespaceName1, + }, + } + Expect(k8sClient.Delete(ctx, workspace1)).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 pause a workspace successfully", func() { + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: true, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the response contains the pause state") + var response WorkspaceActionPauseEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Data).NotTo(BeNil()) + Expect(response.Data.Paused).To(BeTrue()) + + By("getting the Workspace from the Kubernetes API") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + + By("ensuring the workspace is paused") + Expect(workspace.Spec.Paused).To(Equal(ptr.To(true))) + }) + + It("should start a workspace successfully", func() { + By("setting the workspace's status state to Paused") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + workspace.Status.State = kubefloworgv1beta1.WorkspaceStatePaused + Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed()) + + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: false, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the response contains the pause state") + var response WorkspaceActionPauseEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Data).NotTo(BeNil()) + Expect(response.Data.Paused).To(BeFalse()) + + By("getting the Workspace from the Kubernetes API") + workspace = &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + + By("ensuring the workspace is not paused") + Expect(workspace.Spec.Paused).To(Equal(ptr.To(false))) + }) + + It("should return 404 for a non-existent workspace when starting", func() { + missingWorkspaceName := "non-existent-workspace" + + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: false, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 404 for a non-existent workspace when pausing", func() { + missingWorkspaceName := "non-existent-workspace" + + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: true, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 422 when starting a workspace that is not in Paused state", func() { + By("setting the workspace's status state to Unknown and spec.paused to false") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + workspace.Spec.Paused = ptr.To(false) + workspace.Status.State = kubefloworgv1beta1.WorkspaceStateUnknown + Expect(k8sClient.Update(ctx, workspace)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed()) + + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: false, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code is 422") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 422 when pausing a workspace that is already paused", func() { + By("setting the workspace's spec.paused to true") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + workspace.Spec.Paused = ptr.To(true) + Expect(k8sClient.Update(ctx, workspace)).To(Succeed()) + + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: true, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code is 422") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 422 when request body is missing data field", func() { + By("creating the request body without data field") + requestBody := map[string]interface{}{} + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code is 422") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + It("should return 415 when Content-Type is not application/json", func() { + By("creating the request body") + requestBody := &WorkspaceActionPauseEnvelope{ + Data: &models.WorkspaceActionPause{ + Paused: true, + }, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers with wrong Content-Type") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/merge-patch+json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code is 415") + Expect(rs.StatusCode).To(Equal(http.StatusUnsupportedMediaType), descUnexpectedHTTPStatus, rr.Body.String()) + }) + + // This test highlights that when the pause API receives a payload of {"data":{}}, + // the zero value for the 'Paused' field (false) is used. This is equivalent to + // explicitly setting "paused": false. This test case is included to make the behavior + // obvious for future maintainers. While this is not necessarily desired behavior, + // the effort to add sufficient validation to the API is not worth the effort as it would + // require a "framework" to validate the raw JSON payload before it is deserialized. + It("should handle empty data object payload correctly", func() { + By("setting the workspace's spec.paused to true and status state to Paused") + workspace := &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + workspace.Spec.Paused = ptr.To(true) + Expect(k8sClient.Update(ctx, workspace)).To(Succeed()) + workspace.Status.State = kubefloworgv1beta1.WorkspaceStatePaused + Expect(k8sClient.Status().Update(ctx, workspace)).To(Succeed()) + + By("creating the request body with empty data object") + requestBody := map[string]interface{}{ + "data": map[string]interface{}{}, + } + bodyBytes, err := json.Marshal(requestBody) + Expect(err).NotTo(HaveOccurred()) + + By("creating the HTTP request") + path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1) + path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1) + req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyBytes))) + Expect(err).NotTo(HaveOccurred()) + + By("setting the auth headers") + req.Header.Set(userIdHeader, adminUser) + req.Header.Set("Content-Type", "application/json") + + By("executing PauseActionWorkspaceHandler") + ps := httprouter.Params{ + httprouter.Param{Key: NamespacePathParam, Value: namespaceName1}, + httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1}, + } + rr := httptest.NewRecorder() + a.PauseActionWorkspaceHandler(rr, req, ps) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String()) + + By("reading the HTTP response body") + body, err := io.ReadAll(rs.Body) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the response contains the pause state") + var response WorkspaceActionPauseEnvelope + err = json.Unmarshal(body, &response) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Data).NotTo(BeNil()) + Expect(response.Data.Paused).To(BeFalse()) + + By("getting the Workspace from the Kubernetes API") + workspace = &kubefloworgv1beta1.Workspace{} + Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed()) + + By("ensuring the workspace is not paused (empty data object results in false)") + Expect(workspace.Spec.Paused).To(Equal(ptr.To(false))) + }) + }) +}) diff --git a/workspaces/backend/internal/models/workspaces/actions/funcs.go b/workspaces/backend/internal/models/workspaces/actions/funcs.go new file mode 100644 index 000000000..ce048d3f3 --- /dev/null +++ b/workspaces/backend/internal/models/workspaces/actions/funcs.go @@ -0,0 +1,28 @@ +/* +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 actions + +import ( + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + "k8s.io/utils/ptr" +) + +func NewWorkspaceActionPauseFromWorkspace(ws *kubefloworgv1beta1.Workspace) *WorkspaceActionPause { + return &WorkspaceActionPause{ + Paused: ptr.Deref(ws.Spec.Paused, false), + } +} diff --git a/workspaces/backend/internal/models/workspaces/actions/types.go b/workspaces/backend/internal/models/workspaces/actions/types.go new file mode 100644 index 000000000..6716b740e --- /dev/null +++ b/workspaces/backend/internal/models/workspaces/actions/types.go @@ -0,0 +1,22 @@ +/* +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 actions + +// WorkspaceActionPause represents the outcome of pause/start workspace actions +type WorkspaceActionPause struct { + Paused bool `json:"paused"` +} diff --git a/workspaces/backend/internal/repositories/workspaces/repo.go b/workspaces/backend/internal/repositories/workspaces/repo.go index 82f3eada7..9b0743aab 100644 --- a/workspaces/backend/internal/repositories/workspaces/repo.go +++ b/workspaces/backend/internal/repositories/workspaces/repo.go @@ -18,19 +18,25 @@ package workspaces import ( "context" + "encoding/json" "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/apimachinery/pkg/types" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" + action_models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions" ) -var ErrWorkspaceNotFound = fmt.Errorf("workspace not found") -var ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists") +var ( + ErrWorkspaceNotFound = fmt.Errorf("workspace not found") + ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists") + ErrWorkspaceInvalidState = fmt.Errorf("workspace is in an invalid state for this operation") +) type WorkspaceRepository struct { client client.Client @@ -214,3 +220,67 @@ func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, wo return nil } + +// WorkspacePatchOperation represents a single JSONPatch operation +type WorkspacePatchOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Value interface{} `json:"value,omitempty"` +} + +// HandlePauseAction handles pause/start operations for a workspace +func (r *WorkspaceRepository) HandlePauseAction(ctx context.Context, namespace, workspaceName string, workspaceActionPause *action_models.WorkspaceActionPause) (*action_models.WorkspaceActionPause, error) { + targetPauseState := workspaceActionPause.Paused + + // Build patch operations incrementally + patch := []WorkspacePatchOperation{ + { + Op: "test", + Path: "/spec/paused", + Value: !targetPauseState, // Test current state (opposite of target state) + }, + } + + // For start operations, add additional test for paused state + // "test" operations on JSON Patch only support strict equality checks, so we can't apply an additional test + // for pause operations on the workspace as we'd want to check the workspace state != paused. + if !targetPauseState { + patch = append(patch, WorkspacePatchOperation{ + Op: "test", + Path: "/status/state", + Value: kubefloworgv1beta1.WorkspaceStatePaused, + }) + } + + // Always add the replace operation + patch = append(patch, WorkspacePatchOperation{ + Op: "replace", + Path: "/spec/paused", + Value: targetPauseState, + }) + + patchBytes, err := json.Marshal(patch) + if err != nil { + return nil, fmt.Errorf("failed to marshal patch: %w", err) + } + + workspace := &kubefloworgv1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: workspaceName, + }, + } + + if err := r.client.Patch(ctx, workspace, client.RawPatch(types.JSONPatchType, patchBytes)); err != nil { + if apierrors.IsNotFound(err) { + return nil, ErrWorkspaceNotFound + } + if apierrors.IsInvalid(err) { + return nil, ErrWorkspaceInvalidState + } + return nil, fmt.Errorf("failed to patch workspace: %w", err) + } + + workspaceActionPauseModel := action_models.NewWorkspaceActionPauseFromWorkspace(workspace) + return workspaceActionPauseModel, nil +} diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index ba09bcc6a..2923a4120 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -453,6 +453,104 @@ const docTemplate = `{ } } }, + "/workspaces/{namespace}/{workspaceName}/actions/pause": { + "post": { + "description": "Pauses or unpauses a workspace, stopping or resuming all associated pods.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Pause or unpause a workspace", + "parameters": [ + { + "type": "string", + "x-example": "default", + "description": "Namespace of the workspace", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-workspace", + "description": "Name of the workspace", + "name": "workspaceName", + "in": "path", + "required": true + }, + { + "description": "Intended pause state of the workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.WorkspaceActionPauseEnvelope" + } + } + ], + "responses": { + "200": { + "description": "Successful action. Returns the current pause state.", + "schema": { + "$ref": "#/definitions/api.WorkspaceActionPauseEnvelope" + } + }, + "400": { + "description": "Bad Request.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to access the workspace.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found. Workspace does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Workspace is not in appropriate state.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces/{namespace}/{workspace_name}": { "get": { "description": "Returns details of a specific workspace identified by namespace and workspace name.", @@ -592,6 +690,14 @@ const docTemplate = `{ } }, "definitions": { + "actions.WorkspaceActionPause": { + "type": "object", + "properties": { + "paused": { + "type": "boolean" + } + } + }, "api.ErrorCause": { "type": "object", "properties": { @@ -650,6 +756,14 @@ const docTemplate = `{ } } }, + "api.WorkspaceActionPauseEnvelope": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/actions.WorkspaceActionPause" + } + } + }, "api.WorkspaceCreateEnvelope": { "type": "object", "properties": { diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index 31c2b5548..ac75f974f 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -451,6 +451,104 @@ } } }, + "/workspaces/{namespace}/{workspaceName}/actions/pause": { + "post": { + "description": "Pauses or unpauses a workspace, stopping or resuming all associated pods.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Pause or unpause a workspace", + "parameters": [ + { + "type": "string", + "x-example": "default", + "description": "Namespace of the workspace", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "x-example": "my-workspace", + "description": "Name of the workspace", + "name": "workspaceName", + "in": "path", + "required": true + }, + { + "description": "Intended pause state of the workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.WorkspaceActionPauseEnvelope" + } + } + ], + "responses": { + "200": { + "description": "Successful action. Returns the current pause state.", + "schema": { + "$ref": "#/definitions/api.WorkspaceActionPauseEnvelope" + } + }, + "400": { + "description": "Bad Request.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to access the workspace.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found. Workspace does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Workspace is not in appropriate state.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces/{namespace}/{workspace_name}": { "get": { "description": "Returns details of a specific workspace identified by namespace and workspace name.", @@ -590,6 +688,14 @@ } }, "definitions": { + "actions.WorkspaceActionPause": { + "type": "object", + "properties": { + "paused": { + "type": "boolean" + } + } + }, "api.ErrorCause": { "type": "object", "properties": { @@ -648,6 +754,14 @@ } } }, + "api.WorkspaceActionPauseEnvelope": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/actions.WorkspaceActionPause" + } + } + }, "api.WorkspaceCreateEnvelope": { "type": "object", "properties": {