Skip to content

Commit 14ce8d1

Browse files
committed
feat(ws): Notebooks 2.0 // Backend // API that allows frontend to upload a YAML file containing a full new WorkspaceKind definition
Signed-off-by: Asaad Balum <[email protected]>
1 parent 09f8f37 commit 14ce8d1

File tree

8 files changed

+399
-0
lines changed

8 files changed

+399
-0
lines changed

workspaces/backend/api/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ const (
5656
// swagger
5757
SwaggerPath = PathPrefix + "/swagger/*any"
5858
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
59+
60+
// YAML manifest content type
61+
ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml"
5962
)
6063

6164
type App struct {
@@ -106,6 +109,7 @@ func (a *App) Routes() http.Handler {
106109
// workspacekinds
107110
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
108111
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
112+
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
109113

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

workspaces/backend/api/helpers.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ package api
1919
import (
2020
"encoding/json"
2121
"fmt"
22+
"io"
2223
"mime"
2324
"net/http"
2425
"strings"
26+
27+
"sigs.k8s.io/yaml"
2528
)
2629

2730
// Envelope is the body of all requests and responses that contain data.
@@ -94,3 +97,25 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
9497
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
9598
return path
9699
}
100+
101+
// ParseYAMLBody decodes the YAML request body into the given destination value.
102+
// It returns false and handles writing the HTTP error response if parsing fails.
103+
func (a *App) ParseYAMLBody(w http.ResponseWriter, r *http.Request, dst interface{}) bool {
104+
body, err := io.ReadAll(r.Body)
105+
if err != nil {
106+
a.badRequestResponse(w, r, err)
107+
return false
108+
}
109+
defer func() {
110+
if err := r.Body.Close(); err != nil {
111+
fmt.Printf("warning: failed to close body: %v\n", err)
112+
}
113+
}()
114+
115+
if err := yaml.Unmarshal(body, dst); err != nil {
116+
a.badRequestResponse(w, r, fmt.Errorf("failed to parse YAML manifest: %w", err))
117+
return false
118+
}
119+
120+
return true
121+
}

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,66 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
125125
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
126126
a.dataResponse(w, r, responseEnvelope)
127127
}
128+
129+
// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest.
130+
131+
// @Summary Create workspace kind
132+
// @Description Creates a new workspace kind from a raw YAML manifest.
133+
// @Tags workspacekinds
134+
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
135+
// @Produce json
136+
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
137+
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
138+
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
139+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
140+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
141+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
142+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
143+
// @Router /workspacekinds [post]
144+
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
145+
// === Content-Type check ===
146+
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
147+
return
148+
}
149+
150+
// === Read body and parse YAML ===
151+
var newWsk kubefloworgv1beta1.WorkspaceKind
152+
if ok := a.ParseYAMLBody(w, r, &newWsk); !ok {
153+
return
154+
}
155+
156+
// === Validate name exists in YAML ===
157+
if newWsk.Name == "" {
158+
a.failedValidationResponse(w, r, "'.metadata.name' is a required field", field.ErrorList{
159+
field.Required(field.NewPath("metadata").Child("name"), "must not be empty"),
160+
}, nil)
161+
return
162+
}
163+
164+
// === AUTH ===
165+
authPolicies := []*auth.ResourcePolicy{
166+
auth.NewResourcePolicy(
167+
auth.ResourceVerbCreate,
168+
&kubefloworgv1beta1.WorkspaceKind{
169+
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
170+
},
171+
),
172+
}
173+
if success := a.requireAuth(w, r, authPolicies); !success {
174+
return
175+
}
176+
177+
// === Create ===
178+
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), &newWsk)
179+
if err != nil {
180+
a.serverErrorResponse(w, r, err)
181+
return
182+
}
183+
184+
// === Return created object in envelope ===
185+
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
186+
err = a.WriteJSON(w, http.StatusCreated, responseEnvelope, nil)
187+
if err != nil {
188+
a.logger.Error("failed to write success response", "error", err)
189+
}
190+
}

