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 <asaad.balum@gmail.com>
This commit is contained in:
Asaad Balum 2025-06-19 15:30:26 +03:00
parent 981527855a
commit 175e5a5841
7 changed files with 449 additions and 2 deletions

View File

@ -56,6 +56,9 @@ const (
// swagger // swagger
SwaggerPath = PathPrefix + "/swagger/*any" SwaggerPath = PathPrefix + "/swagger/*any"
SwaggerDocPath = PathPrefix + "/swagger/doc.json" SwaggerDocPath = PathPrefix + "/swagger/doc.json"
// YAML manifest content type
ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml"
) )
type App struct { type App struct {
@ -106,6 +109,7 @@ func (a *App) Routes() http.Handler {
// workspacekinds // workspacekinds
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
// swagger // swagger
router.GET(SwaggerPath, a.GetSwaggerHandler) router.GET(SwaggerPath, a.GetSwaggerHandler)

View File

@ -94,3 +94,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1) path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
return path return path
} }
// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource.
func (a *App) LocationGetWorkspaceKind(name string) string {
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1)
return path
}

View File

@ -18,11 +18,17 @@ package api
import ( import (
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/validation/field"
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth" "github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
@ -35,6 +41,74 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind] type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
// cachedRestrictedScheme holds the pre-built runtime scheme that only knows about WorkspaceKind.
var cachedRestrictedScheme *runtime.Scheme
// cachedUniversalDeserializer holds the pre-built universal deserializer for the restricted scheme.
var cachedUniversalDeserializer runtime.Decoder
// cachedExpectedGVK holds the expected GVK for WorkspaceKind, derived programmatically.
var cachedExpectedGVK schema.GroupVersionKind
// init builds the restricted scheme and deserializer once at package initialization time.
func init() {
restrictedScheme := runtime.NewScheme()
if err := kubefloworgv1beta1.AddToScheme(restrictedScheme); err != nil {
panic(fmt.Sprintf("failed to add WorkspaceKind types to restricted scheme: %v", err))
}
cachedRestrictedScheme = restrictedScheme
codecs := serializer.NewCodecFactory(cachedRestrictedScheme)
cachedUniversalDeserializer = codecs.UniversalDeserializer()
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
gvks, _, err := cachedRestrictedScheme.ObjectKinds(workspaceKind)
if err != nil || len(gvks) == 0 {
panic(fmt.Sprintf("failed to derive GVK from WorkspaceKind type: %v", err))
}
cachedExpectedGVK = gvks[0]
}
// ParseWorkspaceKindManifestBody reads and decodes a YAML request body into a WorkspaceKind object
// using Kubernetes runtime validation to ensure maximum security.
func (a *App) ParseWorkspaceKindManifestBody(w http.ResponseWriter, r *http.Request) (*kubefloworgv1beta1.WorkspaceKind, bool) {
// NOTE: A server-level middleware should enforce a max body size.
body, err := io.ReadAll(r.Body)
if err != nil {
a.badRequestResponse(w, r, fmt.Errorf("failed to read request body: %w", err))
return nil, false
}
defer func() {
if err := r.Body.Close(); err != nil {
a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", err))
}
}()
if len(body) == 0 {
a.badRequestResponse(w, r, errors.New("request body is empty"))
return nil, false
}
obj, gvk, err := cachedUniversalDeserializer.Decode(body, nil, nil)
if err != nil {
a.badRequestResponse(w, r, fmt.Errorf("failed to decode YAML manifest: %w", err))
return nil, false
}
if gvk.Kind != cachedExpectedGVK.Kind || gvk.Version != cachedExpectedGVK.Version {
a.badRequestResponse(w, r, fmt.Errorf("invalid GVK: expected %s, got %s", cachedExpectedGVK.Kind, gvk.Kind))
return nil, false
}
workspaceKind, ok := obj.(*kubefloworgv1beta1.WorkspaceKind)
if !ok {
a.badRequestResponse(w, r, fmt.Errorf("unexpected type: got %T, want *WorkspaceKind", obj))
return nil, false
}
return workspaceKind, true
}
// GetWorkspaceKindHandler retrieves a specific workspace kind by name. // GetWorkspaceKindHandler retrieves a specific workspace kind by name.
// //
// @Summary Get workspace kind // @Summary Get workspace kind
@ -123,3 +197,73 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds} responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
a.dataResponse(w, r, responseEnvelope) a.dataResponse(w, r, responseEnvelope)
} }
// CreateWorkspaceKindHandler creates a new workspace kind from a YAML manifest.
// @Summary Create workspace kind
// @Description Creates a new workspace kind from a raw YAML manifest.
// @Tags workspacekinds
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
// @Produce json
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
// @Failure 500 {object} ErrorEnvelope "Internal server error."
// @Router /workspacekinds [post]
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// === Content-Type check ===
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
return
}
// === Read body and parse with secure parser ===
newWsk, ok := a.ParseWorkspaceKindManifestBody(w, r)
if !ok {
return
}
// === Validate name exists in YAML ===
if newWsk.Name == "" {
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
return
}
// === AUTH ===
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(
auth.ResourceVerbCreate,
&kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
},
),
}
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// === Create ===
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), newWsk)
if err != nil {
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
a.conflictResponse(w, r, err)
return
}
// This handles validation errors from the K8s API Server (webhook)
if apierrors.IsInvalid(err) {
causes := helper.StatusCausesFromAPIStatus(err)
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
return
}
a.serverErrorResponse(w, r, err)
return
}
// === Return created object in envelope ===
location := a.LocationGetWorkspaceKind(createdModel.Name)
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
a.createdResponse(w, r, responseEnvelope, location)
}

