Skip to content

Commit 455b333

Browse files
feat: add PVC and StorageClass backend API endpoints (#905)
* feat: add PVC and StorageClass backend API endpoints Implements List, Create, and Delete endpoints for PersistentVolumeClaims and a List endpoint for StorageClasses in the backend API. PVC List endpoint includes cross-references to: - Bound PersistentVolume (with StorageClass metadata) - Pods that mount the PVC - Workspaces that reference the PVC (via home or data volumes) Cross-reference collections (pods, workspaces) always serialize as empty arrays `[]` rather than being omitted, matching the pattern used by `services` and `data` on WorkspaceListItem. The `pv` field uses `omitempty` and is omitted when the PVC is unbound. Pod and Workspace cross-references use a list-once-and-map approach (single List call per resource type, then in-memory lookup) rather than per-PVC queries, since Kubernetes does not support field selectors for filtering pods by PVC claim name. Notable: - RBAC includes `persistentvolumes` (get/list/watch) in addition to `persistentvolumeclaims`, since bound PV metadata is resolved for each PVC in the list response. - PVC Create returns an empty Location header because there is no GET-by-name endpoint for PVCs (only list by namespace). - PVC sentinel errors use `errors.New` instead of `fmt.Errorf` (without format verbs), aligning with the workspacekinds repository pattern rather than the workspaces/secrets pattern. - Sample manifests (PVCs, Secret) now include `can-mount` and `can-update` labels so resources created outside the API render correctly in list responses. Signed-off-by: Andy Stoneberg <astonebe@redhat.com> * mathew: 1 Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> * mathew: 2 Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> * mathew: 3 Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> * chore: mathew PR verification - fixed tests to ensure all passing - fixed reference to ensure we are using `pvc.Spec.StorageClassName` when getting pvcs - added storageclass data prep to `setup-kind.sh` to facilitate better/more realistic local development Signed-off-by: Andy Stoneberg <astonebe@redhat.com> --------- Signed-off-by: Andy Stoneberg <astonebe@redhat.com> Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> Co-authored-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
1 parent 22af5cf commit 455b333

39 files changed

+3880
-167
lines changed

developing/scripts/setup-kind.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,32 @@ fi
2828
# Ensure kubectl context is set to the Kind cluster
2929
kubectl config use-context "kind-${CLUSTER_NAME}" || true
3030

31+
# Configure StorageClasses with Notebooks labels and annotations
32+
echo "Configuring StorageClasses for the Notebooks UI..."
33+
34+
# Label and annotate the default 'standard' StorageClass
35+
kubectl label storageclass standard \
36+
"notebooks.kubeflow.org/can-use=true" \
37+
--overwrite
38+
kubectl annotate storageclass standard \
39+
"notebooks.kubeflow.org/display-name=Standard (Local Path)" \
40+
"notebooks.kubeflow.org/description=Local path provisioner for development. Data is stored on the node and not replicated." \
41+
--overwrite
42+
43+
# Create an additional 'premium-local' StorageClass (same provisioner, but not usable, for testing)
44+
kubectl apply -f - <<EOF
45+
apiVersion: storage.k8s.io/v1
46+
kind: StorageClass
47+
metadata:
48+
name: premium-local
49+
labels:
50+
notebooks.kubeflow.org/can-use: "false"
51+
annotations:
52+
notebooks.kubeflow.org/display-name: "Premium Local (Local Path)"
53+
notebooks.kubeflow.org/description: "Simulated premium storage for development. Not enabled for use."
54+
provisioner: rancher.io/local-path
55+
reclaimPolicy: Delete
56+
volumeBindingMode: WaitForFirstConsumer
57+
EOF
58+
3159
echo "Kind cluster setup complete"

workspaces/backend/.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ linters-settings:
7878
- unnamedResult
7979
- whyNoLint
8080

81+
goconst:
82+
ignore-strings: "^(true|false)$"
83+
8184
goimports:
8285
local-prefixes: github.com/kubeflow/notebooks/workspaces/backend
8386

workspaces/backend/api/app.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ const (
6464
SecretsByNamespacePath = PathPrefix + "/secrets/:" + NamespacePathParam
6565
SecretsByNamePath = SecretsByNamespacePath + "/:" + ResourceNamePathParam
6666

67+
// storageclasses
68+
AllStorageClassesPath = PathPrefix + "/storageclasses"
69+
70+
// persistentvolumeclaims
71+
PVCsByNamespacePath = PathPrefix + "/persistentvolumeclaims/:" + NamespacePathParam
72+
PVCsByNamePath = PVCsByNamespacePath + "/:" + ResourceNamePathParam
73+
6774
// swagger
6875
SwaggerPath = PathPrefix + "/swagger/*any"
6976
)
@@ -136,6 +143,14 @@ func (a *App) Routes() http.Handler {
136143
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
137144
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
138145

146+
// storageclasses
147+
router.GET(AllStorageClassesPath, a.GetStorageClassesHandler)
148+
149+
// persistentvolumeclaims
150+
router.GET(PVCsByNamespacePath, a.GetPVCsByNamespaceHandler)
151+
router.POST(PVCsByNamespacePath, a.CreatePVCHandler)
152+
router.DELETE(PVCsByNamePath, a.DeletePVCHandler)
153+
139154
// swagger
140155
if a.Config.SwaggerEnabled {
141156
router.GET(SwaggerPath, a.GetSwaggerHandler)

workspaces/backend/api/namespaces_handler.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import (
2727

2828
type NamespaceListEnvelope Envelope[[]models.Namespace]
2929

30-
// GetNamespacesHandler returns a list of all namespaces.
30+
// GetNamespacesHandler returns a list of all namespaces in the cluster.
3131
//
32-
// @Summary Returns a list of all namespaces
33-
// @Description Provides a list of all namespaces that the user has access to
32+
// @Summary List namespaces
33+
// @Description Returns a list of all namespaces in the cluster.
3434
// @Tags namespaces
3535
// @ID listNamespaces
3636
// @Produce application/json
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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+
apierrors "k8s.io/apimachinery/pkg/api/errors"
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+
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/pvcs"
31+
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/pvcs"
32+
)
33+
34+
type PVCListEnvelope Envelope[[]models.PVCListItem]
35+
type PVCCreateEnvelope Envelope[*models.PVCCreate]
36+
37+
// GetPVCsByNamespaceHandler returns a list of persistent volume claims in a specific namespace.
38+
//
39+
// @Summary List persistent volume claims by namespace
40+
// @Description Returns a list of persistent volume claims in a specific namespace.
41+
// @Tags persistentvolumeclaims
42+
// @ID listPVCs
43+
// @Produce application/json
44+
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
45+
// @Success 200 {object} PVCListEnvelope "Successful PVCs response"
46+
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
47+
// @Failure 403 {object} ErrorEnvelope "Forbidden"
48+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
49+
// @Failure 500 {object} ErrorEnvelope "Internal server error"
50+
// @Router /persistentvolumeclaims/{namespace} [get]
51+
func (a *App) GetPVCsByNamespaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { //nolint:dupl
52+
namespace := ps.ByName(NamespacePathParam)
53+
54+
// validate path parameters
55+
var valErrs field.ErrorList
56+
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
57+
if len(valErrs) > 0 {
58+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
59+
return
60+
}
61+
62+
// =========================== AUTH ===========================
63+
authPolicies := []*auth.ResourcePolicy{
64+
auth.NewResourcePolicy(auth.VerbList, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace}),
65+
}
66+
if success := a.requireAuth(w, r, authPolicies); !success {
67+
return
68+
}
69+
// ============================================================
70+
71+
pvcs, err := a.repositories.PVC.GetPVCs(r.Context(), namespace)
72+
if err != nil {
73+
a.serverErrorResponse(w, r, err)
74+
return
75+
}
76+
77+
responseEnvelope := &PVCListEnvelope{Data: pvcs}
78+
a.dataResponse(w, r, responseEnvelope)
79+
}
80+
81+
// CreatePVCHandler creates a new persistent volume claim in the specified namespace.
82+
//
83+
// @Summary Create persistent volume claim
84+
// @Description Creates a new persistent volume claim in the specified namespace.
85+
// @Tags persistentvolumeclaims
86+
// @ID createPVC
87+
// @Accept json
88+
// @Produce json
89+
// @Param namespace path string true "Namespace name"
90+
// @Param pvc body PVCCreateEnvelope true "PVC creation request"
91+
// @Success 201 {object} PVCCreateEnvelope "PVC created successfully"
92+
// @Failure 400 {object} ErrorEnvelope "Bad request"
93+
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
94+
// @Failure 403 {object} ErrorEnvelope "Forbidden"
95+
// @Failure 409 {object} ErrorEnvelope "PVC already exists"
96+
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
97+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
98+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
99+
// @Failure 500 {object} ErrorEnvelope "Internal server error"
100+
// @Router /persistentvolumeclaims/{namespace} [post]
101+
func (a *App) CreatePVCHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
102+
namespace := ps.ByName(NamespacePathParam)
103+
104+
// validate path parameters
105+
var valErrs field.ErrorList
106+
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
107+
if len(valErrs) > 0 {
108+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
109+
return
110+
}
111+
112+
// validate the Content-Type header
113+
if success := a.ValidateContentType(w, r, MediaTypeJson); !success {
114+
return
115+
}
116+
117+
// decode the request body
118+
bodyEnvelope := &PVCCreateEnvelope{}
119+
err := a.DecodeJSON(r, bodyEnvelope)
120+
if err != nil {
121+
if a.IsMaxBytesError(err) {
122+
a.requestEntityTooLargeResponse(w, r, err)
123+
return
124+
}
125+
126+
//
127+
// TODO: handle UnmarshalTypeError and return 422,
128+
// decode the paths which were failed to decode (included in the error)
129+
// and also do this in the other handlers which decode json
130+
//
131+
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
132+
return
133+
}
134+
135+
// validate the request body
136+
dataPath := field.NewPath("data")
137+
if bodyEnvelope.Data == nil {
138+
valErrs = field.ErrorList{field.Required(dataPath, "data is required")}
139+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
140+
return
141+
}
142+
valErrs = bodyEnvelope.Data.Validate(dataPath)
143+
if len(valErrs) > 0 {
144+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
145+
return
146+
}
147+
148+
// give the request data a clear name
149+
pvcCreate := bodyEnvelope.Data
150+
151+
// =========================== AUTH ===========================
152+
authPolicies := []*auth.ResourcePolicy{
153+
auth.NewResourcePolicy(auth.VerbCreate, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace, Name: pvcCreate.Name}),
154+
}
155+
if success := a.requireAuth(w, r, authPolicies); !success {
156+
return
157+
}
158+
// ============================================================
159+
160+
createdPVC, err := a.repositories.PVC.CreatePVC(r.Context(), pvcCreate, namespace)
161+
if err != nil {
162+
if helper.IsInternalValidationError(err) {
163+
fieldErrs := helper.FieldErrorsFromInternalValidationError(err)
164+
a.failedValidationResponse(w, r, errMsgInternalValidation, fieldErrs, nil)
165+
return
166+
}
167+
if errors.Is(err, repository.ErrPVCAlreadyExists) {
168+
causes := helper.StatusCausesFromAPIStatus(err)
169+
a.conflictResponse(w, r, err, causes)
170+
return
171+
}
172+
if apierrors.IsInvalid(err) {
173+
causes := helper.StatusCausesFromAPIStatus(err)
174+
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
175+
return
176+
}
177+
a.serverErrorResponse(w, r, fmt.Errorf("error creating PVC: %w", err))
178+
return
179+
}
180+
181+
// calculate the GET location for the created PVC (for the Location header)
182+
// TODO: create a helper LocationGetPVC and call it here when a GET PVC by name endpoint is implemented
183+
location := ""
184+
185+
responseEnvelope := &PVCCreateEnvelope{Data: createdPVC}
186+
a.createdResponse(w, r, responseEnvelope, location)
187+
}
188+
189+
// DeletePVCHandler deletes a specific persistent volume claim by namespace and name.
190+
//
191+
// @Summary Deletes a persistent volume claim
192+
// @Description Deletes a specific persistent volume claim identified by namespace and name.
193+
// @Tags persistentvolumeclaims
194+
// @ID deletePVC
195+
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
196+
// @Param name path string true "PVC name" extensions(x-example=my-pvc)
197+
// @Success 204 "No Content"
198+
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
199+
// @Failure 403 {object} ErrorEnvelope "Forbidden"
200+
// @Failure 404 {object} ErrorEnvelope "PVC not found"
201+
// @Failure 409 {object} ErrorEnvelope "Conflict"
202+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
203+
// @Failure 500 {object} ErrorEnvelope "Internal server error"
204+
// @Router /persistentvolumeclaims/{namespace}/{name} [delete]
205+
func (a *App) DeletePVCHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
206+
namespace := ps.ByName(NamespacePathParam)
207+
pvcName := ps.ByName(ResourceNamePathParam)
208+
209+
// validate path parameters
210+
var valErrs field.ErrorList
211+
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
212+
valErrs = append(valErrs, helper.ValidateKubernetesPVCName(field.NewPath(ResourceNamePathParam), pvcName)...)
213+
if len(valErrs) > 0 {
214+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
215+
return
216+
}
217+
218+
// =========================== AUTH ===========================
219+
authPolicies := []*auth.ResourcePolicy{
220+
auth.NewResourcePolicy(auth.VerbDelete, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace, Name: pvcName}),
221+
}
222+
if success := a.requireAuth(w, r, authPolicies); !success {
223+
return
224+
}
225+
// ============================================================
226+
227+
err := a.repositories.PVC.DeletePVC(r.Context(), namespace, pvcName)
228+
if err != nil {
229+
if errors.Is(err, repository.ErrPVCNotFound) {
230+
a.notFoundResponse(w, r)
231+
return
232+
}
233+
if errors.Is(err, repository.ErrPVCNotCanUpdate) {
234+
a.badRequestResponse(w, r, err)
235+
return
236+
}
237+
if apierrors.IsConflict(err) {
238+
causes := helper.StatusCausesFromAPIStatus(err)
239+
a.conflictResponse(w, r, err, causes)
240+
return
241+
}
242+
a.serverErrorResponse(w, r, fmt.Errorf("error deleting PVC: %w", err))
243+
return
244+
}
245+
246+
a.deletedResponse(w, r)
247+
}

0 commit comments

Comments
 (0)