feat(ws): add workspace pause actions backend API (#340)

related: #298

- Added PauseActionWorkspaceHandler to handle pausing or unpausing a given workspace
- Introduced single new route for starting and pausing workspaces in the API.
    - `api/v1/workspaces/{namespace}/{name}/actions/pause`
			- pausing or unpausing operation is specified in the request payload
- Created a new WorkspaceActionPauseEnvelope type for successful responses.
- Leveraging JSONPatch / client.RawPatch to ensure Workspace in "valid state" before attempting action
    - for `start`: `spec.paused` must be `true`, and `status.state` must be `Paused`
    - for `pause`: `spec.paused` must be `false`
        - note: I would love to have a `status.state` check here of `status.state != Paused`, but that type of comparison is not supported in [JSONPatch](https://datatracker.ietf.org/doc/html/rfc6902#section-4.6)
- Added tests for the new API, including success and error cases.
- Updated README/OpenAPI documentation to include the new endpoints.

---

As an interesting "edge case" worth calling out, the following payload is currently honored by the API:
```
{
  "data": {}
}
```

Given the `WorkspaceActionPause` struct is simply `{"paused": true|false}`, the "empty" Envelope presented above deserializes the JSON using the zero value of `bool` (which is `false`).

Our validation today is always performed against the **deserialized** object, and as such impossible to distinguish the following cases:
```
{
  "data": {}
}
```

vs

```
{
  "data": {
    "paused": false
  }
}
```

The effort and (relative) complexity to prevent this and return a `422` in this scenario was not deemed "worth it" for the time being.  As a result, a test case has been added for this specific scenario to at minimum document this "strange" behavior.
- Clients, however, should **NOT** rely on this behavior and always provide a fully defined `WorkspaceActionPause` JSON object to ensure future compatibility.

Signed-off-by: Andy Stoneberg <astonebe@redhat.com>
This commit is contained in:
Andy Stoneberg 2025-07-24 16:05:02 -04:00 committed by GitHub
parent e747ad5e84
commit 843e5c3c48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1023 additions and 21 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View File

@ -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)))
})
})
})

View File

@ -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),
}
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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": {

View File

@ -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": {