Skip to content

Commit 1ec33fd

Browse files
committed
feat: workspace update api stub
_WORK IN PROGRESS_ Signed-off-by: Andy Stoneberg <[email protected]>
1 parent c70aedb commit 1ec33fd

File tree

9 files changed

+512
-219
lines changed

9 files changed

+512
-219
lines changed

workspaces/backend/api/workspaces_handler.go

Lines changed: 29 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,23 @@ import (
3333
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspaces"
3434
)
3535

36-
type WorkspaceCreateEnvelope Envelope[*models.WorkspaceCreate]
37-
38-
type WorkspaceUpdateEnvelope Envelope[*models.WorkspaceUpdate]
36+
type WorkspaceEnvelope Envelope[*models.WorkspaceUpdate]
3937

40-
type WorkspaceListEnvelope Envelope[[]models.Workspace]
38+
type WorkspaceCreateEnvelope Envelope[*models.WorkspaceCreate]
4139

42-
type WorkspaceEnvelope Envelope[models.Workspace]
40+
type WorkspaceListEnvelope Envelope[[]models.WorkspaceListItem]
4341

4442
// GetWorkspaceHandler retrieves a specific workspace by namespace and name.
4543
//
4644
// @Summary Get workspace
47-
// @Description Returns details of a specific workspace identified by namespace and workspace name.
45+
// @Description Returns the current state of a specific workspace identified by namespace and workspace name, including the revision for optimistic locking. This endpoint is intended for retrieving the workspace state before updating it.
4846
// @Tags workspaces
4947
// @ID getWorkspace
5048
// @Accept json
5149
// @Produce json
5250
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=kubeflow-user-example-com)
5351
// @Param workspace_name path string true "Name of the workspace" extensions(x-example=my-workspace)
54-
// @Success 200 {object} WorkspaceEnvelope "Successful operation. Returns the requested workspace details."
52+
// @Success 200 {object} WorkspaceEnvelope "Successful operation. Returns the requested workspace details with revision."
5553
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
5654
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace."
5755
// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace does not exist."
@@ -169,7 +167,7 @@ func (a *App) getWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps ht
169167
}
170168
// ============================================================
171169

172-
var workspaces []models.Workspace
170+
var workspaces []models.WorkspaceListItem
173171
var err error
174172
if namespace == "" {
175173
workspaces, err = a.repositories.Workspace.GetAllWorkspaces(r.Context())
@@ -195,7 +193,7 @@ func (a *App) getWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps ht
195193
// @Produce json
196194
// @Param namespace path string true "Namespace for the workspace" extensions(x-example=kubeflow-user-example-com)
197195
// @Param body body WorkspaceCreateEnvelope true "Workspace creation configuration"
198-
// @Success 201 {object} WorkspaceEnvelope "Workspace created successfully"
196+
// @Success 201 {object} WorkspaceCreateEnvelope "Workspace created successfully"
199197
// @Failure 400 {object} ErrorEnvelope "Bad Request."
200198
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
201199
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
@@ -307,18 +305,18 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
307305
// @ID updateWorkspace
308306
// @Accept json
309307
// @Produce json
310-
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=kubeflow-user-example-com)
311-
// @Param name path string true "Name of the workspace" extensions(x-example=my-workspace)
312-
// @Param body body WorkspaceCreateEnvelope true "Workspace creation configuration"
313-
// @Success 200 {object} WorkspaceEnvelope "Workspace updated successfully"
314-
// @Failure 400 {object} ErrorEnvelope "Bad Request."
315-
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
316-
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
317-
// @Failure 409 {object} ErrorEnvelope "Conflict. Current workspace generation is newer than provided."
318-
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
319-
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
320-
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
321-
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
308+
// @Param namespace path string true "Namespace of the workspace" extensions(x-example=kubeflow-user-example-com)
309+
// @Param name path string true "Name of the workspace" extensions(x-example=my-workspace)
310+
// @Param body body WorkspaceEnvelope true "Workspace update configuration"
311+
// @Success 200 {object} WorkspaceEnvelope "Workspace updated successfully"
312+
// @Failure 400 {object} ErrorEnvelope "Bad Request."
313+
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
314+
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
315+
// @Failure 409 {object} ErrorEnvelope "Conflict. Current workspace generation is newer than provided."
316+
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large."
317+
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
318+
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
319+
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
322320
// @Router /workspaces/{namespace}/{name} [patch]
323321
func (a *App) UpdateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
324322
namespace := ps.ByName(NamespacePathParam)
@@ -333,25 +331,13 @@ func (a *App) UpdateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
333331
return
334332
}
335333

