Skip to content
Closed
Show file tree
Hide file tree
Changes from 69 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
e07cc59
Tilt setup for backend
Jun 15, 2025
0ac6f9f
feat(ws): added namespaces tab to workspace kind details (#406)
paulovmr Jun 5, 2025
92661f0
chore(ws): Upgrade vulnerable package webpack-dev-server (#407)
paulovmr Jun 5, 2025
4ce7875
fix(ws): Action Button Alignment and Jupyter Image Display (#408)
jenny-s51 Jun 16, 2025
cd02eb4
fix(ws): Expose active nav item on initial Workspaces page load (#419)
jenny-s51 Jun 16, 2025
99538a7
chore(ws): enforce named imports for react hooks (#414)
paulovmr Jun 16, 2025
72b3ba9
chore(ws): Upgrade vulnerable packages (#427)
paulovmr Jun 16, 2025
ca7f656
feat(ws): add `WorkspaceKindSummary` page and other improvements arou…
caponetto Jun 17, 2025
0c87cb6
chore(ws): allowed theme configuration during frontend start (#438)
paulovmr Jun 24, 2025
234dee0
feat(ws): Notebooks v2 Create Workspace Kind (#365)
thaorell Jun 25, 2025
c0b8f7b
chore(ws): lint frontend on each commit (#440)
caponetto Jun 25, 2025
eb8d3ac
chore(ws): show ESLint errors from local rules on IDE (#439)
caponetto Jun 25, 2025
4c91627
feat(ws): validate podMetadata for ws and wsk in webhook (#436)
thesuperzapper Jun 26, 2025
eb70a94
feat(ws): fix swagger warnings and only generate json output (#424)
liavweiss Jun 26, 2025
48b690e
ci(ws): archive frontend cypress test results in github actions (#396)
jiridanek Jun 26, 2025
e3978c2
chore: reference ghcr.io images in workspacekind yaml (#305)
andyatmiami Jun 26, 2025
b6e664c
chore: add OWNERS files with reviewers and labels (#450)
thesuperzapper Jun 26, 2025
eae9e23
fix(ws): backend dockerfile (#386)
liavweiss Jun 26, 2025
a4cd1c2
feat(ws): add fallback mechanism to broken images (#448)
caponetto Jun 27, 2025
6ba18c0
feat: refactor Form View to Edit only (#451)
thaorell Jun 27, 2025
23fed9c
feat(ws): Make Create Workspace Kind button visible (#466)
thaorell Jul 2, 2025
c6e81c2
fix(ws): Improve Workspace Creation Wizard Step Descriptions (#452)
jenny-s51 Jul 4, 2025
d680ea0
feat: workspace kind Edit Pod Configs (#425)
thaorell Jul 4, 2025
db3e000
fix: removed blank space on left of dropdown options (#329)
dominikkawka Jul 4, 2025
60d6de0
feat(ws): backend api to create wsk with YAML (#434)
asaadbalum Jul 6, 2025
08c206d
feat(ws): prepare frontend for validation errors during WorkspaceKind…
caponetto Jul 7, 2025
989fe53
chore(ws): added cspell to enforce spelling check (#469)
paulovmr Jul 7, 2025
da615f5
chore(ws): added prettier to test and test:fix scripts (#470)
paulovmr Jul 7, 2025
9607fab
fix(ws): Updates to Table Columns, Expandable Rows, and Theming (#432)
jenny-s51 Jul 7, 2025
3fed049
chore(ws): upgrade deprecated rimraf transitive dependency (#474)
paulovmr Jul 9, 2025
3feccf7
fix(ws): Improve workspace form drawer details and wizard flow (#467)
jenny-s51 Jul 9, 2025
5e9c88f
chore(ws): Add support for PF utility classes (#476)
jenny-s51 Jul 10, 2025
0e90e5d
feat(ws): Add advanced pod configurations in Workspace Edit (#468)
thaorell Jul 10, 2025
3e7f44a
feat(ws): Refactor restYAML to restFILE (#478)
thaorell Jul 17, 2025
13a66ae
fix(ws): normalize text case for workspace count buttons, update colu…
jenny-s51 Jul 17, 2025
de0e5c4
feat(ws): Make Workspace Kind drawer resizable and add table view to …
thaorell Jul 18, 2025
3218768
fix(ws): Implement dual scrolling for workspace kind wizard (#484)
jenny-s51 Jul 21, 2025
296f63f
chore(ws): enforce component specific imports (#475)
paulovmr Jul 21, 2025
b21cf69
chore(ws): Upgrade vulnerable packages (#495)
paulovmr Jul 23, 2025
b18812a
fix(ws): Apply sentence case to text elements across UI (#497)
jenny-s51 Jul 24, 2025
4b88c15
improve: UX Enhancements in workspace summary (#473)
dominikkawka Jul 24, 2025
f1f1c83
feat(ws): add ws counts to backend wsk model (#368)
roee1313 Jul 24, 2025
dd94a8f
feat(ws): containerize frontend component (#394)
noalimoy Jul 24, 2025
f02f07c
feat(ws): add workspace pause actions backend API (#340)
andyatmiami Jul 24, 2025
bdbfe1b
fix(ws): update frontend to support latest start/stop API changes (#503)
caponetto Jul 25, 2025
7bed0be
fix(ws): Refactors toolbar and filter logic to fix "clear all filters…
jenny-s51 Jul 29, 2025
7a6bb30
feat(ws): fix workspaces table pagination (#506)
paulovmr Jul 29, 2025
77c69c5
feat(ws): use workspace counts from API response (#508)
caponetto Jul 29, 2025
039e0c9
feat(ws): add @ID swag annotation to handlers (#488)
andyatmiami Jul 31, 2025
c50bdbb
chore(ws): update swag to 1.16.6 for required fields (#489)
andyatmiami Jul 31, 2025
2d5b830
chore(ws): comment workspace details logs and pod template tabs while…
paulovmr Aug 5, 2025
dcf6b93
feat(ws): automate generation of types and HTTP client layer from Swa…
caponetto Aug 5, 2025
673989f
feat: Make Frontend Basepath Configurable via APP_PREFIX env variable…
thaorell Aug 5, 2025
053f278
feat: Conditionally render masthead on Production and Standalone mode…
thaorell Aug 5, 2025
d2b97e7
ci(ws): run client generator on frontend PR check (#519)
caponetto Aug 7, 2025
fd2dc79
feat: Refactor APP_PREFIX to const.ts (#523)
thaorell Aug 7, 2025
a932c0f
fix(ws): fixed filter by labels during workspace creation (#524)
paulovmr Aug 7, 2025
1b14efd
test: add unit tests for frontend hooks (#527)
caponetto Aug 12, 2025
1f5c6e1
chore: Upgrade PatternFly to 6.3.0 (#532)
jenny-s51 Aug 20, 2025
1950ea3
fix: fixed workspace kind summary breadcrumb navigation (#535)
paulovmr Aug 20, 2025
877e6de
feat(ws): add manifests for backend (#455)
liavweiss Aug 21, 2025
42ffd9b
feat(ws): add manifests for frontend (#487)
noalimoy Aug 21, 2025
e666e2e
feat: add environment configuration files for frontend (#536)
caponetto Aug 26, 2025
b210a56
feat: enhance husky pre-commit hook to conditionally run lint checks …
caponetto Aug 27, 2025
95431b4
feat(ws): frontend Makefile to support deploy (#534)
mkoushni Sep 4, 2025
253b25e
feat: integrate the frontend shared libraries (#552)
caponetto Sep 4, 2025
f3a4b51
feat: Add dry-run functionality for WorkspaceKind creation
Sep 14, 2025
073b16f
Merge branch 'notebooks-v2' into notebooks-v2
bhaktinarvekar Sep 14, 2025
d2926ab
Merge conflicts
Sep 14, 2025
311812b
This commit include dry-run changes in workspace kind API
Oct 4, 2025
3610ec6
repo.go changes
Oct 5, 2025
013f95f
todo added back
Oct 5, 2025
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
23 changes: 21 additions & 2 deletions workspaces/backend/api/workspacekinds_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"io"
"net/http"
"strings"

"github.com/julienschmidt/httprouter"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
Expand Down Expand Up @@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate this is the first time we are supporting a query parameter across any of our APIs - but it won't be the last! As such - I think its warranted to consider adding a helper function to workspaces/backend/api/helpers.go.
admittedly, I'm not immediately sure how to structure this for proper abstraction - presently leaning towards:

func (a *App) GetBooleanQueryParameter(w http.ResponseWriter, r *http.Request, paramName string) (bool, error) {
	value := r.URL.Query().Get(paramName)
	if value == "" {
		return false, nil // parameter not present, default to false
	}

	if value != "true" && value != "false" {
		err := fmt.Errorf("Invalid query parameter '%s' value '%s'. Must be 'true' or 'false'", paramName, value)
		a.badRequestResponse(w, r, err)
		return false, err
	}

	return value == "true", nil
}

calling code would then look like:

isDryRun, err := a.GetBooleanQueryParameter(w, r, "dry_run")
if err != nil {
    return
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Note: strconv.ParseBool(...) not being leveraged here (which I agree with) - as it will also properly parse 0, 1, etc as boolean values - which feels wrong to support in an API.


// 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"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain your rationale for making this change to NOT call ValidateContentType ? I'm assuming its a desire to outright fail in the presence of additionally parameters declared in Content-Type header ?

i.e. application/yaml; charset=utf-8

  • passes in ValidateContentType
  • fails in your custom implementation

If that is the primary motivation - I would opt to not deviate from the common helper function we have created and used consistently throughout the codebase. So, I would recommend undoing this change and leaving this particular piece of logic as-is.

If I am misunderstanding the motivation behind the change - or you feel its critical to implement something like this - lets talk about it - but ideally with the goal of supporting this in the common helper function and not fragmenting the codebase w.r.t. content type validation.

return
}

Expand Down Expand Up @@ -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)
Expand All @@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The a.dataResponse and a.createdResponse helper functions already handle setting this Content-Type header..

Was there something you observed that made it necessary to explicitly set this here? I would imagine we should remove this logic as its redundant.. but please correct me if I am wrong!


// 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)

Expand Down
200 changes: 200 additions & 0 deletions workspaces/backend/api/workspacekinds_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -507,4 +508,203 @@ metadata:
))
})
})


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())
})
})
})
17 changes: 10 additions & 7 deletions workspaces/backend/internal/repositories/workspacekinds/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines 76 to 78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As implemented - this is NOT achieving the desired outcome as written in the issue... primarly:

a dry_run is performed at the kubernetes layer (see: CreateOptions , UpdateOptions )

This code simply "echoes back" what was provided on the request - which won't inform the user if there were issues in the provided payload.

Even when dryRun is true - we STILL want to call r.client.Create - but we want to invoke it in such a way that the controller-runtime will evaluate it with dry_run semantics..

Something like the following is what I would expect to see in the implementation

    var createOptions []client.CreateOption
    
    if dryRun {
        // Add dry-run option to the Kubernetes client call
        createOptions = append(createOptions, client.DryRunAll)
    }
    
    // Always call the Kubernetes client, with or without dry-run options
    if err := r.client.Create(ctx, workspaceKind, createOptions...); err != nil {

and then in testing this against a live cluster.. you should be able to see errors returned from k8s itself in the event you provided an invalid YAML

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Please note this will then significantly impact the tests provided in this PR to support the desired behavior.


// 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
//
Comment on lines 88 to 90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should preserve this TODO comment as it will still hold true even with the merge of this PR

i.e. once available - we would want a WorkspaceKindUpdate being returned from this function

// Convert the created workspace to a WorkspaceKindUpdate model
createdWorkspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind)

return &createdWorkspaceKindModel, nil
Expand Down
2 changes: 1 addition & 1 deletion workspaces/frontend/package-lock.json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not be included in this PR - as none of the actual changes implemented affect the workspaces/frontend directory.

I am guessing this is a side effect of the extreme amount of commits that are erroneously getting included in the PR. Feel free to reach out to me in Slack if you need help getting your source branch cleaned up!

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading