Skip to content

Commit 7e5c649

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 7e5c649

File tree

8 files changed

+506
-2
lines changed

8 files changed

+506
-2
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ package api
1818

1919
import (
2020
"encoding/json"
21+
"errors"
2122
"fmt"
23+
"io"
2224
"mime"
2325
"net/http"
2426
"strings"
27+
28+
"sigs.k8s.io/yaml"
2529
)
2630

2731
// Envelope is the body of all requests and responses that contain data.
@@ -94,3 +98,44 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
9498
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
9599
return path
96100
}
101+
102+
// ParseYAMLBody reads and decodes a YAML request body into a given destination value,
103+
// after first verifying the 'kind' field in the YAML matches the expectedKind.
104+
// It returns false and handles writing the HTTP error response on any failure.
105+
func (a *App) ParseYAMLBody(w http.ResponseWriter, r *http.Request, dst interface{}, expectedKind string) bool {
106+
body, err := io.ReadAll(r.Body)
107+
if err != nil {
108+
a.badRequestResponse(w, r, errors.New("failed to read request body"))
109+
return false
110+
}
111+
defer func() {
112+
if err := r.Body.Close(); err != nil {
113+
fmt.Printf("warning: failed to close body: %v\n", err)
114+
}
115+
}()
116+
117+
// First, check the 'kind' field without parsing the whole object.
118+
type kindCheck struct {
119+
Kind string `yaml:"kind"`
120+
}
121+
var kc kindCheck
122+
if err := yaml.Unmarshal(body, &kc); err != nil {
123+
// This handles files that are not valid YAML at all.
124+
a.badRequestResponse(w, r, errors.New("request body is not a valid YAML manifest"))
125+
return false
126+
}
127+
128+
// Now, validate the 'kind'.
129+
if kc.Kind != expectedKind {
130+
a.badRequestResponse(w, r, fmt.Errorf("invalid kind in YAML: expected '%s', got '%s'", expectedKind, kc.Kind))
131+
return false
132+
}
133+
134+
// If kind is correct, parse the full object into the destination struct.
135+
if err := yaml.Unmarshal(body, dst); err != nil {
136+
a.badRequestResponse(w, r, fmt.Errorf("request body is not a valid YAML manifest for a %s", expectedKind))
137+
return false
138+
}
139+
140+
return true
141+
}

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,69 @@ 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 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
142+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
143+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
144+
// @Router /workspacekinds [post]
145+
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
146+
// === Content-Type check ===
147+
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
148+
return
149+
}
150+
151+
// === Read body, check kind, and parse YAML all in one step ===
152+
var newWsk kubefloworgv1beta1.WorkspaceKind
153+
if ok := a.ParseYAMLBody(w, r, &newWsk, "WorkspaceKind"); !ok {
154+
return
155+
}
156+
157+
// === Validate name exists in YAML ===
158+
if newWsk.Name == "" {
159+
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
160+
return
161+
}
162+
163+
// === AUTH ===
164+
authPolicies := []*auth.ResourcePolicy{
165+
auth.NewResourcePolicy(
166+
auth.ResourceVerbCreate,
167+
&kubefloworgv1beta1.WorkspaceKind{
168+
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
169+
},
170+
),
171+
}
172+
if success := a.requireAuth(w, r, authPolicies); !success {
173+
return
174+
}
175+
176+
// === Create ===
177+
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), &newWsk)
178+
if err != nil {
179+
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
180+
a.conflictResponse(w, r, err)
181+
return
182+
}
183+
a.serverErrorResponse(w, r, err)
184+
return
185+
}
186+
187+
// === Return created object in envelope ===
188+
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
189+
err = a.WriteJSON(w, http.StatusCreated, responseEnvelope, nil)
190+
if err != nil {
191+
a.logger.Error("failed to write success response", "error", err)
192+
}
193+
}

workspaces/backend/api/workspacekinds_handler_test.go

Lines changed: 189 additions & 2 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"
@@ -195,8 +196,6 @@ var _ = Describe("WorkspaceKinds Handler", func() {
195196
})
196197
})
197198