336-
//
337-
// TODO: require the caller to provide the generation base that they are expecting to update.
338-
// either include the generation as a field in the request body, or a header.
339-
//
340-
341-
//
342-
// TODO: update the "get" workspace (not list) to return the current generation of the workspace.
343-
// this probably means swapping the GET for Workspace with return a WorkspaceUpdate rather than a Workspace
344-
// - it also means that we will be treating GET as only used for retrieving the current state of the workspace,
345-
// before updating it, whereas LIST is used for getting the details of all workspaces (e.g. the table view in the UI).
346-
//
347-
348334
// validate the Content-Type header
349335
if success := a.ValidateContentType(w, r, "application/json"); !success {
350336
return
351337
}
352338

353339
// decode the request body
354-
bodyEnvelope := &WorkspaceUpdateEnvelope{}
340+
bodyEnvelope := &WorkspaceEnvelope{}
355341
err := a.DecodeJSON(r, bodyEnvelope)
356342
if err != nil {
357343
if a.IsMaxBytesError(err) {
@@ -405,6 +391,14 @@ func (a *App) UpdateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
405391
a.notFoundResponse(w, r)
406392
return
407393
}
394+
if errors.Is(err, repository.ErrWorkspaceInvalidRevision) {
395+
// Extract the underlying error message for better user feedback
396+
var valErrs field.ErrorList
397+
revisionPath := field.NewPath("data", "revision")
398+
valErrs = append(valErrs, field.Invalid(revisionPath, workspaceUpdate.Revision, err.Error()))
399+
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
400+
return
401+
}
408402
//
409403
// TODO: there is still a race condition once we actually try and patch/update the workspace,
410404
// unless we use optimistic locking with the generation field
@@ -424,7 +418,7 @@ func (a *App) UpdateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
424418
return
425419
}
426420

427-
responseEnvelope := &WorkspaceUpdateEnvelope{Data: updatedWorkspace}
421+
responseEnvelope := &WorkspaceEnvelope{Data: updatedWorkspace}
428422
a.dataResponse(w, r, responseEnvelope)
429423
}
430424

workspaces/backend/api/workspaces_handler_test.go

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,17 @@ var _ = Describe("Workspaces Handler", func() {
197197

198198
By("ensuring the response contains the expected Workspaces")
199199
Expect(response.Data).To(ConsistOf(
200-
models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
201-
models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
202-
models.NewWorkspaceModelFromWorkspace(workspace3, workspaceKind),
200+
models.NewWorkspaceListItemFromWorkspace(workspace1, workspaceKind),
201+
models.NewWorkspaceListItemFromWorkspace(workspace2, workspaceKind),
202+
models.NewWorkspaceListItemFromWorkspace(workspace3, workspaceKind),
203203
))
204204

205-
By("ensuring the response can be marshaled to JSON and back to []Workspace")
205+
By("ensuring the response can be marshaled to JSON and back to []WorkspaceListItem")
206206
dataJSON, err := json.Marshal(response.Data)
207207
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
208-
var dataObject []models.Workspace
208+
var dataObject []models.WorkspaceListItem
209209
err = json.Unmarshal(dataJSON, &dataObject)
210-
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace")
210+
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []WorkspaceListItem")
211211
})
212212

