Skip to content

Commit 41f8118

Browse files
committed
feat(ws): Implement workspace start + pause as backend API
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 <[email protected]>
1 parent 526ef9d commit 41f8118

File tree

9 files changed

+999
-21
lines changed

9 files changed

+999
-21
lines changed

workspaces/backend/README.md

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,30 @@ make run
2727
If you want to use a different port:
2828

2929
```shell
30-
make run PORT=8000
30+
make run PORT=8000
3131
```
3232

3333
### Endpoints
3434

35-
| URL Pattern | Handler | Action |
36-
|----------------------------------------------|------------------------|-----------------------------------------|
37-
| GET /api/v1/healthcheck | healthcheck_handler | Show application information |
38-
| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces |
39-
| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation |
40-
| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces |
41-
| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace |
42-
| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace |
43-
| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity |
44-
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
45-
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
46-
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
47-
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
48-
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
49-
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
50-
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
51-
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
52-
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
35+
| URL Pattern | Handler | Action |
36+
|-----------------------------------------------------------|---------------------------|-----------------------------------------|
37+
| GET /api/v1/healthcheck | healthcheck_handler | Show application information |
38+
| GET /api/v1/namespaces | namespaces_handler | Get all Namespaces |
39+
| GET /api/v1/swagger/ | swagger_handler | Swagger API documentation |
40+
| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces |
41+
| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace |
42+
| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace |
43+
| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity |
44+
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
45+
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
46+
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
47+
| POST /api/v1/workspaces/{namespace}/{name}/actions/pause | workspace_actions_handler | Set paused state of a workspace |
48+
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
49+
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
50+
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
51+
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
52+
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
53+
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
5354

5455
### Sample local calls
5556

@@ -128,6 +129,32 @@ Get a Workspace:
128129
curl -i localhost:4000/api/v1/workspaces/default/dora
129130
```
130131

132+
Pause a Workspace:
133+
134+
```shell
135+
# POST /api/v1/workspaces/{namespace}/{name}/actions/pause
136+
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \
137+
-H "Content-Type: application/json" \
138+
-d '{
139+
"data": {
140+
"paused": true
141+
}
142+
}'
143+
```
144+
145+
Start a Workspace:
146+
147+
```shell
148+
# POST /api/v1/workspaces/{namespace}/{name}/actions/pause
149+
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause \
150+
-H "Content-Type: application/json" \
151+
-d '{
152+
"data": {
153+
"paused": false
154+
}
155+
}'
156+
```
157+
131158
Delete a Workspace:
132159

133160
```shell

workspaces/backend/api/app.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const (
5050
AllWorkspacesPath = PathPrefix + "/workspaces"
5151
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
5252
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam
53+
WorkspaceActionsPath = WorkspacesByNamePath + "/actions"
54+
PauseWorkspacePath = WorkspaceActionsPath + "/pause"
5355

5456
// workspacekinds
5557
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
@@ -116,6 +118,7 @@ func (a *App) Routes() http.Handler {
116118
router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
117119
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
118120
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
121+
router.POST(PauseWorkspacePath, a.PauseActionWorkspaceHandler)
119122

120123
// workspacekinds
121124
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
24+
"github.com/julienschmidt/httprouter"
25+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/util/validation/field"
28+
29+
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
30+
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
31+
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions"
32+
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
33+
)
34+
35+
type WorkspaceActionPauseEnvelope Envelope[*models.WorkspaceActionPause]
36+
37+
// PauseActionWorkspaceHandler handles setting the paused state of a workspace.
38+
//
39+
// @Summary Pause or unpause a workspace
40+
// @Description Pauses or unpauses a workspace, stopping or resuming all associated pods.
41+
// @Tags workspaces
42+
// @Accept json
43+
// @Produce json
44+
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=default)
45+
// @Param workspaceName path string true "Name of the workspace" extensions(x-example=my-workspace)
46+
// @Param body body WorkspaceActionPauseEnvelope true "Intended pause state of the workspace"
47+
// @Success 200 {object} WorkspaceActionPauseEnvelope "Successful action. Returns the current pause state."
48+
// @Failure 400 {object} ErrorEnvelope "Bad Request."
49+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
50+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace."
51+
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
52+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Workspace is not in appropriate state."
53+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
54+
// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post]
55+
func (a *App) PauseActionWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
56+
namespace := ps.ByName(NamespacePathParam)
57+
workspaceName := ps.ByName(ResourceNamePathParam)
58+
59+
// validate path parameters
60+
var valErrs field.ErrorList
61+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
62+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), workspaceName)...)
63+
if len(valErrs) > 0 {
64+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
65+
return
66+
}
67+
68+
// validate the Content-Type header
69+
if success := a.ValidateContentType(w, r, "application/json"); !success {
70+
return
71+
}
72+
73+
// decode the request body
74+
bodyEnvelope := &WorkspaceActionPauseEnvelope{}
75+
err := a.DecodeJSON(r, bodyEnvelope)
76+
if err != nil {
77+
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
78+
return
79+
}
80+
81+
// validate the request body
82+
dataPath := field.NewPath("data")
83+
if bodyEnvelope.Data == nil {
84+
valErrs = field.ErrorList{field.Required(dataPath, "data is required")}
85+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
86+
return
87+
}
88+
89+
workspaceActionPause := bodyEnvelope.Data
90+
91+
// =========================== AUTH ===========================
92+
authPolicies := []*auth.ResourcePolicy{
93+
auth.NewResourcePolicy(
94+
auth.ResourceVerbUpdate,
95+
&kubefloworgv1beta1.Workspace{
96+
ObjectMeta: metav1.ObjectMeta{
97+
Namespace: namespace,
98+
Name: workspaceName,
99+
},
100+
},
101+
),
102+
}
103+
if success := a.requireAuth(w, r, authPolicies); !success {
104+
return
105+
}
106+
// ============================================================
107+
108+
// Execute the workspace action
109+
workspaceActionPauseState, err := a.repositories.Workspace.HandlePauseAction(r.Context(), namespace, workspaceName, workspaceActionPause)
110+
if err != nil {
111+
if errors.Is(err, repository.ErrWorkspaceNotFound) {
112+
a.notFoundResponse(w, r)
113+
return
114+
}
115+
if errors.Is(err, repository.ErrWorkspaceInvalidState) {
116+
a.failedValidationResponse(w, r, err.Error(), nil, nil)
117+
return
118+
}
119+
a.serverErrorResponse(w, r, err)
120+
return
121+
}
122+
123+
// Return 200 OK with pause state
124+
responseEnvelope := &WorkspaceActionPauseEnvelope{
125+
Data: workspaceActionPauseState,
126+
}
127+
a.dataResponse(w, r, responseEnvelope)
128+
}

0 commit comments

Comments
 (0)