workspaces/backend/api/workspacekinds_handler_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package api
1818

1919
import (
20+
"bytes"
2021
"encoding/json"
2122
"fmt"
2223
"io"
@@ -254,4 +255,124 @@ var _ = Describe("WorkspaceKinds Handler", func() {
254255
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
255256
})
256257
})
258+
259+
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
260+
// therefore, we run them using the `Serial` Ginkgo decorator.
261+
Context("when creating a WorkspaceKind", Serial, func() {
262+
263+
var newWorkspaceKindName = "wsk-create-test"
264+
265+
AfterEach(func() {
266+
By("cleaning up the created WorkspaceKind")
267+
wsk := &kubefloworgv1beta1.WorkspaceKind{
268+
ObjectMeta: metav1.ObjectMeta{
269+
Name: newWorkspaceKindName,
270+
},
271+
}
272+
_ = k8sClient.Delete(ctx, wsk)
273+
})
274+
275+
It("should succeed when creating a new WorkspaceKind with valid YAML", func() {
276+
By("defining the final, complete, and valid YAML payload")
277+
// This YAML now includes the icon and logo fields required by the API server.
278+
validYAML := []byte(fmt.Sprintf(`
279+
apiVersion: workspaces.kubeflow.org/v1beta1
280+
kind: WorkspaceKind
281+
metadata:
282+
name: %s
283+
spec:
284+
spawner:
285+
displayName: "Test Jupyter Environment"
286+
description: "A valid description for testing."
287+
icon:
288+
url: "https://example.com/icon.png"
289+
logo:
290+
url: "https://example.com/logo.svg"
291+
podTemplate:
292+
options:
293+
imageConfig:
294+
spawner:
295+
default: "default-image"
296+
values:
297+
- id: "default-image"
298+
name: "Jupyter Scipy"
299+
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
300+
spawner:
301+
displayName: "Jupyter with SciPy v1.9.0"
302+
spec:
303+
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
304+
ports:
305+
- id: "notebook-port"
306+
displayName: "Notebook Port"
307+
port: 8888
308+
protocol: "HTTP"
309+
podConfig:
310+
spawner:
311+
default: "default-pod-config"
312+
values:
313+
- id: "default-pod-config"
314+
name: "Default Resources"
315+
spawner:
316+
displayName: "Small CPU/RAM"
317+
resources:
318+
requests:
319+
cpu: "500m"
320+
memory: "1Gi"
321+
limits:
322+
cpu: "1"
323+
memory: "2Gi"
324+
volumeMounts:
325+
home: "/home/jovyan"
326+
`, newWorkspaceKindName))
327+
328+
By("creating the HTTP request")
329+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
330+
Expect(err).NotTo(HaveOccurred())
331+
req.Header.Set("Content-Type", "application/vnd.kubeflow-notebooks.manifest+yaml")
332+
req.Header.Set(userIdHeader, adminUser)
333+
334+
By("executing the CreateWorkspaceKindHandler")
335+
rr := httptest.NewRecorder()
336+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
337+
rs := rr.Result()
338+
defer rs.Body.Close()
339+
340+
By("verifying the HTTP response status code is 201 Created")
341+
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
342+
343+
By("verifying the resource was created in the cluster")
344+
createdWsk := &kubefloworgv1beta1.WorkspaceKind{}
345+
err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWsk)
346+
Expect(err).NotTo(HaveOccurred())
347+
})
348+
It("should fail if the YAML is syntactically invalid", func() {
349+
By("defining a string that is not valid YAML")
350+
// This text has incorrect indentation and structure.
351+
invalidYAML := []byte(`
352+
apiVersion: v1
353+
kind: WorkspaceKind
354+
metadata
355+
name: broken-yaml
356+
`)
357+
358+
By("creating the HTTP request")
359+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
360+
Expect(err).NotTo(HaveOccurred())
361+
req.Header.Set("Content-Type", "application/vnd.kubeflow-notebooks.manifest+yaml")
362+
req.Header.Set(userIdHeader, adminUser)
363+
364+
By("executing the CreateWorkspaceKindHandler")
365+
rr := httptest.NewRecorder()
366+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
367+
rs := rr.Result()
368+
defer rs.Body.Close()
369+
370+
By("verifying the handler returns a 400 Bad Request with a valid error envelope")
371+
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
372+
var response ErrorEnvelope
373+
err = json.Unmarshal(rr.Body.Bytes(), &response)
374+
Expect(err).NotTo(HaveOccurred(), "The error response should be valid JSON")
375+
Expect(response.Error.Message).To(ContainSubstring("failed to parse YAML manifest"))
376+
})
377+
})
257378
})

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
)
2929