213213
It("should retrieve Workspaces from Namespace 1 successfully", func() {
@@ -252,16 +252,16 @@ var _ = Describe("Workspaces Handler", func() {
252252

253253
By("ensuring the response contains the expected Workspaces")
254254
Expect(response.Data).To(ConsistOf(
255-
models.NewWorkspaceModelFromWorkspace(workspace1, workspaceKind),
256-
models.NewWorkspaceModelFromWorkspace(workspace2, workspaceKind),
255+
models.NewWorkspaceListItemFromWorkspace(workspace1, workspaceKind),
256+
models.NewWorkspaceListItemFromWorkspace(workspace2, workspaceKind),
257257
))
258258

259-
By("ensuring the response can be marshaled to JSON and back to []Workspace")
259+
By("ensuring the response can be marshaled to JSON and back to []WorkspaceListItem")
260260
dataJSON, err := json.Marshal(response.Data)
261261
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
262-
var dataObject []models.Workspace
262+
var dataObject []models.WorkspaceListItem
263263
err = json.Unmarshal(dataJSON, &dataObject)
264-
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []Workspace")
264+
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to []WorkspaceListItem")
265265
})
266266

267267
It("should retrieve a single Workspace successfully", func() {
@@ -296,23 +296,24 @@ var _ = Describe("Workspaces Handler", func() {
296296
err = json.Unmarshal(body, &response)
297297
Expect(err).NotTo(HaveOccurred())
298298

299-
By("getting the WorkspaceKind from the Kubernetes API")
300-
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
301-
Expect(k8sClient.Get(ctx, workspaceKindKey, workspaceKind)).To(Succeed())
302-
303299
By("getting the Workspace from the Kubernetes API")
304300
workspace := &kubefloworgv1beta1.Workspace{}
305301
Expect(k8sClient.Get(ctx, workspaceKey1, workspace)).To(Succeed())
306302

307-
By("ensuring the response matches the expected Workspace")
308-
Expect(response.Data).To(BeComparableTo(models.NewWorkspaceModelFromWorkspace(workspace, workspaceKind)))
303+
By("ensuring the response matches the expected WorkspaceUpdate")
304+
expectedWorkspaceUpdate := models.NewWorkspaceUpdateModelFromWorkspace(workspace)
305+
// Normalize Secrets to nil if empty to match JSON unmarshaling behavior (omitempty causes empty slices to become nil)
306+
if len(expectedWorkspaceUpdate.PodTemplate.Volumes.Secrets) == 0 {
307+
expectedWorkspaceUpdate.PodTemplate.Volumes.Secrets = nil
308+
}
309+
Expect(response.Data).To(BeComparableTo(expectedWorkspaceUpdate))
309310

310-
By("ensuring the response can be marshaled to JSON and back to Workspace")
311+
By("ensuring the response can be marshaled to JSON and back to WorkspaceUpdate")
311312
dataJSON, err := json.Marshal(response.Data)
312313
Expect(err).NotTo(HaveOccurred(), "failed to marshal data to JSON")
313-
var dataObject models.Workspace
314+
var dataObject models.WorkspaceUpdate
314315
err = json.Unmarshal(dataJSON, &dataObject)
315-
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to Workspace")
316+
Expect(err).NotTo(HaveOccurred(), "failed to unmarshal JSON to WorkspaceUpdate")
316317
})
317318
})
318319

@@ -463,7 +464,7 @@ var _ = Describe("Workspaces Handler", func() {
463464
Expect(k8sClient.Get(ctx, workspaceInvalidImageConfigKey, workspaceInvalidImageConfig)).To(Succeed())
464465

465466
By("ensuring the model for Workspace with missing WorkspaceKind is as expected")
466-
workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil)
467+
workspaceMissingWskModel := models.NewWorkspaceListItemFromWorkspace(workspaceMissingWsk, nil)
467468
Expect(workspaceMissingWskModel.WorkspaceKind.Missing).To(BeTrue())
468469
Expect(workspaceMissingWskModel.PodTemplate.Volumes.Home.MountPath).To(Equal(models.UnknownHomeMountPath))
469470
Expect(workspaceMissingWskModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig))
@@ -472,12 +473,12 @@ var _ = Describe("Workspaces Handler", func() {
472473
Expect(workspaceMissingWskModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig))
473474

474475
By("ensuring the model for Workspace with invalid PodConfig is as expected")
475-
workspaceInvalidPodConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidPodConfig, workspaceKind)
476+
workspaceInvalidPodConfigModel := models.NewWorkspaceListItemFromWorkspace(workspaceInvalidPodConfig, workspaceKind)
476477
Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.DisplayName).To(Equal(models.UnknownPodConfig))
477478
Expect(workspaceInvalidPodConfigModel.PodTemplate.Options.PodConfig.Current.Description).To(Equal(models.UnknownPodConfig))
478479

479480
By("ensuring the model for Workspace with invalid ImageConfig is as expected")
480-
workspaceInvalidImageConfigModel := models.NewWorkspaceModelFromWorkspace(workspaceInvalidImageConfig, workspaceKind)
481+
workspaceInvalidImageConfigModel := models.NewWorkspaceListItemFromWorkspace(workspaceInvalidImageConfig, workspaceKind)
481482
Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.DisplayName).To(Equal(models.UnknownImageConfig))
482483
Expect(workspaceInvalidImageConfigModel.PodTemplate.Options.ImageConfig.Current.Description).To(Equal(models.UnknownImageConfig))
483484

