Skip to content

Commit 5d06b71

Browse files
thesuperzapperandyatmiami
authored andcommitted
pr 2: workspace update api
Signed-off-by: Mathew Wicks <[email protected]> Signed-off-by: Andy Stoneberg <[email protected]>
1 parent 054ef4e commit 5d06b71

File tree

5 files changed

+220
-3
lines changed

5 files changed

+220
-3
lines changed

workspaces/backend/api/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ func (a *App) Routes() http.Handler {
117117
router.GET(WorkspacesByNamespacePath, a.GetWorkspacesByNamespaceHandler)
118118
router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
119119
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
120+
router.PATCH(WorkspacesByNamePath, a.UpdateWorkspaceHandler)
120121
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)
121122
router.POST(PauseWorkspacePath, a.PauseActionWorkspaceHandler)
122123

workspaces/backend/api/workspaces_handler.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import (
3535

3636
type WorkspaceCreateEnvelope Envelope[*models.WorkspaceCreate]
3737

38+
type WorkspaceUpdateEnvelope Envelope[*models.WorkspaceUpdate]
39+
3840
type WorkspaceListEnvelope Envelope[[]models.Workspace]
3941

4042
type WorkspaceEnvelope Envelope[models.Workspace]
@@ -292,6 +294,135 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
292294
a.createdResponse(w, r, responseEnvelope, location)
293295
}
294296

297+
// UpdateWorkspaceHandler updates an existing workspace.
298+
//
299+
// @Summary Update workspace
300+
// @Description Updates an existing workspace
301+
// @Tags workspaces
302+
// @ID updateWorkspace
303+
// @Accept json
304+
// @Produce json
305+
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=kubeflow-user-example-com)
306+
// @Param name path string true "Name of the workspace" extensions(x-example=my-workspace)
307+
// @Param body body WorkspaceCreateEnvelope true "Workspace creation configuration"
308+
// @Success 200 {object} WorkspaceEnvelope "Workspace updated successfully"
309+
// @Failure 400 {object} ErrorEnvelope "Bad Request."
310+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
311+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
312+
// @Failure 409 {object} ErrorEnvelope "Conflict. Current workspace generation is newer than provided."
313+
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
314+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
315+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
316+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
317+
// @Router /workspaces/{namespace}/{name} [patch]
318+
func (a *App) UpdateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
319+
namespace := ps.ByName(NamespacePathParam)
320+
workspaceName := ps.ByName(ResourceNamePathParam)
321+
322+
// validate path parameters
323+
var valErrs field.ErrorList
324+
valErrs = append(valErrs, helper.ValidateKubernetesNamespaceName(field.NewPath(NamespacePathParam), namespace)...)
325+
valErrs = append(valErrs, helper.ValidateWorkspaceName(field.NewPath(ResourceNamePathParam), workspaceName)...)
326+
if len(valErrs) > 0 {
327+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
328+
return
329+
}
330+
331+
//
332+
// TODO: require the caller to provide the generation base that they are expecting to update.
333+
// either include the generation as a field in the request body, or a header.
334+
//
335+
336+
//
337+
// TODO: update the "get" workspace (not list) to return the current generation of the workspace.
338+
// this probably means swapping the GET for Workspace with return a WorkspaceUpdate rather than a Workspace
339+
// - it also means that we will be treating GET as only used for retrieving the current state of the workspace,
340+
// before updating it, whereas LIST is used for getting the details of all workspaces (e.g. the table view in the UI).
341+
//
342+
343+
// validate the Content-Type header
344+
if success := a.ValidateContentType(w, r, "application/json"); !success {
345+
return
346+
}
347+
348+
// decode the request body
349+
bodyEnvelope := &WorkspaceUpdateEnvelope{}
350+
err := a.DecodeJSON(r, bodyEnvelope)
351+
if err != nil {
352+
if a.IsMaxBytesError(err) {
353+
a.requestEntityTooLargeResponse(w, r, err)
354+
return
355+
}
356+
//
357+
// TODO: handle UnmarshalTypeError and return 422,
358+
// decode the paths which were failed to decode (included in the error)
359+
// and also do this in the other handlers which decode json
360+
//
361+
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
362+
return
363+
}
364+
365+
// validate the request body
366+
dataPath := field.NewPath("data")
367+
if bodyEnvelope.Data == nil {
368+
valErrs = field.ErrorList{field.Required(dataPath, "data is required")}
369+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
370+
return
371+
}
372+
valErrs = bodyEnvelope.Data.Validate(dataPath)
373+
if len(valErrs) > 0 {
374+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
375+
return
376+
}
377+
378+
workspaceUpdate := bodyEnvelope.Data
379+
380+
// =========================== AUTH ===========================
381+
authPolicies := []*auth.ResourcePolicy{
382+
auth.NewResourcePolicy(
383+
auth.ResourceVerbUpdate,
384+
&kubefloworgv1beta1.Workspace{
385+
ObjectMeta: metav1.ObjectMeta{
386+
Namespace: namespace,
387+
Name: workspaceName,
388+
},
389+
},
390+
),
391+
}
392+
if success := a.requireAuth(w, r, authPolicies); !success {
393+
return
394+
}
395+
// ============================================================
396+
397+
updatedWorkspace, err := a.repositories.Workspace.UpdateWorkspace(r.Context(), workspaceUpdate, namespace, workspaceName)
398+
if err != nil {
399+
if errors.Is(err, repository.ErrWorkspaceNotFound) {
400+
a.notFoundResponse(w, r)
401+
return
402+
}
403+
//
404+
// TODO: there is still a race condition once we actually try and patch/update the workspace,
405+
// unless we use optimistic locking with the generation field
406+
// (or resourceVersion, with the risk of unexpected 500 errors to the caller).
407+
//
408+
if errors.Is(err, repository.ErrWorkspaceGenerationConflict) {
409+
causes := helper.StatusCausesFromAPIStatus(err)
410+
a.conflictResponse(w, r, err, causes)
411+
return
412+
}
413+
if apierrors.IsInvalid(err) {
414+
causes := helper.StatusCausesFromAPIStatus(err)
415+
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
416+
return
417+
}
418+
a.serverErrorResponse(w, r, fmt.Errorf("error updating workspace: %w", err))
419+
return
420+
}
421+
422+
responseEnvelope := &WorkspaceUpdateEnvelope{Data: updatedWorkspace}
423+
a.dataResponse(w, r, responseEnvelope)
424+
}
425+
295426
// DeleteWorkspaceHandler deletes a specific workspace by namespace and name.
296427
//
297428
// @Summary Delete workspace

