Skip to content

Commit 175e5a5

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 9815278 commit 175e5a5

File tree

7 files changed

+449
-2
lines changed

7 files changed

+449
-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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
9494
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
9595
return path
9696
}
97+
98+
// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource.
99+
func (a *App) LocationGetWorkspaceKind(name string) string {
100+
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1)
101+
return path
102+
}

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ package api
1818

1919
import (
2020
"errors"
21+
"fmt"
22+
"io"
2123
"net/http"
2224

2325
"github.com/julienschmidt/httprouter"
2426
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
27+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2528
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/runtime"
30+
"k8s.io/apimachinery/pkg/runtime/schema"
31+
"k8s.io/apimachinery/pkg/runtime/serializer"
2632
"k8s.io/apimachinery/pkg/util/validation/field"
2733

2834
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
@@ -35,6 +41,74 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
3541

3642
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
3743

44+
// cachedRestrictedScheme holds the pre-built runtime scheme that only knows about WorkspaceKind.
45+
var cachedRestrictedScheme *runtime.Scheme
46+
47+
// cachedUniversalDeserializer holds the pre-built universal deserializer for the restricted scheme.
48+
var cachedUniversalDeserializer runtime.Decoder
49+
50+
// cachedExpectedGVK holds the expected GVK for WorkspaceKind, derived programmatically.
51+
var cachedExpectedGVK schema.GroupVersionKind
52+
53+
// init builds the restricted scheme and deserializer once at package initialization time.
54+
func init() {
55+
restrictedScheme := runtime.NewScheme()
56+
if err := kubefloworgv1beta1.AddToScheme(restrictedScheme); err != nil {
57+
panic(fmt.Sprintf("failed to add WorkspaceKind types to restricted scheme: %v", err))
58+
}
59+
cachedRestrictedScheme = restrictedScheme
60+
61+
codecs := serializer.NewCodecFactory(cachedRestrictedScheme)
62+
cachedUniversalDeserializer = codecs.UniversalDeserializer()
63+
64+
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
65+
gvks, _, err := cachedRestrictedScheme.ObjectKinds(workspaceKind)
66+
if err != nil || len(gvks) == 0 {
67+
panic(fmt.Sprintf("failed to derive GVK from WorkspaceKind type: %v", err))
68+
}
69+
cachedExpectedGVK = gvks[0]
70+
}
71+
72+
// ParseWorkspaceKindManifestBody reads and decodes a YAML request body into a WorkspaceKind object
73+
// using Kubernetes runtime validation to ensure maximum security.
74+
func (a *App) ParseWorkspaceKindManifestBody(w http.ResponseWriter, r *http.Request) (*kubefloworgv1beta1.WorkspaceKind, bool) {
75+
// NOTE: A server-level middleware should enforce a max body size.
76+
body, err := io.ReadAll(r.Body)
77+
if err != nil {
78+
a.badRequestResponse(w, r, fmt.Errorf("failed to read request body: %w", err))
79+
return nil, false
80+
}
81+
defer func() {
82+
if err := r.Body.Close(); err != nil {
83+
a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", err))
84+
}
85+
}()
86+
87+
if len(body) == 0 {
88+
a.badRequestResponse(w, r, errors.New("request body is empty"))
89+
return nil, false
90+
}
91+
92+
obj, gvk, err := cachedUniversalDeserializer.Decode(body, nil, nil)
93+
if err != nil {
94+
a.badRequestResponse(w, r, fmt.Errorf("failed to decode YAML manifest: %w", err))
95+
return nil, false
96+
}
97+
98+
if gvk.Kind != cachedExpectedGVK.Kind || gvk.Version != cachedExpectedGVK.Version {
99+
a.badRequestResponse(w, r, fmt.Errorf("invalid GVK: expected %s, got %s", cachedExpectedGVK.Kind, gvk.Kind))
100+
return nil, false
101+
}
102+
103+
workspaceKind, ok := obj.(*kubefloworgv1beta1.WorkspaceKind)
104+
if !ok {
105+
a.badRequestResponse(w, r, fmt.Errorf("unexpected type: got %T, want *WorkspaceKind", obj))
106+
return nil, false
107+
}
108+
109+
return workspaceKind, true
110+
}
111+
38112
// GetWorkspaceKindHandler retrieves a specific workspace kind by name.
39113
//
40114
// @Summary Get workspace kind
@@ -123,3 +197,73 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
123197
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
124198
a.dataResponse(w, r, responseEnvelope)
125199
}
200+
201+
// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest.
202+
203+
// @Summary Create workspace kind
204+
// @Description Creates a new workspace kind from a raw YAML manifest.
205+
// @Tags workspacekinds
206+
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
207+
// @Produce json
208+
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
209+
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
210+
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
211+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
212+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
213+
// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
214+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
215+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
216+
// @Router /workspacekinds [post]
217+
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
218+
// === Content-Type check ===
219+
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
220+
return
221+
}
222+
223+
// === Read body and parse with secure parser ===
224+
newWsk, ok := a.ParseWorkspaceKindManifestBody(w, r)
225+
if !ok {
226+
return
227+
}
228+
229+
// === Validate name exists in YAML ===
230+
if newWsk.Name == "" {
231+
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
232+
return
233+
}
234+
235+
// === AUTH ===
236+
authPolicies := []*auth.ResourcePolicy{
237+
auth.NewResourcePolicy(
238+
auth.ResourceVerbCreate,
239+
&kubefloworgv1beta1.WorkspaceKind{
240+
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
241+
},
242+
),
243+
}
244+
if success := a.requireAuth(w, r, authPolicies); !success {
245+
return
246+
}
247+
248+
// === Create ===
249+
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), newWsk)
250+
if err != nil {
251+
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
252+
a.conflictResponse(w, r, err)
253+
return
254+
}
255+
// This handles validation errors from the K8s API Server (webhook)
256+
if apierrors.IsInvalid(err) {
257+
causes := helper.StatusCausesFromAPIStatus(err)
258+
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
259+
return
260+
}
261+
a.serverErrorResponse(w, r, err)
262+
return
263+
}
264+
265+
// === Return created object in envelope ===
266+
location := a.LocationGetWorkspaceKind(createdModel.Name)
267+
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
268+
a.createdResponse(w, r, responseEnvelope, location)
269+
}

