feat: Add dry-run functionality for WorkspaceKind creation

- Implement dry-run parameter handling in workspacekinds handler
- Update repository layer to support dry-run
- Add comprehensive test cases for dry-run functionality

Fixes #417
This commit is contained in:
Bhakti Narvekar 2025-09-13 17:53:31 -07:00
parent 253b25e477
commit f3a4b5199e
3 changed files with 232 additions and 9 deletions

View File

@ -21,6 +21,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"github.com/julienschmidt/httprouter"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
@ -153,9 +154,17 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
// @Router /workspacekinds [post]
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// Parse dry-run query parameter
dryRun := r.URL.Query().Get("dry_run")
if dryRun != "" && dryRun != "true" && dryRun != "false" {
a.badRequestResponse(w, r, fmt.Errorf("Invalid dry_run value. Must be 'true' or 'false'"))
return
}
isDryRun := dryRun == "true"
// validate the Content-Type header
if success := a.ValidateContentType(w, r, MediaTypeYaml); !success {
if !strings.EqualFold(r.Header.Get("Content-Type"), MediaTypeYaml) {
a.unsupportedMediaTypeResponse(w, r, fmt.Errorf("Only application/yaml is supported"))
return
}
@ -204,7 +213,7 @@ func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request,
}
// ============================================================
createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind)
createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind, isDryRun)
if err != nil {
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
a.conflictResponse(w, r, err)
@ -219,6 +228,16 @@ func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request,
return
}
// Set response Content-Type header
w.Header().Set("Content-Type", "application/json")
// Return appropriate response based on dry-run
if isDryRun {
responseEnvelope := &WorkspaceKindEnvelope{Data: *createdWorkspaceKind}
a.dataResponse(w, r, responseEnvelope)
return
}
// calculate the GET location for the created workspace kind (for the Location header)
location := a.LocationGetWorkspaceKind(createdWorkspaceKind.Name)

View File