View File

@ -17,6 +17,7 @@ limitations under the License.
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -195,8 +196,6 @@ var _ = Describe("WorkspaceKinds Handler", func() {
}) })
}) })
// NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests.
// therefore, we run them using the `Serial` Ginkgo decorators.
Context("with no existing WorkspaceKinds", Serial, func() { Context("with no existing WorkspaceKinds", Serial, func() {
It("should return an empty list of WorkspaceKinds", func() { It("should return an empty list of WorkspaceKinds", func() {
@ -254,4 +253,143 @@ var _ = Describe("WorkspaceKinds Handler", func() {
Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String())
}) })
}) })
// NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel.
// therefore, we run them using the `Serial` Ginkgo decorator.
Context("when creating a WorkspaceKind", Serial, func() {
var newWorkspaceKindName = "wsk-create-test"
var validYAML []byte
// BeforeEach runs before each "It" block, ensuring the validYAML is always available.
BeforeEach(func() {
validYAML = []byte(fmt.Sprintf(`
apiVersion: kubeflow.org/v1beta1
kind: WorkspaceKind
metadata:
name: %s
spec:
spawner:
displayName: "Test Jupyter Environment"
description: "A valid description for testing."
icon:
url: "https://example.com/icon.png"
logo:
url: "https://example.com/logo.svg"
podTemplate:
options:
imageConfig:
spawner:
default: "default-image"
values:
- id: "default-image"
name: "Jupyter Scipy"
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
spawner:
displayName: "Jupyter with SciPy v1.9.0"
spec:
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
ports:
- id: "notebook-port"
displayName: "Notebook Port"
port: 8888
protocol: "HTTP"
podConfig:
spawner:
default: "default-pod-config"
values:
- id: "default-pod-config"
name: "Default Resources"
spawner:
displayName: "Small CPU/RAM"
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1"
memory: "2Gi"
volumeMounts:
home: "/home/jovyan"
`, newWorkspaceKindName))
})
AfterEach(func() {
By("cleaning up the created WorkspaceKind")
wsk := &kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: newWorkspaceKindName,
},
}
_ = k8sClient.Delete(ctx, wsk)
})
It("should succeed when creating a new WorkspaceKind with valid YAML", func() {
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
Expect(err).NotTo(HaveOccurred())
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
req.Header.Set(userIdHeader, adminUser)
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
rs := rr.Result()
defer rs.Body.Close()
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
})
It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() {
By("creating the resource once successfully")
req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
req1.Header.Set("Content-Type", ContentTypeYAMLManifest)
req1.Header.Set(userIdHeader, adminUser)
rr1 := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
Expect(rr1.Code).To(Equal(http.StatusCreated))
By("attempting to create the exact same resource a second time")
req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
req2.Header.Set("Content-Type", ContentTypeYAMLManifest)
req2.Header.Set(userIdHeader, adminUser)
rr2 := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
Expect(rr2.Code).To(Equal(http.StatusConflict))
})
It("should fail with 400 Bad Request when the YAML has the wrong kind", func() {
wrongKindYAML := []byte(`apiVersion: v1
kind: Pod
metadata:
name: i-am-the-wrong-kind`)
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
req.Header.Set(userIdHeader, adminUser)
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
Expect(rr.Code).To(Equal(http.StatusBadRequest))
// UPDATED: Check for the new, more specific error message
Expect(rr.Body.String()).To(ContainSubstring("no kind \\\"Pod\\\" is registered"))
})
It("should fail with 400 Bad Request for an empty YAML object", func() {
invalidYAML := []byte("{}")
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
Expect(err).NotTo(HaveOccurred())
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
req.Header.Set(userIdHeader, adminUser)
rr := httptest.NewRecorder()
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
rs := rr.Result()
defer rs.Body.Close()
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
body, _ := io.ReadAll(rs.Body)
// UPDATED: Check for the new, more specific error message from the secure parser
Expect(string(body)).To(ContainSubstring("failed to decode YAML manifest"))
})
})
}) })