@@ -489,7 +490,7 @@ var _ = Describe("Workspaces Handler", func() {
489490
))
490491
})
491492

492-
It("should retrieve a single invalid Workspace successfully", func() {
493+
It("should retrieve a single Workspace successfully", func() {
493494
By("creating the HTTP request")
494495
path := strings.Replace(WorkspacesByNamePath, ":"+NamespacePathParam, namespaceName1, 1)
495496
path = strings.Replace(path, ":"+ResourceNamePathParam, workspaceMissingWskName, 1)
@@ -525,8 +526,12 @@ var _ = Describe("Workspaces Handler", func() {
525526
workspaceMissingWsk := &kubefloworgv1beta1.Workspace{}
526527
Expect(k8sClient.Get(ctx, workspaceMissingWskKey, workspaceMissingWsk)).To(Succeed())
527528

528-
By("ensuring the response matches the expected Workspace")
529-
workspaceMissingWskModel := models.NewWorkspaceModelFromWorkspace(workspaceMissingWsk, nil)
529+
By("ensuring the response matches the expected WorkspaceUpdate")
530+
workspaceMissingWskModel := models.NewWorkspaceUpdateModelFromWorkspace(workspaceMissingWsk)
531+
// Normalize Secrets to nil if empty to match JSON unmarshaling behavior (omitempty causes empty slices to become nil)
532+
if len(workspaceMissingWskModel.PodTemplate.Volumes.Secrets) == 0 {
533+
workspaceMissingWskModel.PodTemplate.Volumes.Secrets = nil
534+
}
530535
Expect(response.Data).To(BeComparableTo(workspaceMissingWskModel))
531536
})
532537
})

workspaces/backend/internal/models/workspaces/funcs.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ const (
2929
UnknownPodConfig = "__UNKNOWN_POD_CONFIG__"
3030
)
3131

32-
// NewWorkspaceModelFromWorkspace creates a Workspace model from a Workspace and WorkspaceKind object.
32+
// NewWorkspaceListItemFromWorkspace creates a WorkspaceListItem model from a Workspace and WorkspaceKind object.
3333
// NOTE: the WorkspaceKind might not exist, so we handle the case where it is nil or has no UID.
34-
func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) Workspace {
34+
func NewWorkspaceListItemFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) WorkspaceListItem {
3535
// ensure the provided WorkspaceKind matches the Workspace
3636
if wskExists(wsk) && ws.Spec.Kind != wsk.Name {
3737
panic("provided WorkspaceKind does not match the Workspace")
@@ -98,7 +98,7 @@ func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubef
9898
}
9999
}
100100

101-
workspaceModel := Workspace{
101+
workspaceModel := WorkspaceListItem{
102102
Name: ws.Name,
103103
Namespace: ws.Namespace,
104104
WorkspaceKind: WorkspaceKindInfo{

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ limitations under the License.
1717
package workspaces
1818

1919
import (
20+
"strconv"
21+
2022
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
2123
"k8s.io/utils/ptr"
2224
)
@@ -44,6 +46,15 @@ func NewWorkspaceCreateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *Wor
4446
}
4547
}
4648

49+
secretMounts := make([]PodSecretMount, len(ws.Spec.PodTemplate.Volumes.Secrets))
50+
for i, s := range ws.Spec.PodTemplate.Volumes.Secrets {
51+
secretMounts[i] = PodSecretMount{
52+
SecretName: s.SecretName,
53+
MountPath: s.MountPath,
54+
DefaultMode: s.DefaultMode,
55+
}
56+
}
57+
4758
workspaceCreateModel := &WorkspaceCreate{
4859
Name: ws.Name,
4960
Kind: ws.Spec.Kind,
@@ -55,8 +66,9 @@ func NewWorkspaceCreateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *Wor
5566
Annotations: podAnnotations,
5667
},
5768
Volumes: PodVolumesMutate{
58-
Home: ws.Spec.PodTemplate.Volumes.Home,
59-
Data: dataVolumes,
69+
Home: ws.Spec.PodTemplate.Volumes.Home,
70+
Data: dataVolumes,
71+
Secrets: secretMounts,
6072
},
6173
Options: PodTemplateOptionsMutate{
6274
ImageConfig: ws.Spec.PodTemplate.Options.ImageConfig,
@@ -91,7 +103,17 @@ func NewWorkspaceUpdateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *Wor
91103
}
92104
}
93105

106+
secretMounts := make([]PodSecretMount, len(ws.Spec.PodTemplate.Volumes.Secrets))
107+
for i, s := range ws.Spec.PodTemplate.Volumes.Secrets {
108+
secretMounts[i] = PodSecretMount{
109+
SecretName: s.SecretName,
110+
MountPath: s.MountPath,
111+
DefaultMode: s.DefaultMode,
112+
}
113+
}
114+
94115
workspaceUpdateModel := &WorkspaceUpdate{
116+
Revision: strconv.FormatInt(ws.Generation, 10),
95117
Paused: ptr.Deref(ws.Spec.Paused, false),
96118
DeferUpdates: ptr.Deref(ws.Spec.DeferUpdates, false),
97119
PodTemplate: PodTemplateMutate{
@@ -100,8 +122,9 @@ func NewWorkspaceUpdateModelFromWorkspace(ws *kubefloworgv1beta1.Workspace) *Wor
100122
Annotations: podAnnotations,
101123
},
102124
Volumes: PodVolumesMutate{
103-
Home: ws.Spec.PodTemplate.Volumes.Home,
104-
Data: dataVolumes,
125+
Home: ws.Spec.PodTemplate.Volumes.Home,
126+
Data: dataVolumes,
127+
Secrets: secretMounts,
105128
},
106129
Options: PodTemplateOptionsMutate{
107130
ImageConfig: ws.Spec.PodTemplate.Options.ImageConfig,

workspaces/backend/internal/models/workspaces/types.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ limitations under the License.
1616

1717
package workspaces
1818

19-
// Workspace represents a workspace in the system, and is returned by GET and LIST operations.
20-
// NOTE: this type is not used for CREATE or UPDATE operations, see WorkspaceCreate
21-
type Workspace struct {
19+
// WorkspaceListItem represents a workspace in the system, and is returned by LIST operations.
20+
// NOTE: this type is not used for GET, CREATE or UPDATE operations, see WorkspaceUpdate and WorkspaceCreate
21+
// TODO: we need to validate which fields should actually be returned in the response
22+
// - should only be returning fields relevant to the list view in the UI
23+
type WorkspaceListItem struct {
2224
Name string `json:"name"`
2325
Namespace string `json:"namespace"`
2426
WorkspaceKind WorkspaceKindInfo `json:"workspaceKind"`
@@ -66,6 +68,7 @@ type PodMetadata struct {
6668
Annotations map[string]string `json:"annotations"`
6769
}
6870

71+
// TODO: determine proper use of omitempty for PodVolumes
6972
type PodVolumes struct {
7073
Home *PodVolumeInfo `json:"home,omitempty"`
7174
Data []PodVolumeInfo `json:"data"`

0 commit comments

Comments
 (0)