@@ -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,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+ }
0 commit comments