Skip to content

Commit 50d6f9b

Browse files
mathew: 1
Signed-off-by: Mathew Wicks <[email protected]>
1 parent 175e5a5 commit 50d6f9b

File tree

11 files changed

+367
-203
lines changed

11 files changed

+367
-203
lines changed

workspaces/backend/api/app.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ limitations under the License.
1717
package api
1818

1919
import (
20+
"fmt"
2021
"log/slog"
2122
"net/http"
2223

2324
"github.com/julienschmidt/httprouter"
2425
"k8s.io/apimachinery/pkg/runtime"
26+
"k8s.io/apimachinery/pkg/runtime/serializer"
2527
"k8s.io/apiserver/pkg/authentication/authenticator"
2628
"k8s.io/apiserver/pkg/authorization/authorizer"
2729
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -35,6 +37,9 @@ const (
3537
Version = "1.0.0"
3638
PathPrefix = "/api/v1"
3739

40+
MediaTypeJson = "application/json"
41+
MediaTypeYaml = "application/yaml"
42+
3843
NamespacePathParam = "namespace"
3944
ResourceNamePathParam = "name"
4045

@@ -56,32 +61,38 @@ const (
5661
// swagger
5762
SwaggerPath = PathPrefix + "/swagger/*any"
5863
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
59-
60-
// YAML manifest content type
61-
ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml"
6264
)
6365

6466
type App struct {
65-
Config *config.EnvConfig
66-
logger *slog.Logger
67-
repositories *repositories.Repositories
68-
Scheme *runtime.Scheme
69-
RequestAuthN authenticator.Request
70-
RequestAuthZ authorizer.Authorizer
67+
Config *config.EnvConfig
68+
logger *slog.Logger
69+
repositories *repositories.Repositories
70+
Scheme *runtime.Scheme
71+
StrictYamlSerializer runtime.Serializer
72+
RequestAuthN authenticator.Request
73+
RequestAuthZ authorizer.Authorizer
7174
}
7275

7376
// NewApp creates a new instance of the app
7477
func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) {
7578

7679
// TODO: log the configuration on startup
7780

81+
// get a serializer for Kubernetes YAML
82+
codecFactory := serializer.NewCodecFactory(scheme)
83+
yamlSerializerInfo, found := runtime.SerializerInfoForMediaType(codecFactory.SupportedMediaTypes(), runtime.ContentTypeYAML)
84+
if !found {
85+
return nil, fmt.Errorf("unable to find Kubernetes serializer for media type: %s", runtime.ContentTypeYAML)
86+
}
87+
7888
app := &App{
79-
Config: cfg,
80-
logger: logger,
81-
repositories: repositories.NewRepositories(cl),
82-
Scheme: scheme,
83-
RequestAuthN: reqAuthN,
84-
RequestAuthZ: reqAuthZ,
89+
Config: cfg,
90+
logger: logger,
91+
repositories: repositories.NewRepositories(cl),
92+
Scheme: scheme,
93+
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
94+
RequestAuthN: reqAuthN,
95+
RequestAuthZ: reqAuthZ,
8596
}
8697
return app, nil
8798
}

workspaces/backend/api/helpers.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package api
1818

1919
import (
2020
"encoding/json"
21+
"errors"
2122
"fmt"
2223
"mime"
2324
"net/http"
@@ -46,7 +47,7 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
4647
w.Header()[key] = value
4748
}
4849

49-
w.Header().Set("Content-Type", "application/json")
50+
w.Header().Set("Content-Type", MediaTypeJson)
5051
w.WriteHeader(status)
5152
_, err = w.Write(js)
5253
if err != nil {
@@ -61,11 +62,21 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
6162
decoder := json.NewDecoder(r.Body)
6263
decoder.DisallowUnknownFields()
6364
if err := decoder.Decode(v); err != nil {
65+
// NOTE: we don't wrap this error so we can unpack it in the caller
66+
if a.IsMaxBytesError(err) {
67+
return err
68+
}
6469
return fmt.Errorf("error decoding JSON: %w", err)
6570
}
6671
return nil
6772
}
6873

74+
// IsMaxBytesError checks if the error is an instance of http.MaxBytesError.
75+
func (a *App) IsMaxBytesError(err error) bool {
76+
var maxBytesError *http.MaxBytesError
77+
return errors.As(err, &maxBytesError)
78+
}
79+
6980
// ValidateContentType validates the Content-Type header of the request.
7081
// If this method returns false, the request has been handled and the caller should return immediately.
7182
// If this method returns true, the request has the correct Content-Type.

workspaces/backend/api/response_errors.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error
156156
a.errorResponse(w, r, httpError)
157157
}
158158

159+
// HTTP:413
160+
func (a *App) requestEntityTooLargeResponse(w http.ResponseWriter, r *http.Request, err error) {
161+
httpError := &HTTPError{
162+
StatusCode: http.StatusRequestEntityTooLarge,
163+
ErrorResponse: ErrorResponse{
164+
Code: strconv.Itoa(http.StatusRequestEntityTooLarge),
165+
Message: err.Error(),
166+
},
167+
}
168+
a.errorResponse(w, r, httpError)
169+
}
170+
159171
// HTTP:415
160172
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
161173
httpError := &HTTPError{

workspaces/backend/api/suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ var _ = BeforeSuite(func() {
150150
By("creating the application")
151151
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
152152
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
153+
Expect(err).NotTo(HaveOccurred())
153154

154155
go func() {
155156
defer GinkgoRecover()

workspaces/backend/api/workspacekinds_handler.go

Lines changed: 58 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4041
type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
4142

4243
type 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]
217152
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 {
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

Comments
 (0)