@ -30,6 +30,7 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation/field"
@ -507,4 +508,204 @@ metadata:
))
})
})
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
// therefore, we run them using the `Serial` Ginkgo decorator.
Context("when creating a WorkspaceKind", Serial, func() {
var newWorkspaceKindName = "wsk-create-test"
var validYAML []byte
BeforeEach(func() {
validYAML = []byte(fmt.Sprintf(`
apiVersion: kubeflow.org/v1beta1
kind: WorkspaceKind
metadata:
name: %s
spec:
spawner:
displayName: "JupyterLab Notebook"
description: "A Workspace which runs JupyterLab in a Pod"
icon:
url: "https://jupyter.org/assets/favicons/apple-touch-icon.png"
logo:
url: "https://jupyter.org/assets/logos/jupyter/jupyter.png"
podTemplate:
serviceAccount:
name: default-editor
volumeMounts:
home: "/home/jovyan"
options:
imageConfig:
default: "jupyterlab_scipy_180"
values:
- id: "jupyterlab_scipy_180"
displayName: "JupyterLab SciPy 1.8.0"
description: "JupyterLab with SciPy 1.8.0"
spec:
image: "jupyter/scipy-notebook:2024.1.0"
podConfig:
default: "tiny_cpu"
values:
- id: "tiny_cpu"
displayName: "Tiny CPU"
description: "1 CPU core, 2GB RAM"
spec:
resources:
requests:
cpu: "100m"
memory: "512Mi"
limits:
cpu: "1"
memory: "2Gi"
`, newWorkspaceKindName))
})
AfterEach(func() {
By("deleting the WorkspaceKind if it exists")
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: newWorkspaceKindName,
},
}
_ = k8sClient.Delete(ctx, workspaceKind)
})
It("should create a WorkspaceKind successfully without dry-run", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
Expect(err).NotTo(HaveOccurred())
By("setting required headers")
req.Header.Set("Content-Type", MediaTypeYaml)
req.Header.Set(userIdHeader, adminUser)
By("executing CreateWorkspaceKindHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String())
By("verifying the Location header")
location := rs.Header.Get("Location")
Expect(location).To(Equal(fmt.Sprintf("/api/v1/workspacekinds/%s", newWorkspaceKindName)))
By("reading and parsing the response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
var response WorkspaceKindCreateEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("verifying the created resource exists in the cluster")
createdWorkspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWorkspaceKind)
Expect(err).NotTo(HaveOccurred())
By("verifying the response matches the created resource")
expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(createdWorkspaceKind)
Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind))
})
It("should validate WorkspaceKind with dry-run=true without creating it", func() {
By("creating the HTTP request with dry-run=true")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath+"?dry_run=true", bytes.NewReader(validYAML))
Expect(err).NotTo(HaveOccurred())
By("setting required headers")
req.Header.Set("Content-Type", MediaTypeYaml)
req.Header.Set(userIdHeader, adminUser)
By("executing CreateWorkspaceKindHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusOK), descUnexpectedHTTPStatus, rr.Body.String())
By("reading and parsing the response body")
body, err := io.ReadAll(rs.Body)
Expect(err).NotTo(HaveOccurred())
var response WorkspaceKindEnvelope
err = json.Unmarshal(body, &response)
Expect(err).NotTo(HaveOccurred())
By("verifying the resource was not created in the cluster")
notCreatedWorkspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, notCreatedWorkspaceKind)
Expect(err).To(HaveOccurred())
Expect(apierrors.IsNotFound(err)).To(BeTrue())
})
It("should return 400 for invalid YAML", func() {
invalidYAML := []byte("invalid: yaml: :")
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
Expect(err).NotTo(HaveOccurred())
By("setting required headers")
req.Header.Set("Content-Type", MediaTypeYaml)
req.Header.Set(userIdHeader, adminUser)
By("executing CreateWorkspaceKindHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String())
})
It("should return 415 for wrong content-type", func() {
By("creating the HTTP request")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
Expect(err).NotTo(HaveOccurred())
By("setting wrong content-type header")
req.Header.Set("Content-Type", MediaTypeJson)
req.Header.Set(userIdHeader, adminUser)
By("executing CreateWorkspaceKindHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusUnsupportedMediaType), descUnexpectedHTTPStatus, rr.Body.String())
})
It("should return 400 for invalid dry-run value", func() {
By("creating the HTTP request with invalid dry-run value")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath+"?dry_run=invalid", bytes.NewReader(validYAML))
Expect(err).NotTo(HaveOccurred())
By("setting required headers")
req.Header.Set("Content-Type", MediaTypeYaml)
req.Header.Set(userIdHeader, adminUser)
By("executing CreateWorkspaceKindHandler")
ps := httprouter.Params{}
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, ps)
rs := rr.Result()
defer rs.Body.Close()
By("verifying the HTTP response status code")
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String())
})
})
})

View File

@ -70,24 +70,27 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
return workspaceKindsModels, nil
}
func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) (*models.WorkspaceKind, error) {
// create workspace kind
func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind, dryRun bool) (*models.WorkspaceKind, error) {
if dryRun {
// For dry-run, just convert to model and return without creating
workspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)
return &workspaceKindModel, nil
}
// Create workspace kind only if not dry-run
if err := r.client.Create(ctx, workspaceKind); err != nil {
if apierrors.IsAlreadyExists(err) {
return nil, ErrWorkspaceKindAlreadyExists
}
if apierrors.IsInvalid(err) {
// NOTE: we don't wrap this error so we can unpack it in the caller
// and extract the validation errors returned by the Kubernetes API server
// and extract the validation errors returned by the Kubernetes API server
return nil, err
}
return nil, err
}
// convert the created workspace to a WorkspaceKindUpdate model
//
// TODO: this function should return the WorkspaceKindUpdate model, once the update WSK api is implemented
//
// Convert the created workspace to a WorkspaceKindUpdate model
createdWorkspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)
return &createdWorkspaceKindModel, nil