198-
// NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests.
199-
// therefore, we run them using the `Serial` Ginkgo decorators.
200199
Context("with no existing WorkspaceKinds", Serial, func() {
201200

202201
It("should return an empty list of WorkspaceKinds", func() {
@@ -254,4 +253,192 @@ var _ = Describe("WorkspaceKinds Handler", func() {
254253
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
255254
})
256255
})
256+
257+
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
258+
// therefore, we run them using the `Serial` Ginkgo decorator.
259+
Context("when creating a WorkspaceKind", Serial, func() {
260+
261+
var newWorkspaceKindName = "wsk-create-test"
262+
var validYAML []byte
263+
264+
BeforeEach(func() {
265+
validYAML = []byte(fmt.Sprintf(`
266+
apiVersion: workspaces.kubeflow.org/v1beta1
267+
kind: WorkspaceKind
268+
metadata:
269+
name: %s
270+
spec:
271+
spawner:
272+
displayName: "Test Jupyter Environment"
273+
description: "A valid description for testing."
274+
icon:
275+
url: "https://example.com/icon.png"
276+
logo:
277+
url: "https://example.com/logo.svg"
278+
podTemplate:
279+
options:
280+
imageConfig:
281+
spawner:
282+
default: "default-image"
283+
values:
284+
- id: "default-image"
285+
name: "Jupyter Scipy"
286+
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
287+
spawner:
288+
displayName: "Jupyter with SciPy v1.9.0"
289+
spec:
290+
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
291+
ports:
292+
- id: "notebook-port"
293+
displayName: "Notebook Port"
294+
port: 8888
295+
protocol: "HTTP"
296+
podConfig:
297+
spawner:
298+
default: "default-pod-config"
299+
values:
300+
- id: "default-pod-config"
301+
name: "Default Resources"
302+
spawner:
303+
displayName: "Small CPU/RAM"
304+
resources:
305+
requests:
306+
cpu: "500m"
307+
memory: "1Gi"
308+
limits:
309+
cpu: "1"
310+
memory: "2Gi"
311+
volumeMounts:
312+
home: "/home/jovyan"
313+
`, newWorkspaceKindName))
314+
})
315+
316+
AfterEach(func() {
317+
By("cleaning up the created WorkspaceKind")
318+
wsk := &kubefloworgv1beta1.WorkspaceKind{
319+
ObjectMeta: metav1.ObjectMeta{
320+
Name: newWorkspaceKindName,
321+
},
322+
}
323+
_ = k8sClient.Delete(ctx, wsk)
324+
})
325+
326+
It("should succeed when creating a new WorkspaceKind with valid YAML", func() {
327+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
328+
Expect(err).NotTo(HaveOccurred())
329+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
330+
req.Header.Set(userIdHeader, adminUser)
331+
332+
rr := httptest.NewRecorder()
333+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
334+
rs := rr.Result()
335+
defer rs.Body.Close()
336+
337+
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
338+
339+
By("verifying the resource was created in the cluster")
340+
createdWsk := &kubefloworgv1beta1.WorkspaceKind{}
341+
err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWsk)
342+
Expect(err).NotTo(HaveOccurred())
343+
})
344+
345+
It("should fail with 400 Bad Request if the YAML is missing a required name", func() {
346+
missingNameYAML := []byte(`
347+
apiVersion: workspaces.kubeflow.org/v1beta1
348+
kind: WorkspaceKind
349+
metadata: {}
350+
spec:
351+
spawner:
352+
displayName: "This will fail"`)
353+
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(missingNameYAML))
354+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
355+
req.Header.Set(userIdHeader, adminUser)
356+
357+
rr := httptest.NewRecorder()
358+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
359+
360+
Expect(rr.Code).To(Equal(http.StatusBadRequest))
361+
Expect(rr.Body.String()).To(ContainSubstring("'.metadata.name' is a required field"))
362+
})
363+
364+
It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() {
365+
By("creating the resource once successfully")
366+
req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
367+
req1.Header.Set("Content-Type", ContentTypeYAMLManifest)
368+
req1.Header.Set(userIdHeader, adminUser)
369+
rr1 := httptest.NewRecorder()
370+
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
371+
Expect(rr1.Code).To(Equal(http.StatusCreated))
372+
373+
By("attempting to create the exact same resource a second time")
374+
req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
375+
req2.Header.Set("Content-Type", ContentTypeYAMLManifest)
376+
req2.Header.Set(userIdHeader, adminUser)
377+
rr2 := httptest.NewRecorder()
378+
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
379+
380+
Expect(rr2.Code).To(Equal(http.StatusConflict))
381+
})
382+
383+
It("should fail with 400 Bad Request when the YAML has the wrong kind", func() {
384+
wrongKindYAML := []byte(`apiVersion: v1
385+
kind: Pod
386+
metadata:
387+
name: i-am-the-wrong-kind`)
388+
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
389+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
390+
req.Header.Set(userIdHeader, adminUser)
391+
392+
rr := httptest.NewRecorder()
393+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
394+
395+
Expect(rr.Code).To(Equal(http.StatusBadRequest))
396+
Expect(rr.Body.String()).To(ContainSubstring("invalid kind in YAML: expected 'WorkspaceKind', got 'Pod'"))
397+
})
398+
399+
It("should fail when the body is not valid YAML", func() {
400+
notYAML := []byte(`this is not yaml {`)
401+
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(notYAML))
402+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
403+
req.Header.Set(userIdHeader, adminUser)
404+
405+
rr := httptest.NewRecorder()
406+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
407+
408+
By("verifying the handler returns a 400 Bad Request with a valid error envelope")
409+
Expect(rr.Code).To(Equal(http.StatusBadRequest))
410+
var response ErrorEnvelope
411+
err := json.Unmarshal(rr.Body.Bytes(), &response)
412+
Expect(err).NotTo(HaveOccurred(), "The error response should be valid JSON")
413+
Expect(response.Error.Message).To(Equal("request body is not a valid YAML manifest"))
414+
})
415+
It("should fail with 400 Bad Request for an empty YAML object", func() {
416+
By("defining an empty YAML object as the payload")
417+
invalidYAML := []byte("{}")
418+
419+
By("creating the HTTP request")
420+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
421+
Expect(err).NotTo(HaveOccurred())
422+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
423+
req.Header.Set(userIdHeader, adminUser)
424+
425+
By("executing the CreateWorkspaceKindHandler")
426+
rr := httptest.NewRecorder()
427+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
428+
rs := rr.Result()
429+
defer rs.Body.Close()
430+
431+
By("verifying the handler returns a 400 Bad Request")
432+
// First, check the status code
433+
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
434+
435+
By("verifying the error message in the response body")
436+
// Second, read the body from the response stream
437+
body, err := io.ReadAll(rs.Body)
438+
Expect(err).NotTo(HaveOccurred()) // Ensure reading the body didn't cause an error
439+
440+
// Finally, assert on the content of the body
441+
Expect(string(body)).To(ContainSubstring("invalid kind in YAML: expected 'WorkspaceKind', got ''"))
442+
})
443+
})
257444
})

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+
}

0 commit comments

Comments
 (0)