Skip to content

Commit 49f77ed

Browse files
committed
feat(ws): Implement DELETE endpoint for WorkspaceKind
- Added the ability to delete a specific WorkspaceKind via a new DELETE API endpoint. - `DELETE /workspacekinds/{name}` - Updated the README to include the new endpoint details and added corresponding tests to ensure proper functionality and error handling for various scenarios, including validation and authorization checks. - Swagger files regenerated The implementation is more or less a "carbon copy" of how it is implemented for Workspaces _with the exception_ of the need to handle the `Conflict` that can be returned by the admission webhook. While there is basic handling of this condition in the PR - I'm not necessarily happy with it. First off, there is an error message prefix inserted by kubernetes itself: ``` curl -X 'DELETE' 'http://localhost:4001/api/v1/workspacekinds/jupyterlab' -H 'accept: application/json' { "error": { "code": "409", "message": "admission webhook \"vworkspacekind.kb.io\" denied the request: Operation cannot be fulfilled on WorkspaceKind.kubeflow.org \"jupyterlab\": WorkspaceKind is used by 2 workspace(s)" } } ``` The `admission webhook \"vworkspacekind.kb.io\" denied the request:` is appended on the error message returned from `workspacekind_webhook.go` and not easily able to be omitted (without falling back to brittle string parsing). I personally don't think that is appropriate details to put in an error message that could be surfaced in the UI. And while a `Conflict` error today is the only surfaced from the workspacekind webhook code if a workspace is using it - I'm not confident that condition would hold in the future... and there is no other information I can reliably act on to distinguish this particular cause (to simply provide a more succinct error message in `repo.go`) Furthermore, our testing framework does not currently run with webhooks - so I have deliberately **NOT** added a test case for this behavior. Signed-off-by: Andy Stoneberg <[email protected]>
1 parent fb0e74a commit 49f77ed

File tree

7 files changed

+388
-1
lines changed

7 files changed

+388
-1
lines changed

