Skip to content

Commit 0882a6b

Browse files
committed
feat(ws): Implement pause workspace functionality as backend API
related: #298 - Added PauseWorkspaceHandler to handle the pause operation for workspaces. - Introduced new route for pausing workspaces in the API. - Created a new EmptyResponse type for successful responses. - Added tests for the pause workspace functionality, including success and error cases. - Updated README/OpenAPI documentation to include the new pause workspace endpoint. Signed-off-by: Andy Stoneberg <[email protected]>
1 parent bde6b4b commit 0882a6b

File tree

9 files changed

+543
-19
lines changed

9 files changed

+543
-19
lines changed

workspaces/backend/README.md

Lines changed: 27 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 | Pause a running 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,13 @@ 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+
```
138+
131139
Delete a Workspace:
132140

133141
```shell

workspaces/backend/api/app.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const (
4545
AllWorkspacesPath = PathPrefix + "/workspaces"
4646
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
4747
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + ResourceNamePathParam
48+
WorkspaceActionsPath = WorkspacesByNamePath + "/actions"
49+
PauseWorkspacePath = WorkspaceActionsPath + "/pause"
4850

4951
// workspacekinds
5052
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
@@ -102,6 +104,7 @@ func (a *App) Routes() http.Handler {
102104
router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
103105
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
104106
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
107+
router.POST(PauseWorkspacePath, a.PauseWorkspaceHandler)
105108

106109
// workspacekinds
107110
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)

workspaces/backend/api/helpers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type Envelope[D any] struct {
3131
Data D `json:"data"`
3232
}
3333

34+
// EmptyResponse represents an empty JSON response
35+
type EmptyResponse struct{}
36+
3437
// WriteJSON writes a JSON response with the given status code, data, and headers.
3538
func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
3639

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
"net/http"
22+
23+
"github.com/julienschmidt/httprouter"
24+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/util/validation/field"
27+
28+
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
29+
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
30+
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
31+
)
32+
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) {
51+
namespace := ps.ByName(NamespacePathParam)
52+
workspaceName := ps.ByName(ResourceNamePathParam)
53+
54+
// validate path parameters
55+
var valErrs field.ErrorList
56+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
57+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), workspaceName)...)
58+
if len(valErrs) > 0 {
59+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
60+
return
61+
}
62+
63+
// =========================== AUTH ===========================
64+
authPolicies := []*auth.ResourcePolicy{
65+
auth.NewResourcePolicy(
66+
auth.ResourceVerbUpdate,
67+
&kubefloworgv1beta1.Workspace{
68+
ObjectMeta: metav1.ObjectMeta{
69+
Namespace: namespace,
70+
Name: workspaceName,
71+
},
72+
},
73+
),
74+
}
75+
if success := a.requireAuth(w, r, authPolicies); !success {
76+
return
77+
}
78+
// ============================================================
79+
80+
err := a.repositories.Workspace.PauseWorkspace(r.Context(), namespace, workspaceName)
81+
if err != nil {
82+
if errors.Is(err, repository.ErrWorkspaceNotFound) {
83+
a.notFoundResponse(w, r)
84+
return
85+
}
86+
a.serverErrorResponse(w, r, err)
87+
return
88+
}
89+
90+
// Return 200 OK with empty JSON object
91+
err = a.WriteJSON(w, http.StatusOK, EmptyResponse{}, nil)
92+
if err != nil {
93+
a.serverErrorResponse(w, r, err)
94+
return
95+
}
96+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
"fmt"
21+
"io"
22+
"net/http"
23+
"net/http/httptest"
24+
"strings"
25+
26+
"github.com/julienschmidt/httprouter"
27+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
28+
. "github.com/onsi/ginkgo/v2"
29+
. "github.com/onsi/gomega"
30+
corev1 "k8s.io/api/core/v1"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
32+
"k8s.io/apimachinery/pkg/types"
33+
"k8s.io/utils/ptr"
34+
)
35+
36+
var _ = Describe("Workspace Actions Handler", func() {
37+
38+
// NOTE: the tests in this context work on the same resources, they must be run in order.
39+
// also, they assume a specific state of the cluster, so cannot be run in parallel with other tests.
40+
// therefore, we run them using the `Ordered` and `Serial` Ginkgo decorators.
41+
Context("with existing Workspaces", Serial, Ordered, func() {
42+
43+
const namespaceName1 = "ws-ops-ns1"
44+
45+
var (
46+
workspaceName1 string
47+
workspaceKey1 types.NamespacedName
48+
workspaceKindName string
49+
)
50+
51+
BeforeAll(func() {
52+
uniqueName := "ws-ops-test"
53+
workspaceName1 = fmt.Sprintf("workspace-1-%s", uniqueName)
54+
workspaceKey1 = types.NamespacedName{Name: workspaceName1, Namespace: namespaceName1}
55+
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
56+
57+
By("creating Namespace 1")
58+
namespace1 := &corev1.Namespace{
59+
ObjectMeta: metav1.ObjectMeta{
60+
Name: namespaceName1,
61+
},
62+
}
63+
Expect(k8sClient.Create(ctx, namespace1)).To(Succeed())
64+
65+
By("creating a WorkspaceKind")
66+
workspaceKind := NewExampleWorkspaceKind(workspaceKindName)
67+
Expect(k8sClient.Create(ctx, workspaceKind)).To(Succeed())
68+
69+
By("creating Workspace 1 in Namespace 1")
70+
workspace1 := NewExampleWorkspace(workspaceName1, namespaceName1, workspaceKindName)
71+
Expect(k8sClient.Create(ctx, workspace1)).To(Succeed())
72+
})
73+
74+
AfterAll(func() {
75+
By("deleting Workspace 1 from Namespace 1")
76+
workspace1 := &kubefloworgv1beta1.Workspace{
77+
ObjectMeta: metav1.ObjectMeta{
78+
Name: workspaceName1,
79+
Namespace: namespaceName1,
80+
},
81+
}
82+
Expect(k8sClient.Delete(ctx, workspace1)).To(Succeed())
83+
84+
By("deleting WorkspaceKind")
85+
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
86+
ObjectMeta: metav1.ObjectMeta{
87+
Name: workspaceKindName,
88+
},
89+
}
90+
Expect(k8sClient.Delete(ctx, workspaceKind)).To(Succeed())
91+
92+
By("deleting Namespace 1")
93+
namespace1 := &corev1.Namespace{
94+
ObjectMeta: metav1.ObjectMeta{
95+
Name: namespaceName1,
96+
},
97+
}
98+
Expect(k8sClient.Delete(ctx, namespace1)).To(Succeed())
99+
})
100+
101+
It("should pause a workspace successfully", func() {
102+
By("creating the HTTP request")
103+
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
104+
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceName1, 1)
105+
req, err := http.NewRequest(http.MethodPost, path, http.NoBody)
106+
Expect(err).NotTo(HaveOccurred())
107+
108+
By("setting the auth headers")
109+
req.Header.Set(userIdHeader, adminUser)
110+
req.Header.Set("Content-Type", "application/merge-patch+json")
111+
112+
By("executing PauseWorkspaceHandler")
113+
ps := httprouter.Params{
114+
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
115+
httprouter.Param{Key: ResourceNamePathParam, Value: workspaceName1},
116+
}
117+
rr := httptest.NewRecorder()
118+
a.PauseWorkspaceHandler(rr, req, ps)
119+
rs := rr.Result()
120+
defer rs.Body.Close()
121+
122+
By("verifying the HTTP response status code")
123+
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
124+
125+
By("reading the HTTP response body")
126+
body, err := io.ReadAll(rs.Body)
127+
Expect(err).NotTo(HaveOccurred())
128+
129+
By("verifying the response is an empty JSON object")
130+
Expect(string(body)).To(Equal("{}\n"))
131+
132+
By("getting the Workspace from the Kubernetes API")
133+
workspace := &kubefloworgv1beta1.Workspace{}
134+
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
135+
136+
By("ensuring the workspace is paused")
137+
Expect(workspace.Spec.Paused).To(Equal(ptr.To(true)))
138+
})
139+
140+
It("should return 404 for a non-existent workspace", func() {
141+
missingWorkspaceName := "non-existent-workspace"
142+
143+
By("creating the HTTP request")
144+
path := strings.Replace(PauseWorkspacePath, ":"+NamespacePathParam, namespaceName1, 1)
145+
path = strings.Replace(path, ":"+ResourceNamePathParam, missingWorkspaceName, 1)
146+
req, err := http.NewRequest(http.MethodPost, path, http.NoBody)
147+
Expect(err).NotTo(HaveOccurred())
148+
149+
By("setting the auth headers")
150+
req.Header.Set(userIdHeader, adminUser)
151+
152+
By("executing PauseWorkspaceHandler")
153+
ps := httprouter.Params{
154+
httprouter.Param{Key: NamespacePathParam, Value: namespaceName1},
155+
httprouter.Param{Key: ResourceNamePathParam, Value: missingWorkspaceName},
156+
}
157+
rr := httptest.NewRecorder()
158+
a.PauseWorkspaceHandler(rr, req, ps)
159+
rs := rr.Result()
160+
defer rs.Body.Close()
161+
162+
By("verifying the HTTP response status code")
163+
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
164+
})
165+
})
166+
})

0 commit comments

Comments
 (0)