Skip to content

Commit f22b3d3

Browse files
committed
feat(ws): Add start workspace functionality to backend API
- Implemented StartWorkspaceHandler to handle the start operation for workspaces. - Introduced new route for starting workspaces in the API. - `api/v1/workspaces/{namespace}/{name}/actions/start` - Refactored workspaces/repo.go to add common helper function for start/stop - Refactored api/workspace_actions_handler.go to add common helper function for start/stop - Updated README to include the new start workspace endpoint. - Enhanced OpenAPI documentation to reflect the new functionality. - Added tests for the start workspace functionality, covering success and error scenarios. Signed-off-by: Andy Stoneberg <[email protected]>
1 parent 1d39e52 commit f22b3d3

File tree

8 files changed

+343
-25
lines changed

8 files changed

+343
-25
lines changed

workspaces/backend/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ make run PORT=8000
4444
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
4545
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
4646
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
47-
| POST /api/v1/workspaces/{namespace}/{name}/actions/pause | workspace_actions_handler | Pause a running workspace |
47+
| POST /api/v1/workspaces/{namespace}/{name}/actions/start | workspace_actions_handler | Start a paused workspace |
48+
| POST /api/v1/workspaces/{namespace}/{name}/actions/pause | workspace_actions_handler | Pause a running workspace |
4849
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
4950
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
5051
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
@@ -136,6 +137,13 @@ Pause a Workspace:
136137
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/pause
137138
```
138139

140+
Start a Workspace:
141+
142+
```shell
143+
# POST /api/v1/workspaces/{namespace}/{name}/actions/start
144+
curl -X POST localhost:4000/api/v1/workspaces/default/dora/actions/start
145+
```
146+
139147
Delete a Workspace:
140148

141149
```shell