workspaces/backend/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ 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
@@ -47,6 +47,7 @@ make run PORT=8000
4747
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
4848
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
4949
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
50+
| DELETE /api/v1/workspacekinds/{name} | workspacekinds_handler | Delete a WorkspaceKind entity |
5051
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
5152
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
5253
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
@@ -148,3 +149,10 @@ Get a WorkspaceKind:
148149
# GET /api/v1/workspacekinds/{name}
149150
curl -i localhost:4000/api/v1/workspacekinds/jupyterlab
150151
```
152+
153+
Delete a WorkspaceKind:
154+
155+
```shell
156+
# DELETE /api/v1/workspaces/{namespace}/{name}
157+
curl -X DELETE localhost:4000/api/v1/workspacekinds/jupyterlab
158+
```

workspaces/backend/api/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ func (a *App) Routes() http.Handler {
121121
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
122122
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
123123
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
124+
router.DELETE(WorkspaceKindsByNamePath, a.DeleteWorkspaceKindHandler)
124125

125126
// swagger
126127
router.GET(SwaggerPath, a.GetSwaggerHandler)

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,66 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
131131
a.dataResponse(w, r, responseEnvelope)
132132
}
133133

134+
// DeleteWorkspaceKindHandler deletes a specific workspace kind by name.
135+
//
136+
// @Summary Delete workspace kind
137+
// @Description Deletes a specific workspace kind identified by its name.
138+
// @Tags workspacekinds
139+
// @Accept json
140+
// @Produce json
141+
// @Param name path string true "Name of the workspace kind" extensions(x-example=jupyterlab)
142+
// @Success 204 {object} nil "Workspace kind deleted successfully"
143+
// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name format."
144+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
145+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to delete the workspace kind."
146+
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace kind does not exist."
147+
// @Failure 409 {object} ErrorEnvelope "Conflict. Workspace kind is in use by one or more workspaces."
148+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
149+
// @Router /workspacekinds/{name} [delete]
150+
func (a *App) DeleteWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
151+
name := ps.ByName(ResourceNamePathParam)
152+
153+
// validate path parameters
154+
var valErrs field.ErrorList
155+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), name)...)
156+
if len(valErrs) > 0 {
157+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
158+
return
159+
}
160+
161+
// =========================== AUTH ===========================
162+
authPolicies := []*auth.ResourcePolicy{
163+
auth.NewResourcePolicy(
164+
auth.ResourceVerbDelete,
165+
&kubefloworgv1beta1.WorkspaceKind{
166+
ObjectMeta: metav1.ObjectMeta{
167+
Name: name,
168+
},
169+
},
170+
),
171+
}
172+
if success := a.requireAuth(w, r, authPolicies); !success {
173+
return
174+
}
175+
// ============================================================
176+
177+
err := a.repositories.WorkspaceKind.DeleteWorkspaceKind(r.Context(), name)
178+
if err != nil {
179+
if errors.Is(err, repository.ErrWorkspaceKindNotFound) {
180+
a.notFoundResponse(w, r)
181+
return
182+
}
183+
if apierrors.IsConflict(err) {
184+
a.conflictResponse(w, r, err)
185+
return
186+
}
187+
a.serverErrorResponse(w, r, err)
188+
return
189+
}
190+
191+
a.deletedResponse(w, r)
192+
}
193+
134194
// CreateWorkspaceKindHandler creates a new workspace kind.
135195
//
136196
// @Summary Create workspace kind

workspaces/backend/api/workspacekinds_handler_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
. "github.com/onsi/ginkgo/v2"
3131
. "github.com/onsi/gomega"
3232
corev1 "k8s.io/api/core/v1"
33+
apierrors "k8s.io/apimachinery/pkg/api/errors"
3334
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3435
"k8s.io/apimachinery/pkg/types"
3536
"k8s.io/apimachinery/pkg/util/validation/field"
@@ -507,4 +508,175 @@ metadata:
507508
))
508509
})
509510
})
511+
512+
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
513+
// therefore, we run them using the `Serial` Ginkgo decorator.
514+
Context("when deleting a WorkspaceKind", Serial, func() {
515+
516+
var (
517+
workspaceKindName string
518+
workspaceKindKey types.NamespacedName
519+
workspaceKindToDelete *kubefloworgv1beta1.WorkspaceKind
520+
)
521+
522+
BeforeEach(func() {
523+
uniqueName := fmt.Sprintf("wsk-delete-test-%d", GinkgoRandomSeed())
524+
workspaceKindName = fmt.Sprintf("workspacekind-%s", uniqueName)
525+
workspaceKindKey = types.NamespacedName{Name: workspaceKindName}
526+
527+
By("creating the WorkspaceKind to delete")
528+
workspaceKindToDelete = NewExampleWorkspaceKind(workspaceKindName)
529+
Expect(k8sClient.Create(ctx, workspaceKindToDelete)).To(Succeed())
530+
})
531+
532+
AfterEach(func() {
533+
By("cleaning up the WorkspaceKind")
534+
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
535+
ObjectMeta: metav1.ObjectMeta{
536+
Name: workspaceKindName,
537+
},
538+
}
539+
_ = k8sClient.Delete(ctx, workspaceKind)
540+
})
541+
542+
It("should successfully delete an existing WorkspaceKind", func() {
543+
By("creating the HTTP request to delete the WorkspaceKind")
544+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, workspaceKindName, 1)
545+
req, err := http.NewRequest(http.MethodDelete, path, http.NoBody)
546+
Expect(err).NotTo(HaveOccurred())
547+
548+
By("setting the auth headers")
549+
req.Header.Set(userIdHeader, adminUser)
550+
551+
By("executing DeleteWorkspaceKindHandler")
552+
rr := httptest.NewRecorder()
553+
ps := httprouter.Params{
554+
httprouter.Param{
555+
Key: ResourceNamePathParam,
556+
Value: workspaceKindName,
557+
},
558+
}
559+
a.DeleteWorkspaceKindHandler(rr, req, ps)
560+
rs := rr.Result()
561+
defer rs.Body.Close()
562+
563+
By("verifying the HTTP response status code")
564+
Expect(rs.StatusCode).To(Equal(http.StatusNoContent), descUnexpectedHTTPStatus, rr.Body.String())
565+
566+
By("ensuring the WorkspaceKind has been deleted")
567+
deletedWorkspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
568+
err = k8sClient.Get(ctx, workspaceKindKey, deletedWorkspaceKind)
569+
Expect(err).To(HaveOccurred())
570+
Expect(apierrors.IsNotFound(err)).To(BeTrue())
571+
})
572+
573+
It("should return 404 when trying to delete a non-existent WorkspaceKind", func() {
574+
nonExistentName := "non-existent-workspacekind"
575+
576+
By("creating the HTTP request to delete a non-existent WorkspaceKind")
577+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, nonExistentName, 1)
578+
req, err := http.NewRequest(http.MethodDelete, path, http.NoBody)
579+
Expect(err).NotTo(HaveOccurred())
580+
581+
By("setting the auth headers")
582+
req.Header.Set(userIdHeader, adminUser)
583+
584+
By("executing DeleteWorkspaceKindHandler")
585+
rr := httptest.NewRecorder()
586+
ps := httprouter.Params{
587+
httprouter.Param{
588+
Key: ResourceNamePathParam,
589+
Value: nonExistentName,
590+
},
591+
}
592+
a.DeleteWorkspaceKindHandler(rr, req, ps)
593+
rs := rr.Result()
594+
defer rs.Body.Close()
595+
596+
By("verifying the HTTP response status code")
597+
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
598+
})
599+
600+
It("should return 422 when workspace kind name has invalid format", func() {
601+
invalidName := "InvalidNameWithUppercase"
602+
603+
By("creating the HTTP request with invalid workspace kind name")
604+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, invalidName, 1)
605+
req, err := http.NewRequest(http.MethodDelete, path, http.NoBody)
606+
Expect(err).NotTo(HaveOccurred())
607+
608+
By("setting the auth headers")
609+
req.Header.Set(userIdHeader, adminUser)
610+
611+
By("executing DeleteWorkspaceKindHandler")
612+
rr := httptest.NewRecorder()
613+
ps := httprouter.Params{
614+
httprouter.Param{
615+
Key: ResourceNamePathParam,
616+
Value: invalidName,
617+
},
618+
}
619+
a.DeleteWorkspaceKindHandler(rr, req, ps)
620+
rs := rr.Result()
621+
defer rs.Body.Close()
622+
623+
By("verifying the HTTP response status code")
624+
Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String())
625+
626+
By("decoding the error response")
627+
var response ErrorEnvelope
628+
err = json.Unmarshal(rr.Body.Bytes(), &response)
629+
Expect(err).NotTo(HaveOccurred())
630+
631+
By("verifying the error message indicates validation failure")
632+
Expect(response.Error.Message).To(ContainSubstring("path parameters were invalid"))
633+
})
634+
635+
It("should return 401 when no authentication is provided", func() {
636+
By("creating the HTTP request without auth headers")
637+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, workspaceKindName, 1)
638+
req, err := http.NewRequest(http.MethodDelete, path, http.NoBody)
639+
Expect(err).NotTo(HaveOccurred())
640+
641+
By("executing DeleteWorkspaceKindHandler without auth")
642+
rr := httptest.NewRecorder()
643+
ps := httprouter.Params{
644+
httprouter.Param{
645+
Key: ResourceNamePathParam,
646+
Value: workspaceKindName,
647+
},
648+
}
649+
a.DeleteWorkspaceKindHandler(rr, req, ps)
650+
rs := rr.Result()
651+
defer rs.Body.Close()
652+
653+
By("verifying the HTTP response status code")
654+
Expect(rs.StatusCode).To(Equal(http.StatusUnauthorized), descUnexpectedHTTPStatus, rr.Body.String())
655+
})
656+
657+
It("should return 403 when user lacks permission to delete workspace kind", func() {
658+
By("creating the HTTP request with non-admin user")
659+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, workspaceKindName, 1)
660+
req, err := http.NewRequest(http.MethodDelete, path, http.NoBody)
661+
Expect(err).NotTo(HaveOccurred())
662+
663+
By("setting the auth headers with non-admin user")
664+
req.Header.Set(userIdHeader, "non-admin-user")
665+
666+
By("executing DeleteWorkspaceKindHandler")
667+
rr := httptest.NewRecorder()
668+
ps := httprouter.Params{
669+
httprouter.Param{
670+
Key: ResourceNamePathParam,
671+
Value: workspaceKindName,
672+
},
673+
}
674+
a.DeleteWorkspaceKindHandler(rr, req, ps)
675+
rs := rr.Result()
676+
defer rs.Body.Close()
677+
678+
By("verifying the HTTP response status code")
679+
Expect(rs.StatusCode).To(Equal(http.StatusForbidden), descUnexpectedHTTPStatus, rr.Body.String())
680+
})
681+
})
510682
})

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"sigs.k8s.io/controller-runtime/pkg/client"
2626

2727
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2829
)
2930

3031
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
@@ -92,3 +93,20 @@ func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kub
9293

9394
return &createdWorkspaceKindModel, nil
9495
}
96+
97+
func (r *WorkspaceKindRepository) DeleteWorkspaceKind(ctx context.Context, name string) error {
98+
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
99+
ObjectMeta: metav1.ObjectMeta{
100+
Name: name,
101+
},
102+
}
103+
104+
if err := r.client.Delete(ctx, workspaceKind); err != nil {
105+
if apierrors.IsNotFound(err) {
106+
return ErrWorkspaceKindNotFound
107+
}
108+
return err
109+
}
110+
111+
return nil
112+
}

workspaces/backend/openapi/docs.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,70 @@ const docTemplate = `{
265265
}
266266
}
267267
}
268+
},
269+
"delete": {
270+
"description": "Deletes a specific workspace kind identified by its name.",
271+
"consumes": [
272+
"application/json"
273+
],
274+
"produces": [
275+
"application/json"
276+
],
277+
"tags": [
278+
"workspacekinds"
279+
],
280+
"summary": "Delete workspace kind",
281+
"parameters": [
282+
{
283+
"type": "string",
284+
"x-example": "jupyterlab",
285+
"description": "Name of the workspace kind",
286+
"name": "name",
287+
"in": "path",
288+
"required": true
289+
}
290+
],
291+
"responses": {
292+
"204": {
293+
"description": "Workspace kind deleted successfully"
294+
},
295+
"400": {
296+
"description": "Bad Request. Invalid workspace kind name format.",
297+
"schema": {
298+
"$ref": "#/definitions/api.ErrorEnvelope"
299+
}
300+
},
301+
"401": {
302+
"description": "Unauthorized. Authentication is required.",
303+
"schema": {
304+
"$ref": "#/definitions/api.ErrorEnvelope"
305+
}
306+
},
307+
"403": {
308+
"description": "Forbidden. User does not have permission to delete the workspace kind.",
309+
"schema": {
310+
"$ref": "#/definitions/api.ErrorEnvelope"
311+
}
312+
},
313+
"404": {
314+
"description": "Not Found. Workspace kind does not exist.",
315+
"schema": {
316+
"$ref": "#/definitions/api.ErrorEnvelope"
317+
}
318+
},
319+
"409": {
320+
"description": "Conflict. Workspace kind is in use by one or more workspaces.",
321+
"schema": {
322+
"$ref": "#/definitions/api.ErrorEnvelope"
323+
}
324+
},
325+
"500": {
326+
"description": "Internal server error. An unexpected error occurred on the server.",
327+
"schema": {
328+
"$ref": "#/definitions/api.ErrorEnvelope"
329+
}
330+
}
331+
}
268332
}
269333
},
270334
"/workspaces": {

0 commit comments

Comments
 (0)