View File

@ -28,6 +28,7 @@ import (
) )
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists")
type WorkspaceKindRepository struct { type WorkspaceKindRepository struct {
client client.Client client client.Client
@ -68,3 +69,21 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
return workspaceKindsModels, nil return workspaceKindsModels, nil
} }
func (r *WorkspaceKindRepository) Create(ctx context.Context, wk *kubefloworgv1beta1.WorkspaceKind) (models.WorkspaceKind, error) {
if err := r.client.Create(ctx, wk); err != nil {
if apierrors.IsAlreadyExists(err) {
return models.WorkspaceKind{}, ErrWorkspaceKindAlreadyExists
}
if apierrors.IsInvalid(err) {
// NOTE: we don't wrap this error so we can unpack it in the caller
return models.WorkspaceKind{}, err
}
return models.WorkspaceKind{}, err
}
// Convert the created k8s object to our backend model before returning
createdModel := models.NewWorkspaceKindModelFromWorkspaceKind(wk)
return createdModel, nil
}

View File

@ -122,6 +122,74 @@ const docTemplate = `{
} }
} }
} }
},
"post": {
"description": "Creates a new workspace kind from a raw YAML manifest.",
"consumes": [
"application/vnd.kubeflow-notebooks.manifest+yaml"
],
"produces": [
"application/json"
],
"tags": [
"workspacekinds"
],
"summary": "Create workspace kind",
"parameters": [
{
"description": "Raw YAML manifest of the WorkspaceKind",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Successful creation. Returns the newly created workspace kind details.",
"schema": {
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
}
},
"400": {
"description": "Bad Request. The YAML is invalid or a required field is missing.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"401": {
"description": "Unauthorized. Authentication is required.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden. User does not have permission to create the workspace kind.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"409": {
"description": "Conflict. A WorkspaceKind with the same name already exists.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"415": {
"description": "Unsupported Media Type. Content-Type header is not correct.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"500": {
"description": "Internal server error.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
}
}
} }
}, },
"/workspacekinds/{name}": { "/workspacekinds/{name}": {

View File

@ -120,6 +120,74 @@
} }
} }
} }
},
"post": {
"description": "Creates a new workspace kind from a raw YAML manifest.",
"consumes": [
"application/vnd.kubeflow-notebooks.manifest+yaml"
],
"produces": [
"application/json"
],
"tags": [
"workspacekinds"
],
"summary": "Create workspace kind",
"parameters": [
{
"description": "Raw YAML manifest of the WorkspaceKind",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Successful creation. Returns the newly created workspace kind details.",
"schema": {
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
}
},
"400": {
"description": "Bad Request. The YAML is invalid or a required field is missing.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"401": {
"description": "Unauthorized. Authentication is required.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"403": {
"description": "Forbidden. User does not have permission to create the workspace kind.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"409": {
"description": "Conflict. A WorkspaceKind with the same name already exists.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"415": {
"description": "Unsupported Media Type. Content-Type header is not correct.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
},
"500": {
"description": "Internal server error.",
"schema": {
"$ref": "#/definitions/api.ErrorEnvelope"
}
}
}
} }
}, },
"/workspacekinds/{name}": { "/workspacekinds/{name}": {