Skip to content

Commit fb2e2fb

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 0882a6b commit fb2e2fb

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
@@ -212,11 +212,11 @@ type WorkspacePausePatch struct {
212212
} `json:"spec"`
213213
}
214214

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

221221
// Convert patch to JSON
222222
patchBytes, err := json.Marshal(patch)
@@ -239,3 +239,13 @@ func (r *WorkspaceRepository) PauseWorkspace(ctx context.Context, namespace, wor
239239

240240
return nil
241241
}
242+
243+
// PauseWorkspace pauses a workspace by setting its paused field to true
244+
func (r *WorkspaceRepository) PauseWorkspace(ctx context.Context, namespace, workspaceName string) error {
245+
return r.setWorkspacePausedState(ctx, namespace, workspaceName, true)
246+
}
247+
248+
// StartWorkspace starts a workspace by setting its paused field to false
249+
func (r *WorkspaceRepository) StartWorkspace(ctx context.Context, namespace, workspaceName string) error {
250+
return r.setWorkspacePausedState(ctx, namespace, workspaceName, false)
251+
}

workspaces/backend/openapi/docs.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,82 @@ const docTemplate = `{
120120
}
121121
}
122122
}
123+
},
124+
"/workspaces/{namespace}/{workspaceName}/actions/start": {
125+
"post": {
126+
"security": [
127+
{
128+
"ApiKeyAuth": []
129+
}
130+
],
131+
"description": "Starts a workspace, resuming all associated pods.",
132+
"consumes": [
133+
"application/json"
134+
],
135+
"produces": [
136+
"application/json"
137+
],
138+
"tags": [
139+
"workspaces"
140+
],
141+
"summary": "Start workspace",
142+
"parameters": [
143+
{
144+
"type": "string",
145+
"example": "default",
146+
"description": "Namespace of the workspace",
147+
"name": "namespace",
148+
"in": "path",
149+
"required": true
150+
},
151+
{
152+
"type": "string",
153+
"example": "my-workspace",
154+
"description": "Name of the workspace",
155+
"name": "workspaceName",
156+
"in": "path",
157+
"required": true
158+
}
159+
],
160+
"responses": {
161+
"200": {
162+
"description": "Successful action. Returns an empty JSON object.",
163+
"schema": {
164+
"$ref": "#/definitions/api.EmptyResponse"
165+
}
166+
},
167+
"400": {
168+
"description": "Bad Request. Invalid workspace kind name format.",
169+
"schema": {
170+
"$ref": "#/definitions/api.ErrorEnvelope"
171+
}
172+
},
173+
"401": {
174+
"description": "Unauthorized. Authentication is required.",
175+
"schema": {
176+
"$ref": "#/definitions/api.ErrorEnvelope"
177+
}
178+
},
179+
"403": {
180+
"description": "Forbidden. User does not have permission to access the workspace.",
181+
"schema": {
182+
"$ref": "#/definitions/api.ErrorEnvelope"
183+
}
184+
},
185+
"404": {
186+
"description": "Not Found. Workspace does not exist.",
187+
"schema": {
188+
"$ref": "#/definitions/api.ErrorEnvelope"
189+
}
190+
},
191+
"500": {
192+
"description": "Internal server error. An unexpected error occurred on the server.",
193+
"schema": {
194+
"$ref": "#/definitions/api.ErrorEnvelope"
195+
}
196+
}
197+
}
198+
}
123199
}
124200
},
125201
"definitions": {

0 commit comments

Comments
 (0)