workspaces/backend/internal/models/workspaces/funcs_write.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,48 @@ func NewWorkspaceCreateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *Wor
6767

6868
return workspaceCreateModel
6969
}
70+
71+
// NewWorkspaceUpdateModelFromWorkspace creates WorkspaceUpdate model from a Workspace object.
72+
func NewWorkspaceUpdateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *WorkspaceUpdate {
73+
podLabels := make(map[string]string)
74+
podAnnotations := make(map[string]string)
75+
if ws.Spec.PodTemplate.PodMetadata != nil {
76+
// NOTE: we copy the maps to avoid creating a reference to the original maps.
77+
for k, v := range ws.Spec.PodTemplate.PodMetadata.Labels {
78+
podLabels[k] = v
79+
}
80+
for k, v := range ws.Spec.PodTemplate.PodMetadata.Annotations {
81+
podAnnotations[k] = v
82+
}
83+
}
84+
85+
dataVolumes := make([]PodVolumeMount, len(ws.Spec.PodTemplate.Volumes.Data))
86+
for i, v := range ws.Spec.PodTemplate.Volumes.Data {
87+
dataVolumes[i] = PodVolumeMount{
88+
PVCName: v.PVCName,
89+
MountPath: v.MountPath,
90+
ReadOnly: ptr.Deref(v.ReadOnly, false),
91+
}
92+
}
93+
94+
workspaceUpdateModel := &WorkspaceUpdate{
95+
Paused: ptr.Deref(ws.Spec.Paused, false),
96+
DeferUpdates: ptr.Deref(ws.Spec.DeferUpdates, false),
97+
PodTemplate: PodTemplateMutate{
98+
PodMetadata: PodMetadataMutate{
99+
Labels: podLabels,
100+
Annotations: podAnnotations,
101+
},
102+
Volumes: PodVolumesMutate{
103+
Home: ws.Spec.PodTemplate.Volumes.Home,
104+
Data: dataVolumes,
105+
},
106+
Options: PodTemplateOptionsMutate{
107+
ImageConfig: ws.Spec.PodTemplate.Options.ImageConfig,
108+
PodConfig: ws.Spec.PodTemplate.Options.PodConfig,
109+
},
110+
},
111+
}
112+
113+
return workspaceUpdateModel
114+
}