workspaces/backend/api/workspacekinds_handler_test.go

Lines changed: 140 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,143 @@ 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 runs before each "It" block, ensuring the validYAML is always available.
265+
BeforeEach(func() {
266+
validYAML = []byte(fmt.Sprintf(`
267+
apiVersion: kubeflow.org/v1beta1
268+
kind: WorkspaceKind
269+
metadata:
270+
name: %s
271+
spec:
272+
spawner:
273+
displayName: "Test Jupyter Environment"
274+
description: "A valid description for testing."
275+
icon:
276+
url: "https://example.com/icon.png"
277+
logo:
278+
url: "https://example.com/logo.svg"
279+
podTemplate:
280+
options:
281+
imageConfig:
282+
spawner:
283+
default: "default-image"
284+
values:
285+
- id: "default-image"
286+
name: "Jupyter Scipy"
287+
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
288+
spawner:
289+
displayName: "Jupyter with SciPy v1.9.0"
290+
spec:
291+
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
292+
ports:
293+
- id: "notebook-port"
294+
displayName: "Notebook Port"
295+
port: 8888
296+
protocol: "HTTP"
297+
podConfig:
298+
spawner:
299+
default: "default-pod-config"
300+
values:
301+
- id: "default-pod-config"
302+
name: "Default Resources"
303+
spawner:
304+
displayName: "Small CPU/RAM"
305+
resources:
306+
requests:
307+
cpu: "500m"
308+
memory: "1Gi"
309+
limits:
310+
cpu: "1"
311+
memory: "2Gi"
312+
volumeMounts:
313+
home: "/home/jovyan"
314+
`, newWorkspaceKindName))
315+
})
316+
317+
AfterEach(func() {
318+
By("cleaning up the created WorkspaceKind")
319+
wsk := &kubefloworgv1beta1.WorkspaceKind{
320+
ObjectMeta: metav1.ObjectMeta{
321+
Name: newWorkspaceKindName,
322+
},
323+
}
324+
_ = k8sClient.Delete(ctx, wsk)
325+
})
326+
327+
It("should succeed when creating a new WorkspaceKind with valid YAML", func() {
328+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
329+
Expect(err).NotTo(HaveOccurred())
330+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
331+
req.Header.Set(userIdHeader, adminUser)
332+
333+
rr := httptest.NewRecorder()
334+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
335+
rs := rr.Result()
336+
defer rs.Body.Close()
337+
338+
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
339+
})
340+
341+
It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() {
342+
By("creating the resource once successfully")
343+
req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
344+
req1.Header.Set("Content-Type", ContentTypeYAMLManifest)
345+
req1.Header.Set(userIdHeader, adminUser)
346+
rr1 := httptest.NewRecorder()
347+
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
348+
Expect(rr1.Code).To(Equal(http.StatusCreated))
349+
350+
By("attempting to create the exact same resource a second time")
351+
req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
352+
req2.Header.Set("Content-Type", ContentTypeYAMLManifest)
353+
req2.Header.Set(userIdHeader, adminUser)
354+
rr2 := httptest.NewRecorder()
355+
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
356+
357+
Expect(rr2.Code).To(Equal(http.StatusConflict))
358+
})
359+
360+
It("should fail with 400 Bad Request when the YAML has the wrong kind", func() {
361+
wrongKindYAML := []byte(`apiVersion: v1
362+
kind: Pod
363+
metadata:
364+
name: i-am-the-wrong-kind`)
365+
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
366+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
367+
req.Header.Set(userIdHeader, adminUser)
368+
369+
rr := httptest.NewRecorder()
370+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
371+
372+
Expect(rr.Code).To(Equal(http.StatusBadRequest))
373+
// UPDATED: Check for the new, more specific error message
374+
Expect(rr.Body.String()).To(ContainSubstring("no kind \\\"Pod\\\" is registered"))
375+
})
376+
377+
It("should fail with 400 Bad Request for an empty YAML object", func() {
378+
invalidYAML := []byte("{}")
379+
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
380+
Expect(err).NotTo(HaveOccurred())
381+
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
382+
req.Header.Set(userIdHeader, adminUser)
383+
384+
rr := httptest.NewRecorder()
385+
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
386+
rs := rr.Result()
387+
defer rs.Body.Close()
388+
389+
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
390+
body, _ := io.ReadAll(rs.Body)
391+
// UPDATED: Check for the new, more specific error message from the secure parser
392+
Expect(string(body)).To(ContainSubstring("failed to decode YAML manifest"))
393+
})
394+
})
257395
})

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)