@@ -27,8 +27,6 @@ import (
2727 apierrors "k8s.io/apimachinery/pkg/api/errors"
2828 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2929 "k8s.io/apimachinery/pkg/runtime"
30- "k8s.io/apimachinery/pkg/runtime/schema"
31- "k8s.io/apimachinery/pkg/runtime/serializer"
3230 "k8s.io/apimachinery/pkg/util/validation/field"
3331
3432 "github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
@@ -37,78 +35,13 @@ import (
3735 repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
3836)
3937
38+ // TODO: this should wrap the models.WorkspaceKindUpdate once we implement the update handler
39+ type WorkspaceKindCreateEnvelope Envelope [* models.WorkspaceKind ]
40+
4041type WorkspaceKindListEnvelope Envelope [[]models.WorkspaceKind ]
4142
4243type WorkspaceKindEnvelope Envelope [models.WorkspaceKind ]
4344
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-
11245// GetWorkspaceKindHandler retrieves a specific workspace kind by name.
11346//
11447// @Summary Get workspace kind
@@ -198,72 +131,94 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
198131 a .dataResponse (w , r , responseEnvelope )
199132}
200133
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]
134+ // CreateWorkspaceKindHandler creates a new workspace kind.
135+ //
136+ // @Summary Create workspace kind
137+ // @Description Creates a new workspace kind.
138+ // @Tags workspacekinds
139+ // @Accept application/yaml
140+ // @Produce json
141+ // @Param body body string true "Kubernetes YAML manifest of a WorkspaceKind"
142+ // @Success 201 {object} WorkspaceKindEnvelope "WorkspaceKind created successfully"
143+ // @Failure 400 {object} ErrorEnvelope "Bad Request."
144+ // @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
145+ // @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create WorkspaceKind."
146+ // @Failure 409 {object} ErrorEnvelope "Conflict. WorkspaceKind with the same name already exists."
147+ // @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large.""
148+ // @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
149+ // @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
150+ // @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
151+ // @Router /workspacekinds [post]
217152func (a * App ) CreateWorkspaceKindHandler (w http.ResponseWriter , r * http.Request , _ httprouter.Params ) {
218- // === Content-Type check ===
219- if ok := a .ValidateContentType (w , r , ContentTypeYAMLManifest ); ! ok {
153+
154+ // validate the Content-Type header
155+ if success := a .ValidateContentType (w , r , MediaTypeYaml ); ! success {
220156 return
221157 }
222158
223- // === Read body and parse with secure parser ===
224- newWsk , ok := a .ParseWorkspaceKindManifestBody (w , r )
225- if ! ok {
159+ // decode the request body
160+ bodyBytes , err := io .ReadAll (r .Body )
161+ if err != nil {
162+ if a .IsMaxBytesError (err ) {
163+ a .requestEntityTooLargeResponse (w , r , err )
164+ return
165+ }
166+ a .badRequestResponse (w , r , err )
167+ return
168+ }
169+ workspaceKind := & kubefloworgv1beta1.WorkspaceKind {}
170+ err = runtime .DecodeInto (a .StrictYamlSerializer , bodyBytes , workspaceKind )
171+ if err != nil {
172+ a .badRequestResponse (w , r , fmt .Errorf ("error decoding request body: %w" , err ))
226173 return
227174 }
228175
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" ))
176+ // validate the workspace kind
177+ // NOTE: we only do basic validation so we know it's safe to send to the Kubernetes API server
178+ // comprehensive validation will be done by Kubernetes
179+ // NOTE: checking the name field is non-empty also verifies that the workspace kind is not nil/empty
180+ var valErrs field.ErrorList
181+ wskNamePath := field .NewPath ("metadata" , "name" )
182+ valErrs = append (valErrs , helper .ValidateFieldIsDNS1123Subdomain (wskNamePath , workspaceKind .Name )... )
183+ if len (valErrs ) > 0 {
184+ a .failedValidationResponse (w , r , errMsgRequestBodyInvalid , valErrs , nil )
232185 return
233186 }
234187
235- // === AUTH ===
188+ // =========================== AUTH ======================== ===
236189 authPolicies := []* auth.ResourcePolicy {
237190 auth .NewResourcePolicy (
238191 auth .ResourceVerbCreate ,
239192 & kubefloworgv1beta1.WorkspaceKind {
240- ObjectMeta : metav1.ObjectMeta {Name : newWsk .Name },
193+ ObjectMeta : metav1.ObjectMeta {
194+ Name : workspaceKind .Name ,
195+ },
241196 },
242197 ),
243198 }
244199 if success := a .requireAuth (w , r , authPolicies ); ! success {
245200 return
246201 }
202+ // ============================================================
247203
248- // === Create ===
249- createdModel , err := a .repositories .WorkspaceKind .Create (r .Context (), newWsk )
204+ createdWorkspaceKind , err := a .repositories .WorkspaceKind .Create (r .Context (), workspaceKind )
250205 if err != nil {
251206 if errors .Is (err , repository .ErrWorkspaceKindAlreadyExists ) {
252207 a .conflictResponse (w , r , err )
253208 return
254209 }
255- // This handles validation errors from the K8s API Server (webhook)
256210 if apierrors .IsInvalid (err ) {
257211 causes := helper .StatusCausesFromAPIStatus (err )
258212 a .failedValidationResponse (w , r , errMsgKubernetesValidation , nil , causes )
259213 return
260214 }
261- a .serverErrorResponse (w , r , err )
215+ a .serverErrorResponse (w , r , fmt . Errorf ( "error creating workspace kind: %w" , err ) )
262216 return
263217 }
264218
265- // === Return created object in envelope ===
266- location := a .LocationGetWorkspaceKind (createdModel .Name )
267- responseEnvelope := & WorkspaceKindEnvelope {Data : createdModel }
219+ // calculate the GET location for the created workspace kind (for the Location header)
220+ location := a .LocationGetWorkspaceKind (createdWorkspaceKind .Name )
221+
222+ responseEnvelope := & WorkspaceKindCreateEnvelope {Data : createdWorkspaceKind }
268223 a .createdResponse (w , r , responseEnvelope , location )
269224}
0 commit comments