workspaces/backend/internal/models/workspaces/types_write.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ func (w *WorkspaceCreate) Validate(prefix *field.Path) []*field.Error {
5151
return errs
5252
}
5353

54+
// WorkspaceUpdate is used to update an existing workspace.
55+
// NOTE: we only do basic validation, more complex validation is done by the controller when attempting to update the workspace.
56+
type WorkspaceUpdate struct {
57+
//
58+
// TODO: it probably makes sense to return the generation here,
59+
// so both the GET and PATCH requests include it, so we can detect update conflicts.
60+
//
61+
// ...
62+
63+
Paused bool `json:"paused"` // TODO: remove `paused` once we have an "actions" api for pausing workspaces
64+
DeferUpdates bool `json:"deferUpdates"` // TODO: remove `deferUpdates` once the controller is no longer applying redirects
65+
PodTemplate PodTemplateMutate `json:"podTemplate"`
66+
}
67+
68+
// Validate validates the WorkspaceUpdate struct.
69+
func (w *WorkspaceUpdate) Validate(prefix *field.Path) []*field.Error {
70+
var errs []*field.Error
71+
72+
// validate pod template
73+
podTemplatePath := prefix.Child("podTemplate")
74+
errs = append(errs, w.PodTemplate.Validate(podTemplatePath)...)
75+
76+
return errs
77+
}
78+
5479
type PodTemplateMutate struct {
5580
PodMetadata PodMetadataMutate `json:"podMetadata"`
5681
Volumes PodVolumesMutate `json:"volumes"`

workspaces/backend/internal/repositories/workspaces/repo.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ import (
3333
)
3434

3535
var (
36-
ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
37-
ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists")
38-
ErrWorkspaceInvalidState = fmt.Errorf("workspace is in an invalid state for this operation")
36+
ErrWorkspaceNotFound = fmt.Errorf("workspace not found")
37+
ErrWorkspaceAlreadyExists = fmt.Errorf("workspace already exists")
38+
ErrWorkspaceInvalidState = fmt.Errorf("workspace is in an invalid state for this operation")
39+
ErrWorkspaceGenerationConflict = fmt.Errorf("current workspace generation does not match request")
3940
)
4041

4142
type WorkspaceRepository struct {
@@ -202,6 +203,20 @@ func (r *WorkspaceRepository) CreateWorkspace(ctx context.Context, workspaceCrea
202203
return createdWorkspaceModel, nil
203204
}
204205

206+
func (r *WorkspaceRepository) UpdateWorkspace(ctx context.Context, workspaceUpdate *models.WorkspaceUpdate, namespace, workspaceName string) (*models.WorkspaceUpdate, error) {
207+
//
208+
// TODO: implement update logic,
209+
//
210+
// TODO: conflict detection based on generation
211+
// make sure to raise `ErrWorkspaceGenerationConflict` which is handled by the caller
212+
// (?? does this mean we should avoid the cache when getting the current workspace)
213+
//
214+
//
215+
// TODO: ensure we raise ErrWorkspaceNotFound if the workspace does not exist (and handle by calkler)
216+
//
217+
return nil, fmt.Errorf("update workspace not implemented yet")
218+
}
219+
205220
func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, workspaceName string) error {
206221
workspace := &kubefloworgv1beta1.Workspace{
207222
ObjectMeta: metav1.ObjectMeta{

0 commit comments

Comments
 (0)