3030
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
31+
var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists")
3132

3233
type WorkspaceKindRepository struct {
3334
client client.Client
@@ -68,3 +69,21 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
6869

6970
return workspaceKindsModels, nil
7071
}
72+
73+
func (r *WorkspaceKindRepository) Create(ctx context.Context, wk *kubefloworgv1beta1.WorkspaceKind) (models.WorkspaceKind, error) {
74+
if err := r.client.Create(ctx, wk); err != nil {
75+
if apierrors.IsAlreadyExists(err) {
76+
return models.WorkspaceKind{}, ErrWorkspaceKindAlreadyExists
77+
}
78+
if apierrors.IsInvalid(err) {
79+
// NOTE: we don't wrap this error so we can unpack it in the caller
80+
return models.WorkspaceKind{}, err
81+
}
82+
return models.WorkspaceKind{}, err
83+
}
84+
85+
// Convert the created k8s object to our backend model before returning
86+
createdModel := models.NewWorkspaceKindModelFromWorkspaceKind(wk)
87+
88+
return createdModel, nil
89+
}

workspaces/backend/openapi/docs.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,68 @@ const docTemplate = `{
127127
}
128128
}
129129
}
130+
},
131+
"post": {
132+
"description": "Creates a new workspace kind from a raw YAML manifest.",
133+
"consumes": [
134+
"application/vnd.kubeflow-notebooks.manifest+yaml"
135+
],
136+
"produces": [
137+
"application/json"
138+
],
139+
"tags": [
140+
"workspacekinds"
141+
],
142+
"summary": "Create workspace kind",
143+
"parameters": [
144+
{
145+
"description": "Raw YAML manifest of the WorkspaceKind",
146+
"name": "body",
147+
"in": "body",
148+
"required": true,
149+
"schema": {
150+
"type": "string"
151+
}
152+
}
153+
],
154+
"responses": {
155+
"201": {
156+
"description": "Successful creation. Returns the newly created workspace kind details.",
157+
"schema": {
158+
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
159+
}
160+
},
161+
"400": {
162+
"description": "Bad Request. The YAML is invalid or a required field is missing.",
163+
"schema": {
164+
"$ref": "#/definitions/api.ErrorEnvelope"
165+
}
166+
},
167+
"401": {
168+
"description": "Unauthorized. Authentication is required.",
169+
"schema": {
170+
"$ref": "#/definitions/api.ErrorEnvelope"
171+
}
172+
},
173+
"403": {
174+
"description": "Forbidden. User does not have permission to create the workspace kind.",
175+
"schema": {
176+
"$ref": "#/definitions/api.ErrorEnvelope"
177+
}
178+
},
179+
"415": {
180+
"description": "Unsupported Media Type. Content-Type header is not correct.",
181+
"schema": {
182+
"$ref": "#/definitions/api.ErrorEnvelope"
183+
}
184+
},
185+
"500": {
186+
"description": "Internal server error.",
187+
"schema": {
188+
"$ref": "#/definitions/api.ErrorEnvelope"
189+
}
190+
}
191+
}
130192
}
131193
},
132194
"/workspacekinds/{name}": {

0 commit comments

Comments
 (0)