workspaces/backend/api/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const (
4747
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam
4848
WorkspaceActionsPath = WorkspacesByNamePath + "/actions"
4949
PauseWorkspacePath = WorkspaceActionsPath + "/pause"
50+
StartWorkspacePath = WorkspaceActionsPath + "/start"
5051

5152
// workspacekinds
5253
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
@@ -105,6 +106,7 @@ func (a *App) Routes() http.Handler {
105106
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
106107
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
107108
router.POST(PauseWorkspacePath, a.PauseWorkspaceHandler)
109+
router.POST(StartWorkspacePath, a.StartWorkspaceHandler)
108110

109111
// workspacekinds
110112
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)

workspaces/backend/api/workspace_actions_handler.go

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package api
1818

1919
import (
20+
"context"
2021
"errors"
2122
"net/http"
2223

@@ -30,24 +31,11 @@ import (
3031
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
3132
)
3233

33-
// PauseWorkspaceHandler handles the pause workspace action
34-
//
35-
// @Summary Pause workspace
36-
// @Description Pauses a workspace, stopping all associated pods.
37-
// @Tags workspaces
38-
// @Accept json
39-
// @Produce json
40-
// @Param namespace path string true "Namespace of the workspace" example(default)
41-
// @Param workspaceName path string true "Name of the workspace" example(my-workspace)
42-
// @Success 200 {object} EmptyResponse "Successful action. Returns an empty JSON object."
43-
// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name format."
44-
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
45-
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace."
46-
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
47-
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
48-
// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post]
49-
// @Security ApiKeyAuth
50-
func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
34+
// workspaceActionFunc represents a function that performs an action on a workspace
35+
type workspaceActionFunc func(ctx context.Context, namespace, name string) error
36+
37+
// handleWorkspaceAction is a helper function that handles common logic for workspace actions
38+
func (a *App) handleWorkspaceAction(w http.ResponseWriter, r *http.Request, ps httprouter.Params, action workspaceActionFunc) {
5139
namespace := ps.ByName(NamespacePathParam)
5240
workspaceName := ps.ByName(ResourceNamePathParam)
5341

@@ -60,7 +48,7 @@ func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps h
6048
return
6149
}
6250

63-
// =========================== AUTH ===========================
51+
// Authorization check
6452
authPolicies := []*auth.ResourcePolicy{
6553
auth.NewResourcePolicy(
6654
auth.ResourceVerbUpdate,
@@ -75,9 +63,9 @@ func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps h
7563
if success := a.requireAuth(w, r, authPolicies); !success {
7664
return
7765
}
78-
// ============================================================
7966

80-
err := a.repositories.Workspace.PauseWorkspace(r.Context(), namespace, workspaceName)
67+
// Execute the workspace action
68+
err := action(r.Context(), namespace, workspaceName)
8169
if err != nil {
8270
if errors.Is(err, repository.ErrWorkspaceNotFound) {
8371
a.notFoundResponse(w, r)
@@ -94,3 +82,45 @@ func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps h
9482
return
9583
}
9684
}
85+
86+
// PauseWorkspaceHandler handles the pause workspace action
87+
//
88+
// @Summary Pause workspace
89+
// @Description Pauses a workspace, stopping all associated pods.
90+
// @Tags workspaces
91+
// @Accept json
92+
// @Produce json
93+
// @Param namespace path string true "Namespace of the workspace" example(default)
94+
// @Param workspaceName path string true "Name of the workspace" example(my-workspace)
95+
// @Success 200 {object} EmptyResponse "Successful action. Returns an empty JSON object."
96+
// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name format."
97+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
98+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace."
99+
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
100+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
101+
// @Router /workspaces/{namespace}/{workspaceName}/actions/pause [post]
102+
// @Security ApiKeyAuth
103+
func (a *App) PauseWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
104+
a.handleWorkspaceAction(w, r, ps, a.repositories.Workspace.PauseWorkspace)
105+
}
106+
107+
// StartWorkspaceHandler handles the start workspace action
108+
//
109+
// @Summary Start workspace
110+
// @Description Starts a workspace, resuming all associated pods.
111+
// @Tags workspaces
112+
// @Accept json
113+
// @Produce json
114+
// @Param namespace path string true "Namespace of the workspace" example(default)
115+
// @Param workspaceName path string true "Name of the workspace" example(my-workspace)
116+
// @Success 200 {object} EmptyResponse "Successful action. Returns an empty JSON object."
117+
// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name format."
118+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
119+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace."
120+
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
121+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
122+
// @Router /workspaces/{namespace}/{workspaceName}/actions/start [post]
123+
// @Security ApiKeyAuth
124+
func (a *App) StartWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
125+
a.handleWorkspaceAction(w, r, ps, a.repositories.Workspace.StartWorkspace)
126+
}

workspaces/backend/api/workspace_actions_handler_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,71 @@ var _ = Describe("Workspace Actions Handler", func() {
137137
Expect(workspace.Spec.Paused).To(Equal(ptr.To(true)))
138138
})
139139

140+
It("should start a workspace successfully", func() {
141+
By("creating the HTTP request")
142+
path := strings.Replace(StartWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
143+
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
144+
req, err := http.NewRequest(http.MethodPost, path, http.NoBody)
145+
Expect(err).NotTo(HaveOccurred())
146+
147+
By("setting the auth headers")
148+
req.Header.Set(userIdHeader, adminUser)
149+
req.Header.Set("Content-Type", "application/merge-patch+json")
150+
151+
By("executing StartWorkspaceHandler")
152+
ps := httprouter.Params{
153+
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
154+
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
155+
}
156+
rr := httptest.NewRecorder()
157+
a.StartWorkspaceHandler(rr, req, ps)
158+
rs := rr.Result()
159+
defer rs.Body.Close()
160+
161+
By("verifying the HTTP response status code")
162+
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
163+
164+
By("reading the HTTP response body")
165+
body, err := io.ReadAll(rs.Body)
166+
Expect(err).NotTo(HaveOccurred())
167+
168+
By("verifying the response is an empty JSON object")
169+
Expect(string(body)).To(Equal("{}\n"))
170+
171+
By("getting the Workspace from the Kubernetes API")
172+
workspace := &kubefloworgv1beta1.Workspace{}
173+
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
174+
175+
By("ensuring the workspace is not paused")
176+
Expect(workspace.Spec.Paused).To(Equal(ptr.To(false)))
177+
})
178+
179+
It("should return 404 for a non-existent workspace when starting", func() {
180+
missingWorkspaceName := "non-existent-workspace"
181+
182+
By("creating the HTTP request")
183+
path := strings.Replace(StartWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
184+
path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1)
185+
req, err := http.NewRequest(http.MethodPost, path, http.NoBody)
186+
Expect(err).NotTo(HaveOccurred())
187+
188+
By("setting the auth headers")
189+
req.Header.Set(userIdHeader, adminUser)
190+
191+
By("executing StartWorkspaceHandler")
192+
ps := httprouter.Params{
193+
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
194+
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName},
195+
}
196+
rr := httptest.NewRecorder()
197+
a.StartWorkspaceHandler(rr, req, ps)
198+
rs := rr.Result()
199+
defer rs.Body.Close()
200+
201+
By("verifying the HTTP response status code")
202+
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
203+
})
204+
140205
It("should return 404 for a non-existent workspace", func() {
141206
missingWorkspaceName := "non-existent-workspace"
142207

workspaces/backend/internal/repositories/workspaces/repo.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,11 @@ type WorkspacePausePatch struct {
224224
} `json:"spec"`
225225
}
226226

227-
// PauseWorkspace pauses a workspace by setting its paused field to true
228-
func (r *WorkspaceRepository) PauseWorkspace(ctx context.Context, namespace, workspaceName string) error {
227+
// setWorkspacePausedState sets a workspace's paused state to the provided value
228+
func (r *WorkspaceRepository) setWorkspacePausedState(ctx context.Context, namespace, workspaceName string, paused bool) error {
229229
// Create a patch that only updates the paused field
230230
patch := WorkspacePausePatch{}
231-
patch.Spec.Paused = true
231+
patch.Spec.Paused = paused
232232

233233
// Convert patch to JSON
234234
patchBytes, err := json.Marshal(patch)
@@ -251,3 +251,13 @@ func (r *WorkspaceRepository) PauseWorkspace(ctx context.Context, namespace, wor
251251

252252
return nil
253253
}
254+
255+
// PauseWorkspace pauses a workspace by setting its paused field to true
256+
func (r *WorkspaceRepository) PauseWorkspace(ctx context.Context, namespace, workspaceName string) error {
257+
return r.setWorkspacePausedState(ctx, namespace, workspaceName, true)
258+
}
259+
260+
// StartWorkspace starts a workspace by setting its paused field to false
261+
func (r *WorkspaceRepository) StartWorkspace(ctx context.Context, namespace, workspaceName string) error {
262+
return r.setWorkspacePausedState(ctx, namespace, workspaceName, false)
263+
}

workspaces/backend/openapi/docs.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,82 @@ const docTemplate = `{
461461
}
462462
}
463463
},
464+
"/workspaces/{namespace}/{workspaceName}/actions/start": {
465+
"post": {
466+
"security": [
467+
{
468+
"ApiKeyAuth": []
469+
}
470+
],
471+
"description": "Starts a workspace, resuming all associated pods.",
472+
"consumes": [
473+
"application/json"
474+
],
475+
"produces": [
476+
"application/json"
477+
],
478+
"tags": [
479+
"workspaces"
480+
],
481+
"summary": "Start workspace",
482+
"parameters": [
483+
{
484+
"type": "string",
485+
"example": "default",
486+
"description": "Namespace of the workspace",
487+
"name": "namespace",
488+
"in": "path",
489+
"required": true
490+
},
491+
{
492+
"type": "string",
493+
"example": "my-workspace",
494+
"description": "Name of the workspace",
495+
"name": "workspaceName",
496+
"in": "path",
497+
"required": true
498+
}
499+
],
500+
"responses": {
501+
"200": {
502+
"description": "Successful action. Returns an empty JSON object.",
503+
"schema": {
504+
"$ref": "#/definitions/api.EmptyResponse"
505+
}
506+
},
507+
"400": {
508+
"description": "Bad Request. Invalid workspace kind name format.",
509+
"schema": {
510+
"$ref": "#/definitions/api.ErrorEnvelope"
511+
}
512+
},
513+
"401": {
514+
"description": "Unauthorized. Authentication is required.",
515+
"schema": {
516+
"$ref": "#/definitions/api.ErrorEnvelope"
517+
}
518+
},
519+
"403": {
520+
"description": "Forbidden. User does not have permission to access the workspace.",
521+
"schema": {
522+
"$ref": "#/definitions/api.ErrorEnvelope"
523+
}
524+
},
525+
"404": {
526+
"description": "Not Found. Workspace does not exist.",
527+
"schema": {
528+
"$ref": "#/definitions/api.ErrorEnvelope"
529+
}
530+
},
531+
"500": {
532+
"description": "Internal server error. An unexpected error occurred on the server.",
533+
"schema": {
534+
"$ref": "#/definitions/api.ErrorEnvelope"
535+
}
536+
}
537+
}
538+
}
539+
},
464540
"/workspaces/{namespace}/{workspace_name}": {
465541
"get": {
466542
"security": [

0 commit comments

Comments
 (0)