Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions developing/scripts/setup-kind.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,32 @@ fi
# Ensure kubectl context is set to the Kind cluster
kubectl config use-context "kind-${CLUSTER_NAME}" || true

# Configure StorageClasses with Notebooks labels and annotations
echo "Configuring StorageClasses for the Notebooks UI..."

# Label and annotate the default 'standard' StorageClass
kubectl label storageclass standard \
"notebooks.kubeflow.org/can-use=true" \
--overwrite
kubectl annotate storageclass standard \
"notebooks.kubeflow.org/display-name=Standard (Local Path)" \
"notebooks.kubeflow.org/description=Local path provisioner for development. Data is stored on the node and not replicated." \
--overwrite

# Create an additional 'premium-local' StorageClass (same provisioner, but not usable, for testing)
kubectl apply -f - <<EOF
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: premium-local
labels:
notebooks.kubeflow.org/can-use: "false"
annotations:
notebooks.kubeflow.org/display-name: "Premium Local (Local Path)"
notebooks.kubeflow.org/description: "Simulated premium storage for development. Not enabled for use."
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
EOF

echo "Kind cluster setup complete"
3 changes: 3 additions & 0 deletions workspaces/backend/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ linters-settings:
- unnamedResult
- whyNoLint

goconst:
ignore-strings: "^(true|false)$"

goimports:
local-prefixes: github.com/kubeflow/notebooks/workspaces/backend

