Skip to content

Commit 1d2e1a3

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 1d2e1a3

File tree

8 files changed

+461
-936
lines changed

8 files changed

+461
-936
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+
a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", 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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package api
1818

1919
import (
2020
"errors"
21+
"fmt"
2122
"net/http"
2223

2324
"github.com/julienschmidt/httprouter"
@@ -125,3 +126,70 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
125126
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
126127
a.dataResponse(w, r, responseEnvelope)
127128
}
129+
130+
// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest.
131+
132+
// @Summary Create workspace kind
133+
// @Description Creates a new workspace kind from a raw YAML manifest.
134+
// @Tags workspacekinds
135+
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
136+
// @Produce json
137+
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
138+
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
139+
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
140+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
141+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
142+
// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
143+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
144+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
145+
// @Router /workspacekinds [post]
146+
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
147+
// === Content-Type check ===
148+
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
149+
return
150+
}
151+
152+
// === Read body, check kind, and parse YAML all in one step ===
153+
var newWsk kubefloworgv1beta1.WorkspaceKind
154+
if ok := a.ParseYAMLBody(w, r, &newWsk, "WorkspaceKind"); !ok {
155+
return
156+
}
157+
158+
// === Validate name exists in YAML ===
159+
if newWsk.Name == "" {
160+
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
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+
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
181+
a.conflictResponse(w, r, err)
182+
return
183+
}
184+
a.serverErrorResponse(w, r, err)
185+
return
186+
}
187+
188+
// === Return created object in envelope ===
189+
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
190+
err = a.WriteJSON(w, http.StatusCreated, responseEnvelope, nil)
191+
if err != nil {
192+
err = fmt.Errorf("failed to write success response: %w", err)
193+
a.LogError(r, err)
194+
}
195+
}

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)