diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index b109e1d3a..e328d5c8b 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -56,6 +56,7 @@ const ( // workspacekinds AllWorkspaceKindsPath = PathPrefix + "/workspacekinds" WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + ResourceNamePathParam + ListValuesPath = WorkspaceKindsByNamePath + "/podtemplate/options/listvalues" // namespaces AllNamespacesPath = PathPrefix + "/namespaces" @@ -136,6 +137,7 @@ func (a *App) Routes() http.Handler { router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler) + router.POST(ListValuesPath, a.ListValuesHandler) // swagger router.GET(SwaggerPath, a.GetSwaggerHandler) diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go index 08829d8fe..62bba9a41 100644 --- a/workspaces/backend/api/workspacekinds_handler.go +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -42,6 +42,8 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind] type WorkspaceKindEnvelope Envelope[models.WorkspaceKind] +type ListValuesEnvelope Envelope[models.ListValuesResponse] + // GetWorkspaceKindHandler retrieves a specific workspace kind by name. // // @Summary Get workspace kind @@ -237,3 +239,70 @@ func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, responseEnvelope := &WorkspaceKindCreateEnvelope{Data: createdWorkspaceKind} a.createdResponse(w, r, responseEnvelope, location) } + +// ListValuesHandler returns filtered imageConfig and podConfig options for a WorkspaceKind. +// +// @Summary List values for workspace kind options +// @Description Returns filtered imageConfig and podConfig options based on the provided context. This endpoint is used by the workspace creation wizard to show compatible options. +// @Tags workspacekinds +// @ID listValues +// @Accept json +// @Produce json +// @Param name path string true "Name of the workspace kind" extensions(x-example=jupyterlab) +// @Param body body models.ListValuesRequest true "Request body with optional context filters" +// @Success 200 {object} ListValuesEnvelope "Successful operation. Returns filtered options with rule_effects." +// @Failure 400 {object} ErrorEnvelope "Bad Request. Invalid workspace kind name or request body." +// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required." +// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to access the workspace kind." +// @Failure 404 {object} ErrorEnvelope "Not Found. Workspace kind does not exist." +// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server." +// @Router /workspacekinds/{name}/podtemplate/options/listvalues [post] +func (a *App) ListValuesHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + name := ps.ByName(ResourceNamePathParam) + + // validate path parameters + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateWorkspaceKindName(field.NewPath(ResourceNamePathParam), name)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + return + } + + // parse request body + var requestBody models.ListValuesRequest + if err := a.DecodeJSON(r, &requestBody); err != nil { + a.badRequestResponse(w, r, err) + return + } + + // =========================== AUTH =========================== + authPolicies := []*auth.ResourcePolicy{ + auth.NewResourcePolicy( + auth.ResourceVerbGet, + &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + }, + ), + } + if success := a.requireAuth(w, r, authPolicies); !success { + return + } + // ============================================================ + + // get the workspace kind + workspaceKind, err := a.repositories.WorkspaceKind.GetWorkspaceKind(r.Context(), name) + if err != nil { + if errors.Is(err, repository.ErrWorkspaceKindNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + // build the response with rule_effects and context filtering + response := models.BuildListValuesResponse(workspaceKind, requestBody.Data.Context) + + responseEnvelope := &ListValuesEnvelope{Data: response} + a.dataResponse(w, r, responseEnvelope) +} diff --git a/workspaces/backend/internal/models/workspacekinds/funcs.go b/workspaces/backend/internal/models/workspacekinds/funcs.go index 268b458ed..9a3719e39 100644 --- a/workspaces/backend/internal/models/workspacekinds/funcs.go +++ b/workspaces/backend/internal/models/workspacekinds/funcs.go @@ -175,3 +175,75 @@ func buildOptionRedirect(redirect *kubefloworgv1beta1.OptionRedirect) *OptionRed Message: message, } } + +// BuildListValuesResponse transforms a WorkspaceKind into a ListValuesResponse +// by adding rule_effects to each option value and applying context filters +func BuildListValuesResponse(wsk WorkspaceKind, context *ListValuesContext) ListValuesResponse { + imageValues := buildImageConfigValuesWithRules(wsk.PodTemplate.Options.ImageConfig, context) + podValues := buildPodConfigValuesWithRules(wsk.PodTemplate.Options.PodConfig, context) + + return ListValuesResponse{ + ImageConfig: ImageConfigWithRules{ + Default: wsk.PodTemplate.Options.ImageConfig.Default, + Values: imageValues, + }, + PodConfig: PodConfigWithRules{ + Default: wsk.PodTemplate.Options.PodConfig.Default, + Values: podValues, + }, + } +} + +func buildImageConfigValuesWithRules(imageConfig ImageConfig, context *ListValuesContext) []ImageConfigValueWithRules { + values := []ImageConfigValueWithRules{} + + for _, v := range imageConfig.Values { + // Filter by context if imageConfig.id is specified + if context != nil && context.ImageConfig != nil { + if v.Id != context.ImageConfig.Id { + continue // skip this value + } + } + + // Transform and add rule_effects + values = append(values, ImageConfigValueWithRules{ + Id: v.Id, + DisplayName: v.DisplayName, + Description: v.Description, + Labels: v.Labels, + Hidden: v.Hidden, + Redirect: v.Redirect, + ClusterMetrics: v.ClusterMetrics, + RuleEffects: RuleEffects{UiHide: false}, // Always false for stub + }) + } + + return values +} + +func buildPodConfigValuesWithRules(podConfig PodConfig, context *ListValuesContext) []PodConfigValueWithRules { + values := []PodConfigValueWithRules{} + + for _, v := range podConfig.Values { + // Filter by context if podConfig.id is specified + if context != nil && context.PodConfig != nil { + if v.Id != context.PodConfig.Id { + continue // skip this value + } + } + + // Transform and add rule_effects + values = append(values, PodConfigValueWithRules{ + Id: v.Id, + DisplayName: v.DisplayName, + Description: v.Description, + Labels: v.Labels, + Hidden: v.Hidden, + Redirect: v.Redirect, + ClusterMetrics: v.ClusterMetrics, + RuleEffects: RuleEffects{UiHide: false}, // Always false for stub + }) + } + + return values +} diff --git a/workspaces/backend/internal/models/workspacekinds/types.go b/workspaces/backend/internal/models/workspacekinds/types.go index 2996f4666..68473ee55 100644 --- a/workspaces/backend/internal/models/workspacekinds/types.go +++ b/workspaces/backend/internal/models/workspacekinds/types.go @@ -109,3 +109,70 @@ const ( RedirectMessageLevelWarning RedirectMessageLevel = "Warning" RedirectMessageLevelDanger RedirectMessageLevel = "Danger" ) + +type ListValuesRequest struct { + Data ListValuesRequestData `json:"data"` +} + +type ListValuesRequestData struct { + Context *ListValuesContext `json:"context,omitempty"` +} + +type ListValuesContext struct { + Namespace *ContextNamespace `json:"namespace,omitempty"` + PodConfig *ContextPodConfig `json:"podConfig,omitempty"` + ImageConfig *ContextImageConfig `json:"imageConfig,omitempty"` +} + +type ContextNamespace struct { + Name string `json:"name"` +} + +type ContextPodConfig struct { + Id string `json:"id"` +} + +type ContextImageConfig struct { + Id string `json:"id"` +} + +type ListValuesResponse struct { + ImageConfig ImageConfigWithRules `json:"imageConfig"` + PodConfig PodConfigWithRules `json:"podConfig"` +} + +type ImageConfigWithRules struct { + Default string `json:"default"` + Values []ImageConfigValueWithRules `json:"values"` +} + +type ImageConfigValueWithRules struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Labels []OptionLabel `json:"labels"` + Hidden bool `json:"hidden"` + Redirect *OptionRedirect `json:"redirect,omitempty"` + ClusterMetrics clusterMetrics `json:"clusterMetrics,omitempty"` + RuleEffects RuleEffects `json:"rule_effects"` +} + +type PodConfigWithRules struct { + Default string `json:"default"` + Values []PodConfigValueWithRules `json:"values"` +} + +type PodConfigValueWithRules struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Labels []OptionLabel `json:"labels"` + Hidden bool `json:"hidden"` + Redirect *OptionRedirect `json:"redirect,omitempty"` + ClusterMetrics clusterMetrics `json:"clusterMetrics,omitempty"` + RuleEffects RuleEffects `json:"rule_effects"` +} + +type RuleEffects struct { + UiHide bool `json:"ui_hide"` +} diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index 86e2090a1..3fdc8d1b1 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -636,6 +636,79 @@ const docTemplate = `{ } } }, + "/workspacekinds/{name}/podtemplate/options/listvalues": { + "post": { + "description": "Returns filtered imageConfig and podConfig options based on the provided context. This endpoint is used by the workspace creation wizard to show compatible options.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "List values for workspace kind options", + "operationId": "listValues", + "parameters": [ + { + "type": "string", + "x-example": "jupyterlab", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Request body with optional context filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/workspacekinds.ListValuesRequest" + } + } + ], + "responses": { + "200": { + "description": "Successful operation. Returns filtered options with rule_effects.", + "schema": { + "$ref": "#/definitions/api.ListValuesEnvelope" + } + }, + "400": { + "description": "Bad Request. Invalid workspace kind name or request body.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to access the workspace kind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found. Workspace kind does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces": { "get": { "description": "Returns a list of all workspaces across all namespaces.", @@ -1264,6 +1337,17 @@ const docTemplate = `{ } } }, + "api.ListValuesEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/workspacekinds.ListValuesResponse" + } + } + }, "api.NamespaceListEnvelope": { "type": "object", "required": [ @@ -1629,6 +1713,39 @@ const docTemplate = `{ } } }, + "workspacekinds.ContextImageConfig": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "workspacekinds.ContextNamespace": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "workspacekinds.ContextPodConfig": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "workspacekinds.ImageConfig": { "type": "object", "required": [ @@ -1683,6 +1800,64 @@ const docTemplate = `{ } } }, + "workspacekinds.ImageConfigValueWithRules": { + "type": "object", + "required": [ + "description", + "displayName", + "hidden", + "id", + "labels", + "rule_effects" + ], + "properties": { + "clusterMetrics": { + "$ref": "#/definitions/workspacekinds.clusterMetrics" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.OptionLabel" + } + }, + "redirect": { + "$ref": "#/definitions/workspacekinds.OptionRedirect" + }, + "rule_effects": { + "$ref": "#/definitions/workspacekinds.RuleEffects" + } + } + }, + "workspacekinds.ImageConfigWithRules": { + "type": "object", + "required": [ + "default", + "values" + ], + "properties": { + "default": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.ImageConfigValueWithRules" + } + } + } + }, "workspacekinds.ImageRef": { "type": "object", "required": [ @@ -1694,6 +1869,54 @@ const docTemplate = `{ } } }, + "workspacekinds.ListValuesContext": { + "type": "object", + "properties": { + "imageConfig": { + "$ref": "#/definitions/workspacekinds.ContextImageConfig" + }, + "namespace": { + "$ref": "#/definitions/workspacekinds.ContextNamespace" + }, + "podConfig": { + "$ref": "#/definitions/workspacekinds.ContextPodConfig" + } + } + }, + "workspacekinds.ListValuesRequest": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/workspacekinds.ListValuesRequestData" + } + } + }, + "workspacekinds.ListValuesRequestData": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/workspacekinds.ListValuesContext" + } + } + }, + "workspacekinds.ListValuesResponse": { + "type": "object", + "required": [ + "imageConfig", + "podConfig" + ], + "properties": { + "imageConfig": { + "$ref": "#/definitions/workspacekinds.ImageConfigWithRules" + }, + "podConfig": { + "$ref": "#/definitions/workspacekinds.PodConfigWithRules" + } + } + }, "workspacekinds.OptionLabel": { "type": "object", "required": [ @@ -1777,6 +2000,64 @@ const docTemplate = `{ } } }, + "workspacekinds.PodConfigValueWithRules": { + "type": "object", + "required": [ + "description", + "displayName", + "hidden", + "id", + "labels", + "rule_effects" + ], + "properties": { + "clusterMetrics": { + "$ref": "#/definitions/workspacekinds.clusterMetrics" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.OptionLabel" + } + }, + "redirect": { + "$ref": "#/definitions/workspacekinds.OptionRedirect" + }, + "rule_effects": { + "$ref": "#/definitions/workspacekinds.RuleEffects" + } + } + }, + "workspacekinds.PodConfigWithRules": { + "type": "object", + "required": [ + "default", + "values" + ], + "properties": { + "default": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.PodConfigValueWithRules" + } + } + } + }, "workspacekinds.PodMetadata": { "type": "object", "required": [ @@ -1871,6 +2152,17 @@ const docTemplate = `{ "RedirectMessageLevelDanger" ] }, + "workspacekinds.RuleEffects": { + "type": "object", + "required": [ + "ui_hide" + ], + "properties": { + "ui_hide": { + "type": "boolean" + } + } + }, "workspacekinds.WorkspaceKind": { "type": "object", "required": [ diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index df8a932db..512c1229f 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -634,6 +634,79 @@ } } }, + "/workspacekinds/{name}/podtemplate/options/listvalues": { + "post": { + "description": "Returns filtered imageConfig and podConfig options based on the provided context. This endpoint is used by the workspace creation wizard to show compatible options.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "List values for workspace kind options", + "operationId": "listValues", + "parameters": [ + { + "type": "string", + "x-example": "jupyterlab", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Request body with optional context filters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/workspacekinds.ListValuesRequest" + } + } + ], + "responses": { + "200": { + "description": "Successful operation. Returns filtered options with rule_effects.", + "schema": { + "$ref": "#/definitions/api.ListValuesEnvelope" + } + }, + "400": { + "description": "Bad Request. Invalid workspace kind name or request body.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to access the workspace kind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found. Workspace kind does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces": { "get": { "description": "Returns a list of all workspaces across all namespaces.", @@ -1262,6 +1335,17 @@ } } }, + "api.ListValuesEnvelope": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/workspacekinds.ListValuesResponse" + } + } + }, "api.NamespaceListEnvelope": { "type": "object", "required": [ @@ -1627,6 +1711,39 @@ } } }, + "workspacekinds.ContextImageConfig": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "workspacekinds.ContextNamespace": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "workspacekinds.ContextPodConfig": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "workspacekinds.ImageConfig": { "type": "object", "required": [ @@ -1681,6 +1798,64 @@ } } }, + "workspacekinds.ImageConfigValueWithRules": { + "type": "object", + "required": [ + "description", + "displayName", + "hidden", + "id", + "labels", + "rule_effects" + ], + "properties": { + "clusterMetrics": { + "$ref": "#/definitions/workspacekinds.clusterMetrics" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.OptionLabel" + } + }, + "redirect": { + "$ref": "#/definitions/workspacekinds.OptionRedirect" + }, + "rule_effects": { + "$ref": "#/definitions/workspacekinds.RuleEffects" + } + } + }, + "workspacekinds.ImageConfigWithRules": { + "type": "object", + "required": [ + "default", + "values" + ], + "properties": { + "default": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.ImageConfigValueWithRules" + } + } + } + }, "workspacekinds.ImageRef": { "type": "object", "required": [ @@ -1692,6 +1867,54 @@ } } }, + "workspacekinds.ListValuesContext": { + "type": "object", + "properties": { + "imageConfig": { + "$ref": "#/definitions/workspacekinds.ContextImageConfig" + }, + "namespace": { + "$ref": "#/definitions/workspacekinds.ContextNamespace" + }, + "podConfig": { + "$ref": "#/definitions/workspacekinds.ContextPodConfig" + } + } + }, + "workspacekinds.ListValuesRequest": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/workspacekinds.ListValuesRequestData" + } + } + }, + "workspacekinds.ListValuesRequestData": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/workspacekinds.ListValuesContext" + } + } + }, + "workspacekinds.ListValuesResponse": { + "type": "object", + "required": [ + "imageConfig", + "podConfig" + ], + "properties": { + "imageConfig": { + "$ref": "#/definitions/workspacekinds.ImageConfigWithRules" + }, + "podConfig": { + "$ref": "#/definitions/workspacekinds.PodConfigWithRules" + } + } + }, "workspacekinds.OptionLabel": { "type": "object", "required": [ @@ -1775,6 +1998,64 @@ } } }, + "workspacekinds.PodConfigValueWithRules": { + "type": "object", + "required": [ + "description", + "displayName", + "hidden", + "id", + "labels", + "rule_effects" + ], + "properties": { + "clusterMetrics": { + "$ref": "#/definitions/workspacekinds.clusterMetrics" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.OptionLabel" + } + }, + "redirect": { + "$ref": "#/definitions/workspacekinds.OptionRedirect" + }, + "rule_effects": { + "$ref": "#/definitions/workspacekinds.RuleEffects" + } + } + }, + "workspacekinds.PodConfigWithRules": { + "type": "object", + "required": [ + "default", + "values" + ], + "properties": { + "default": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/workspacekinds.PodConfigValueWithRules" + } + } + } + }, "workspacekinds.PodMetadata": { "type": "object", "required": [ @@ -1869,6 +2150,17 @@ "RedirectMessageLevelDanger" ] }, + "workspacekinds.RuleEffects": { + "type": "object", + "required": [ + "ui_hide" + ], + "properties": { + "ui_hide": { + "type": "boolean" + } + } + }, "workspacekinds.WorkspaceKind": { "type": "object", "required": [