Expand Down
15 changes: 15 additions & 0 deletions workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ const (
SecretsByNamespacePath = PathPrefix + "/secrets/:" + NamespacePathParam
SecretsByNamePath = SecretsByNamespacePath + "/:" + ResourceNamePathParam

// storageclasses
AllStorageClassesPath = PathPrefix + "/storageclasses"

// persistentvolumeclaims
PVCsByNamespacePath = PathPrefix + "/persistentvolumeclaims/:" + NamespacePathParam
PVCsByNamePath = PVCsByNamespacePath + "/:" + ResourceNamePathParam

// swagger
SwaggerPath = PathPrefix + "/swagger/*any"
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
Expand Down Expand Up @@ -137,6 +144,14 @@ func (a *App) Routes() http.Handler {
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)

// storageclasses
router.GET(AllStorageClassesPath, a.GetStorageClassesHandler)

// persistentvolumeclaims
router.GET(PVCsByNamespacePath, a.GetPVCsByNamespaceHandler)
router.POST(PVCsByNamespacePath, a.CreatePVCHandler)
router.DELETE(PVCsByNamePath, a.DeletePVCHandler)

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

Expand Down
6 changes: 3 additions & 3 deletions workspaces/backend/api/namespaces_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import (

type NamespaceListEnvelope Envelope[[]models.Namespace]

// GetNamespacesHandler returns a list of all namespaces.
// GetNamespacesHandler returns a list of all namespaces in the cluster.
//
// @Summary Returns a list of all namespaces
// @Description Provides a list of all namespaces that the user has access to
// @Summary List namespaces
// @Description Returns a list of all namespaces in the cluster.
// @Tags namespaces
// @ID listNamespaces
// @Produce application/json
Expand Down
247 changes: 247 additions & 0 deletions workspaces/backend/api/pvcs_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/*
Copyright 2024.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package api

import (
"errors"
"fmt"
"net/http"

"github.com/julienschmidt/httprouter"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/pvcs"
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/pvcs"
)

type PVCListEnvelope Envelope[[]models.PVCListItem]
type PVCCreateEnvelope Envelope[*models.PVCCreate]

// GetPVCsByNamespaceHandler returns a list of persistent volume claims in a specific namespace.
//
// @Summary List persistent volume claims by namespace
// @Description Returns a list of persistent volume claims in a specific namespace.
// @Tags persistentvolumeclaims
// @ID listPVCs
// @Produce application/json
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
// @Success 200 {object} PVCListEnvelope "Successful PVCs response"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /persistentvolumeclaims/{namespace} [get]
func (a *App) GetPVCsByNamespaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { //nolint:dupl
namespace := ps.ByName(NamespacePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(auth.VerbList, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace}),
}
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

pvcs, err := a.repositories.PVC.GetPVCs(r.Context(), namespace)
if err != nil {
a.serverErrorResponse(w, r, err)
return
}

responseEnvelope := &PVCListEnvelope{Data: pvcs}
a.dataResponse(w, r, responseEnvelope)
}

// CreatePVCHandler creates a new persistent volume claim in the specified namespace.
//
// @Summary Create persistent volume claim
// @Description Creates a new persistent volume claim in the specified namespace.
// @Tags persistentvolumeclaims
// @ID createPVC
// @Accept json
// @Produce json
// @Param namespace path string true "Namespace name"
// @Param pvc body PVCCreateEnvelope true "PVC creation request"
// @Success 201 {object} PVCCreateEnvelope "PVC created successfully"
// @Failure 400 {object} ErrorEnvelope "Bad request"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 409 {object} ErrorEnvelope "PVC already exists"
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /persistentvolumeclaims/{namespace} [post]
func (a *App) CreatePVCHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// validate the Content-Type header
if success := a.ValidateContentType(w, r, MediaTypeJson); !success {
return
}

// decode the request body
bodyEnvelope := &PVCCreateEnvelope{}
err := a.DecodeJSON(r, bodyEnvelope)
if err != nil {
if a.IsMaxBytesError(err) {
a.requestEntityTooLargeResponse(w, r, err)
return
}

//
// TODO: handle UnmarshalTypeError and return 422,
// decode the paths which were failed to decode (included in the error)
// and also do this in the other handlers which decode json
//
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
return
}

// validate the request body
dataPath := field.NewPath("data")
if bodyEnvelope.Data == nil {
valErrs = field.ErrorList{field.Required(dataPath, "data is required")}
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}
valErrs = bodyEnvelope.Data.Validate(dataPath)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}

// give the request data a clear name
pvcCreate := bodyEnvelope.Data

// =========================== AUTH ===========================
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(auth.VerbCreate, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace, Name: pvcCreate.Name}),
}
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

createdPVC, err := a.repositories.PVC.CreatePVC(r.Context(), pvcCreate, namespace)
if err != nil {
if helper.IsInternalValidationError(err) {
fieldErrs := helper.FieldErrorsFromInternalValidationError(err)
a.failedValidationResponse(w, r, errMsgInternalValidation, fieldErrs, nil)
return
}
if errors.Is(err, repository.ErrPVCAlreadyExists) {
causes := helper.StatusCausesFromAPIStatus(err)
a.conflictResponse(w, r, err, causes)
return
}
if apierrors.IsInvalid(err) {
causes := helper.StatusCausesFromAPIStatus(err)
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
return
}
a.serverErrorResponse(w, r, fmt.Errorf("error creating PVC: %w", err))
return
}

// calculate the GET location for the created PVC (for the Location header)
// TODO: create a helper LocationGetPVC and call it here when a GET PVC by name endpoint is implemented
location := ""

responseEnvelope := &PVCCreateEnvelope{Data: createdPVC}
a.createdResponse(w, r, responseEnvelope, location)
}

// DeletePVCHandler deletes a specific persistent volume claim by namespace and name.
//
// @Summary Deletes a persistent volume claim
// @Description Deletes a specific persistent volume claim identified by namespace and name.
// @Tags persistentvolumeclaims
// @ID deletePVC
// @Param namespace path string true "Namespace name" extensions(x-example=my-namespace)
// @Param name path string true "PVC name" extensions(x-example=my-pvc)
// @Success 204 "No Content"
// @Failure 401 {object} ErrorEnvelope "Unauthorized"
// @Failure 403 {object} ErrorEnvelope "Forbidden"
// @Failure 404 {object} ErrorEnvelope "PVC not found"
// @Failure 409 {object} ErrorEnvelope "Conflict"
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error"
// @Router /persistentvolumeclaims/{namespace}/{name} [delete]
func (a *App) DeletePVCHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
namespace := ps.ByName(NamespacePathParam)
pvcName := ps.ByName(ResourceNamePathParam)

// validate path parameters
var valErrs field.ErrorList
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
valErrs = append(valErrs, helper.ValidateKubernetesPVCName(field.NewPath(ResourceNamePathParam), pvcName)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(auth.VerbDelete, auth.PersistentVolumeClaims, auth.ResourcePolicyResourceMeta{Namespace: namespace, Name: pvcName}),
}
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

err := a.repositories.PVC.DeletePVC(r.Context(), namespace, pvcName)
if err != nil {
if errors.Is(err, repository.ErrPVCNotFound) {
a.notFoundResponse(w, r)
return
}
if errors.Is(err, repository.ErrPVCNotCanUpdate) {
a.badRequestResponse(w, r, err)
return
}
if apierrors.IsConflict(err) {
causes := helper.StatusCausesFromAPIStatus(err)
a.conflictResponse(w, r, err, causes)
return
}
a.serverErrorResponse(w, r, fmt.Errorf("error deleting PVC: %w", err))
return
}

a.deletedResponse(w, r)
}
Loading
Loading