@@ -18,11 +18,17 @@ package api
1818
1919import (
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
3642type 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,74 @@ 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+ // Calculate the GET location for the new resource, as requested by the reviewer.
267+ location := a .LocationGetWorkspaceKind (createdModel .Name )
268+ responseEnvelope := & WorkspaceKindEnvelope {Data : createdModel }
269+ a .createdResponse (w , r , responseEnvelope , location )
270+ }
0 commit comments