From 8e051f4987528c8d774e1d8ae793bbfe1e8f1b23 Mon Sep 17 00:00:00 2001 From: Andy Stoneberg Date: Mon, 10 Nov 2025 13:42:12 -0500 Subject: [PATCH] feat: serve workspacekind assets (logos + icons) This commit implements serving of workspacekind assets (logos and icons) from ConfigMaps through the backend API, with SHA256 hash support for cache-busting. Key changes: - Add endpoint to serve workspacekind assets from ConfigMaps at `/api/v1/workspacekinds/{name}/assets/{icon|logo}.svg` - Compute and include SHA256 hashes for ConfigMap-based assets as query parameters for cache-busting - Add ConfigMap client support to repositories for accessing asset content - partial cache for performance/efficiency that keys off label: - `notebooks.kubeflow.org/image-source: true` - Add GetConfigMapContent method to WorkspaceKindRepository for retrieving asset content from ConfigMaps - Update model functions to support SHA256 hashes in asset URLs: - NewWorkspaceKindModelFromWorkspaceKindWithAssetHashes - NewWorkspaceModelFromWorkspaceWithAssetHashes - Added workspacekind_imagesource_configmap.yaml for reference example - Refactored `backend` `mgr` setup in `main.go` to leverage `helper/k8s.go` - easier to keep logic in sync in e2e tests - particularly with new partial cache Remote URL specification of assets is still supported - but the browser queries the source URL directly for content. Signed-off-by: Andy Stoneberg --- workspaces/backend/api/app.go | 10 +- workspaces/backend/api/helpers.go | 16 ++ workspaces/backend/api/response_success.go | 9 ++ workspaces/backend/api/suite_test.go | 25 ++- .../api/workspacekind_assets_handler.go | 153 ++++++++++++++++++ .../api/workspacekinds_handler_test.go | 2 + workspaces/backend/cmd/main.go | 27 ++-- workspaces/backend/internal/helper/k8s.go | 100 ++++++++++++ .../internal/models/common/assets/funcs.go | 115 +++++++++++++ .../internal/models/common/assets/types.go | 152 +++++++++++++++++ .../models/workspacekinds/assets/funcs.go | 21 +++ .../models/workspacekinds/assets/types.go | 53 ++++++ .../internal/models/workspacekinds/funcs.go | 48 ++++-- .../internal/models/workspacekinds/types.go | 28 ++-- .../internal/models/workspaces/funcs.go | 62 +++++-- .../internal/models/workspaces/types.go | 16 +- .../internal/repositories/repositories.go | 6 +- .../repositories/workspacekinds/repo.go | 102 +++++++++++- .../internal/repositories/workspaces/repo.go | 125 ++++++++++---- workspaces/backend/openapi/docs.go | 149 ++++++++++++++--- workspaces/backend/openapi/swagger.json | 149 ++++++++++++++--- .../api/v1beta1/workspacekind_types.go | 13 +- .../api/v1beta1/zz_generated.deepcopy.go | 50 +++--- .../bases/kubeflow.org_workspacekinds.yaml | 16 ++ .../config/samples/common/kustomization.yaml | 3 +- .../workspacekind_imagesource_configmap.yaml | 21 +++ .../internal/controller/suite_test.go | 9 +- .../workspacekind_controller_test.go | 7 +- .../controller/internal/webhook/suite_test.go | 9 +- 29 files changed, 1283 insertions(+), 213 deletions(-) create mode 100644 workspaces/backend/api/workspacekind_assets_handler.go create mode 100644 workspaces/backend/internal/models/common/assets/funcs.go create mode 100644 workspaces/backend/internal/models/common/assets/types.go create mode 100644 workspaces/backend/internal/models/workspacekinds/assets/funcs.go create mode 100644 workspaces/backend/internal/models/workspacekinds/assets/types.go create mode 100644 workspaces/controller/config/samples/common/workspacekind_imagesource_configmap.yaml diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index 0500e70f0..ea6544129 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -39,6 +39,7 @@ const ( MediaTypeJson = "application/json" MediaTypeYaml = "application/yaml" + MediaTypeSVG = "image/svg+xml" NamespacePathParam = "namespace" ResourceNamePathParam = "name" @@ -56,6 +57,9 @@ const ( // workspacekinds AllWorkspaceKindsPath = PathPrefix + "/workspacekinds" WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + ResourceNamePathParam + WorkspaceKindsAssetsPath = WorkspaceKindsByNamePath + "/assets" + WorkspaceKindIconPath = WorkspaceKindsAssetsPath + "/icon.svg" + WorkspaceKindLogoPath = WorkspaceKindsAssetsPath + "/logo.svg" // namespaces AllNamespacesPath = PathPrefix + "/namespaces" @@ -76,7 +80,7 @@ type App struct { } // NewApp creates a new instance of the app -func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) { +func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, configMapClient client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) { // TODO: log the configuration on startup @@ -90,7 +94,7 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme app := &App{ Config: cfg, logger: logger, - repositories: repositories.NewRepositories(cl), + repositories: repositories.NewRepositories(cl, configMapClient), Scheme: scheme, StrictYamlSerializer: yamlSerializerInfo.StrictSerializer, RequestAuthN: reqAuthN, @@ -124,6 +128,8 @@ func (a *App) Routes() http.Handler { router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler) router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler) router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler) + router.GET(WorkspaceKindIconPath, a.GetWorkspaceKindIconHandler) + router.GET(WorkspaceKindLogoPath, a.GetWorkspaceKindLogoHandler) // swagger router.GET(SwaggerPath, a.GetSwaggerHandler) diff --git a/workspaces/backend/api/helpers.go b/workspaces/backend/api/helpers.go index 7583ea70b..1c52e2a64 100644 --- a/workspaces/backend/api/helpers.go +++ b/workspaces/backend/api/helpers.go @@ -57,6 +57,22 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt return nil } +// WriteSVG writes an SVG response with the given status code, content, and headers. +func (a *App) WriteSVG(w http.ResponseWriter, status int, content []byte, headers http.Header) error { + for key, value := range headers { + w.Header()[key] = value + } + + w.Header().Set("Content-Type", MediaTypeSVG) + w.WriteHeader(status) + _, err := w.Write(content) + if err != nil { + return err + } + + return nil +} + // DecodeJSON decodes the JSON request body into the given value. func (a *App) DecodeJSON(r *http.Request, v any) error { decoder := json.NewDecoder(r.Body) diff --git a/workspaces/backend/api/response_success.go b/workspaces/backend/api/response_success.go index 0829e6886..e4e63a0b0 100644 --- a/workspaces/backend/api/response_success.go +++ b/workspaces/backend/api/response_success.go @@ -26,6 +26,15 @@ func (a *App) dataResponse(w http.ResponseWriter, r *http.Request, body any) { } } +// HTTP: 200 +// Note: SVG images are the only type of image that is served by this API. +func (a *App) imageResponse(w http.ResponseWriter, r *http.Request, content []byte) { + err := a.WriteSVG(w, http.StatusOK, content, nil) + if err != nil { + a.serverErrorResponse(w, r, err) + } +} + // HTTP: 201 func (a *App) createdResponse(w http.ResponseWriter, r *http.Request, body any, location string) { w.Header().Set("Location", location) diff --git a/workspaces/backend/api/suite_test.go b/workspaces/backend/api/suite_test.go index 7ad4e5462..2e2aeab47 100644 --- a/workspaces/backend/api/suite_test.go +++ b/workspaces/backend/api/suite_test.go @@ -35,15 +35,14 @@ import ( "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/utils/ptr" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" "github.com/kubeflow/notebooks/workspaces/backend/internal/config" + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to @@ -128,12 +127,7 @@ var _ = BeforeSuite(func() { })).To(Succeed()) By("setting up the controller manager") - k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - Metrics: metricsserver.Options{ - BindAddress: "0", // disable metrics serving - }, - }) + k8sManager, err := helper.NewManager(cfg, scheme.Scheme) Expect(err).NotTo(HaveOccurred()) By("initializing the application logger") @@ -147,9 +141,13 @@ var _ = BeforeSuite(func() { reqAuthZ, err := auth.NewRequestAuthorizer(k8sManager.GetConfig(), k8sManager.GetHTTPClient()) Expect(err).NotTo(HaveOccurred()) + By("creating the image source ConfigMap client") + imageSourceConfigMapClient, err := helper.BuildImageSourceConfigMapClient(k8sManager) + Expect(err).NotTo(HaveOccurred()) + By("creating the application") // NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client - a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ) + a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, imageSourceConfigMapClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ) Expect(err).NotTo(HaveOccurred()) go func() { @@ -217,14 +215,11 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind { Hidden: ptr.To(false), Deprecated: ptr.To(false), DeprecationMessage: ptr.To("This WorkspaceKind will be removed on 20XX-XX-XX, please use another WorkspaceKind."), - Icon: kubefloworgv1beta1.WorkspaceKindIcon{ + Icon: kubefloworgv1beta1.WorkspaceKindAsset{ Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"), }, - Logo: kubefloworgv1beta1.WorkspaceKindIcon{ - ConfigMap: &kubefloworgv1beta1.WorkspaceKindConfigMap{ - Name: "my-logos", - Key: "apple-touch-icon-152x152.png", - }, + Logo: kubefloworgv1beta1.WorkspaceKindAsset{ + Url: ptr.To("https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg"), }, }, PodTemplate: kubefloworgv1beta1.WorkspaceKindPodTemplate{ diff --git a/workspaces/backend/api/workspacekind_assets_handler.go b/workspaces/backend/api/workspacekind_assets_handler.go new file mode 100644 index 000000000..6cd169df9 --- /dev/null +++ b/workspaces/backend/api/workspacekind_assets_handler.go @@ -0,0 +1,153 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "errors" + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" + "github.com/kubeflow/notebooks/workspaces/backend/internal/helper" + models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" + repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds" +) + +// getWorkspaceKindAssetHandler is a helper function that handles common logic for retrieving +// and serving workspace kind assets (icon or logo). It validates path parameters, performs +// authentication, retrieves the asset, and serves it. +func (a *App) getWorkspaceKindAssetHandler( + w http.ResponseWriter, + r *http.Request, + ps httprouter.Params, + getAsset func(icon, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset, +) { + name := ps.ByName(ResourceNamePathParam) + + // validate path parameters + var valErrs field.ErrorList + valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), name)...) + if len(valErrs) > 0 { + a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil) + 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 both assets using the helper function + icon, logo, err := a.repositories.WorkspaceKind.GetWorkspaceKindAssets(r.Context(), name) + if err != nil { + if errors.Is(err, repository.ErrWorkspaceKindNotFound) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, err) + return + } + + // Get the appropriate asset (icon or logo) using the provided function + asset := getAsset(icon, logo) + + // Serve the asset + a.serveWorkspaceKindAsset(w, r, asset) +} + +// GetWorkspaceKindIconHandler serves the icon image for a WorkspaceKind. +// +// @Summary Get workspace kind icon +// @Description Returns the icon image for a specific workspace kind. If the icon is stored in a ConfigMap, it serves the image content. If the icon is a remote URL, returns 404 (browser should fetch directly). +// @Tags workspacekinds +// @ID getWorkspaceKindIcon +// @Accept json +// @Produce image/svg+xml +// @Param name path string true "Name of the workspace kind" +// @Success 200 {string} string "SVG image content" +// @Failure 404 {object} ErrorEnvelope "Not Found. Icon uses remote URL or resource does not exist." +// @Failure 500 {object} ErrorEnvelope "Internal server error." +// @Router /workspacekinds/{name}/assets/icon.svg [get] +func (a *App) GetWorkspaceKindIconHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + a.getWorkspaceKindAssetHandler(w, r, ps, func(icon, _ models.WorkspaceKindAsset) models.WorkspaceKindAsset { + return icon + }) +} + +// GetWorkspaceKindLogoHandler serves the logo image for a WorkspaceKind. +// +// @Summary Get workspace kind logo +// @Description Returns the logo image for a specific workspace kind. If the logo is stored in a ConfigMap, it serves the image content. If the logo is a remote URL, returns 404 (browser should fetch directly). +// @Tags workspacekinds +// @ID getWorkspaceKindLogo +// @Accept json +// @Produce image/svg+xml +// @Param name path string true "Name of the workspace kind" +// @Success 200 {string} string "SVG image content" +// @Failure 404 {object} ErrorEnvelope "Not Found. Logo uses remote URL or resource does not exist." +// @Failure 500 {object} ErrorEnvelope "Internal server error." +// @Router /workspacekinds/{name}/assets/logo.svg [get] +func (a *App) GetWorkspaceKindLogoHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + a.getWorkspaceKindAssetHandler(w, r, ps, func(_, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset { + return logo + }) +} + +// serveWorkspaceKindAsset serves an icon or logo asset from a WorkspaceKind. +// If the asset uses a remote URL, it returns 404 (browser should fetch directly). +// If the asset uses a ConfigMap, it retrieves and serves the content with proper headers. +func (a *App) serveWorkspaceKindAsset(w http.ResponseWriter, r *http.Request, asset models.WorkspaceKindAsset) { + // If URL is set, return 404 - browser should fetch directly from source + if asset.URL != nil && *asset.URL != "" { + a.notFoundResponse(w, r) + return + } + + // If ConfigMap is not set, return 404 + if asset.ConfigMap == nil { + a.notFoundResponse(w, r) + return + } + + imageContent, err := a.repositories.WorkspaceKind.GetConfigMapContent(r.Context(), asset) + if err != nil { + if apierrors.IsNotFound(err) { + a.notFoundResponse(w, r) + return + } + a.serverErrorResponse(w, r, fmt.Errorf("error retrieving ConfigMap content: %w", err)) + return + } + + // Write the SVG response + a.imageResponse(w, r, []byte(imageContent)) +} diff --git a/workspaces/backend/api/workspacekinds_handler_test.go b/workspaces/backend/api/workspacekinds_handler_test.go index a11e47922..186194fa2 100644 --- a/workspaces/backend/api/workspacekinds_handler_test.go +++ b/workspaces/backend/api/workspacekinds_handler_test.go @@ -137,6 +137,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { Expect(k8sClient.Get(ctx, workspaceKind2Key, workspacekind2)).To(Succeed()) By("ensuring the response contains the expected WorkspaceKinds") + // TODO: determine if/how we want to handle SHA256 hashes for ConfigMap-based assets in the tests Expect(response.Data).To(ConsistOf( models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1), models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind2), @@ -185,6 +186,7 @@ var _ = Describe("WorkspaceKinds Handler", func() { Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed()) By("ensuring the response matches the expected WorkspaceKind") + // TODO: determine if/how we want to handle SHA256 hashes for ConfigMap-based assets in the tests expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1) Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind)) diff --git a/workspaces/backend/cmd/main.go b/workspaces/backend/cmd/main.go index cbce9f639..016d681ea 100644 --- a/workspaces/backend/cmd/main.go +++ b/workspaces/backend/cmd/main.go @@ -23,7 +23,6 @@ import ( "strconv" ctrl "sigs.k8s.io/controller-runtime" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" application "github.com/kubeflow/notebooks/workspaces/backend/api" "github.com/kubeflow/notebooks/workspaces/backend/internal/auth" @@ -113,14 +112,7 @@ func main() { } // Create the controller manager - mgr, err := ctrl.NewManager(kubeconfig, ctrl.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{ - BindAddress: "0", // disable metrics serving - }, - HealthProbeBindAddress: "0", // disable health probe serving - LeaderElection: false, - }) + mgr, err := helper.NewManager(kubeconfig, scheme) if err != nil { logger.Error("unable to create manager", "error", err) os.Exit(1) @@ -139,8 +131,23 @@ func main() { logger.Error("failed to create request authorizer", "error", err) } + // Create a filtered cache client for ConfigMaps with the label notebooks.kubeflow.org/image-source: true + imageSourceConfigMapClient, err := helper.BuildImageSourceConfigMapClient(mgr) + if err != nil { + logger.Error("failed to create image source ConfigMap client", "error", err) + os.Exit(1) + } + // Create the application and server - app, err := application.NewApp(cfg, logger, mgr.GetClient(), mgr.GetScheme(), reqAuthN, reqAuthZ) + app, err := application.NewApp( + cfg, + logger, + mgr.GetClient(), + imageSourceConfigMapClient, + mgr.GetScheme(), + reqAuthN, + reqAuthZ, + ) if err != nil { logger.Error("failed to create app", "error", err) os.Exit(1) diff --git a/workspaces/backend/internal/helper/k8s.go b/workspaces/backend/internal/helper/k8s.go index 74532f8b5..7f46bef90 100644 --- a/workspaces/backend/internal/helper/k8s.go +++ b/workspaces/backend/internal/helper/k8s.go @@ -17,11 +17,20 @@ limitations under the License. package helper import ( + "context" "fmt" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ) // BuildScheme returns builds a new runtime scheme with all the necessary types registered. @@ -35,3 +44,94 @@ func BuildScheme() (*runtime.Scheme, error) { } return scheme, nil } + +// NewManager creates a new controller manager with the standard configuration. +// It disables caching for ConfigMap and Secret resources to avoid cache overhead, +// and disables metrics and health probe serving. +// Following guidance from: https://github.com/kubernetes-sigs/controller-runtime/issues/244#issuecomment-2466564541 +func NewManager(cfg *rest.Config, scheme *runtime.Scheme) (ctrl.Manager, error) { + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Client: client.Options{ + Cache: &client.CacheOptions{ + // Disable caching for ConfigMaps and Secrets as caching all of them can take a LOT of memory in a large cluster + DisableFor: []client.Object{ + &corev1.ConfigMap{}, + &corev1.Secret{}, + }, + }, + }, + Metrics: metricsserver.Options{ + BindAddress: "0", // disable metrics serving + }, + HealthProbeBindAddress: "0", // disable health probe serving + LeaderElection: false, + }) + if err != nil { + return nil, fmt.Errorf("unable to create manager: %w", err) + } + return mgr, nil +} + +// BuildImageSourceConfigMapClient creates a filtered cache client for ConfigMaps with the label +// notebooks.kubeflow.org/image-source: true. This follows the guidance from: +// https://github.com/kubernetes-sigs/controller-runtime/issues/244#issuecomment-2466564541 +// +// WARNING: a client which uses a filtered cache will be completely unable to see resources +// in the cluster which don't match the cache, even if they exist. +func BuildImageSourceConfigMapClient(mgr ctrl.Manager) (client.Client, error) { + // ConfigMaps we manage will have the `notebooks.kubeflow.org/image-source=true` label + imageSourceLabelReq, err := labels.NewRequirement("notebooks.kubeflow.org/image-source", selection.Equals, []string{"true"}) + if err != nil { + return nil, fmt.Errorf("failed to create label requirement: %w", err) + } + imageSourceLabelSelector := labels.NewSelector().Add(*imageSourceLabelReq) + + // create a new cache with a label selector for image source ConfigMaps + // NOTE: this means that the cache/client will be unable to see ConfigMaps without the "image-source" label + configMapCacheOpts := cache.Options{ + HTTPClient: mgr.GetHTTPClient(), + Scheme: mgr.GetScheme(), + Mapper: mgr.GetRESTMapper(), + ByObject: map[client.Object]cache.ByObject{ + &corev1.ConfigMap{}: { + Label: imageSourceLabelSelector, + }, + }, + // this requires us to explicitly start an informer for each object type + // and helps avoid people mistakenly using the configmap client for other resources + ReaderFailOnMissingInformer: true, + } + configMapCache, err := cache.New(mgr.GetConfig(), configMapCacheOpts) + if err != nil { + return nil, fmt.Errorf("failed to create ConfigMap cache: %w", err) + } + + // start an informer for ConfigMaps + // this is required because we set ReaderFailOnMissingInformer to true + _, err = configMapCache.GetInformer(context.Background(), &corev1.ConfigMap{}) + if err != nil { + return nil, fmt.Errorf("failed to get ConfigMap informer: %w", err) + } + + // add the ConfigMap cache to the manager, so that it starts at the same time + err = mgr.Add(configMapCache) + if err != nil { + return nil, fmt.Errorf("failed to add ConfigMap cache to manager: %w", err) + } + + // create a new client that uses the ConfigMap cache + configMapClient, err := client.New(mgr.GetConfig(), client.Options{ + HTTPClient: mgr.GetHTTPClient(), + Scheme: mgr.GetScheme(), + Mapper: mgr.GetRESTMapper(), + Cache: &client.CacheOptions{ + Reader: configMapCache, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create ConfigMap client: %w", err) + } + + return configMapClient, nil +} diff --git a/workspaces/backend/internal/models/common/assets/funcs.go b/workspaces/backend/internal/models/common/assets/funcs.go new file mode 100644 index 000000000..ce5d4077e --- /dev/null +++ b/workspaces/backend/internal/models/common/assets/funcs.go @@ -0,0 +1,115 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assets + +import ( + "errors" + "fmt" + + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" +) + +// BuildImageRef creates an ImageRef from a WorkspaceKindAsset. +// If the asset uses a URL, it returns the URL directly. +// If the asset uses a ConfigMap, it generates a backend API URL with an optional SHA256 hash as a query parameter. +// If assetInfo.ErrorCode() returns a non-empty string, the Error field will be set in the ImageRef. +func BuildImageRef(asset kubefloworgv1beta1.WorkspaceKindAsset, workspaceKindName string, assetInfo WorkspaceKindAssetDetails) ImageRef { + if asset.Url != nil && *asset.Url != "" { + return ImageRef{ + URL: *asset.Url, + } + } + + // If ConfigMap is set, generate backend API URL + if asset.ConfigMap != nil { + url := fmt.Sprintf("/api/v1/workspacekinds/%s/assets/%s.svg", workspaceKindName, assetInfo.Type()) + // Append SHA256 hash as query parameter if provided + if assetInfo.SHA256() != "" { + url = fmt.Sprintf("%s?sha256=%s", url, assetInfo.SHA256()) + } + + imageRef := ImageRef{ + URL: url, + } + + // If there was an error retrieving the ConfigMap, set the error field + if errorCode := assetInfo.ErrorCode(); errorCode != "" { + imageRef.Error = &errorCode + } + + return imageRef + } + + // Neither URL nor ConfigMap is set - return empty URL + return ImageRef{ + URL: "", + } +} + +// NewIconAssetInfo creates a WorkspaceKindIconInfo. The type is automatically determined by the struct type. +// Only asset-related errors (as determined by imageRefErrorCode) are stored; other errors are ignored. +func NewIconAssetInfo(sha256 string, err error) WorkspaceKindIconInfo { + errorCode := imageRefErrorCode(err) + + return WorkspaceKindIconInfo{ + workspaceKindAssetInfo: workspaceKindAssetInfo{ + sha256: sha256, + errorCode: errorCode, + }, + } +} + +// NewLogoAssetInfo creates a WorkspaceKindLogoInfo. The type is automatically determined by the struct type. +// Only asset-related errors (as determined by imageRefErrorCode) are stored; other errors are ignored. +func NewLogoAssetInfo(sha256 string, err error) WorkspaceKindLogoInfo { + errorCode := imageRefErrorCode(err) + + return WorkspaceKindLogoInfo{ + workspaceKindAssetInfo: workspaceKindAssetInfo{ + sha256: sha256, + errorCode: errorCode, + }, + } +} + +// NewAssetContext creates a WorkspaceKindAssetContext with icon and logo workspaceKindAssetInfo, automatically setting the types. +func NewAssetContext(iconSHA256, logoSHA256 string, iconErr, logoErr error) *WorkspaceKindAssetContext { + return &WorkspaceKindAssetContext{ + Icon: NewIconAssetInfo(iconSHA256, iconErr), + Logo: NewLogoAssetInfo(logoSHA256, logoErr), + } +} + +// imageRefErrorCode maps asset-related errors to ImageRefErrorCode enum values. +// Returns the error code if the error is a known asset error, empty string otherwise. +// This is an internal helper function used by the New***Info constructors and buildImageRef functions. +func imageRefErrorCode(err error) ImageRefErrorCode { + if err == nil { + return "" + } + + switch { + case errors.Is(err, ErrWorkspaceKindAssetConfigMapNotFound): + return ImageRefErrorCodeConfigMapMissing + case errors.Is(err, ErrWorkspaceKindAssetConfigMapKeyNotFound): + return ImageRefErrorCodeConfigMapKeyMissing + case errors.Is(err, ErrWorkspaceKindAssetConfigMapUnknown): + return ImageRefErrorCodeConfigMapUnknown + default: + return ImageRefErrorCodeUnknown + } +} diff --git a/workspaces/backend/internal/models/common/assets/types.go b/workspaces/backend/internal/models/common/assets/types.go new file mode 100644 index 000000000..d3596d5c3 --- /dev/null +++ b/workspaces/backend/internal/models/common/assets/types.go @@ -0,0 +1,152 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assets + +import "errors" + +// ImageRef represents a reference to an image (icon or logo) that can be sourced from a URL or ConfigMap. +type ImageRef struct { + URL string `json:"url"` + Error *ImageRefErrorCode `json:"error,omitempty"` +} + +// ImageRefErrorCode represents error codes for asset retrieval errors. +// This is used both internally and in API responses to indicate errors when retrieving assets from ConfigMaps. +type ImageRefErrorCode string + +const ( + ImageRefErrorCodeConfigMapMissing ImageRefErrorCode = "CONFIGMAP_MISSING" + ImageRefErrorCodeConfigMapKeyMissing ImageRefErrorCode = "CONFIGMAP_KEY_MISSING" + ImageRefErrorCodeConfigMapUnknown ImageRefErrorCode = "CONFIGMAP_UNKNOWN" + ImageRefErrorCodeUnknown ImageRefErrorCode = "UNKNOWN" +) + +// Errors related to asset retrieval from ConfigMaps. +// These are used by both workspacekinds and workspaces repositories. +var ( + ErrWorkspaceKindAssetConfigMapNotFound = errors.New("workspace kind asset configmap not found") + ErrWorkspaceKindAssetConfigMapKeyNotFound = errors.New("workspace kind asset configmap key not found") + ErrWorkspaceKindAssetConfigMapUnknown = errors.New("workspace kind asset configmap unknown") +) + +// WorkspaceKindAssetType represents the type of asset (icon or logo). +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindAssetType string + +const ( + // WorkspaceKindAssetTypeIcon represents the icon asset type. + WorkspaceKindAssetTypeIcon WorkspaceKindAssetType = "icon" + // WorkspaceKindAssetTypeLogo represents the logo asset type. + WorkspaceKindAssetTypeLogo WorkspaceKindAssetType = "logo" +) + +// WorkspaceKindAsset represents an asset (icon or logo) for a WorkspaceKind. +// It can be sourced from either a URL or a ConfigMap, but not both. +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindAsset struct { + // URL is an optional remote URL to the asset. + // If set, the asset should be fetched directly from this URL. + URL *string `json:"url,omitempty"` + + // ConfigMap is an optional reference to a ConfigMap containing the asset. + // If set, the asset is stored in a ConfigMap and should be retrieved from there. + ConfigMap *WorkspaceKindAssetConfigMap `json:"configMap,omitempty"` +} + +// WorkspaceKindAssetConfigMap represents a reference to a ConfigMap containing an asset. +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindAssetConfigMap struct { + // Name is the name of the ConfigMap. + Name string `json:"name"` + + // Key is the key within the ConfigMap that contains the asset data. + Key string `json:"key"` + + // Namespace is the namespace where the ConfigMap is located. + Namespace string `json:"namespace"` +} + +// WorkspaceKindAssetDetails defines the interface for asset information. +// Both WorkspaceKindIconInfo and WorkspaceKindLogoInfo implement this interface. +// This interface is used by both workspacekinds and workspaces packages. +type WorkspaceKindAssetDetails interface { + Type() string // Returns the asset type as a string (e.g., "icon" or "logo") + SHA256() string + ErrorCode() ImageRefErrorCode +} + +// workspaceKindAssetInfo contains metadata about an asset (icon or logo). +// This type is used internally within the assets package. +type workspaceKindAssetInfo struct { + // SHA256 is the SHA256 hash of the asset content (for ConfigMap-based assets). + // Empty string if the asset uses a URL or if the hash is not available. + sha256 string + + // ErrorCode is the error code for errors that occurred when retrieving the asset from a ConfigMap. + // This is set when the ConfigMap doesn't exist or lacks the required label. + // Empty string if there was no error or if the asset uses a URL. + errorCode ImageRefErrorCode +} + +// SHA256 returns the SHA256 hash of the asset. +func (a workspaceKindAssetInfo) SHA256() string { + return a.sha256 +} + +// ErrorCode returns the error code that occurred when retrieving the asset. +func (a workspaceKindAssetInfo) ErrorCode() ImageRefErrorCode { + return a.errorCode +} + +// Type returns the type of asset as a string. This method should be overridden by WorkspaceKindIconInfo and WorkspaceKindLogoInfo. +func (a workspaceKindAssetInfo) Type() string { + return "" // Base implementation returns empty - should not be called directly +} + +// WorkspaceKindIconInfo contains metadata about an icon asset. +// It embeds workspaceKindAssetInfo and automatically returns "icon" for Type(). +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindIconInfo struct { + workspaceKindAssetInfo +} + +// Type returns WorkspaceKindAssetTypeIcon for icon assets. +func (WorkspaceKindIconInfo) Type() string { + return string(WorkspaceKindAssetTypeIcon) +} + +// WorkspaceKindLogoInfo contains metadata about a logo asset. +// It embeds workspaceKindAssetInfo and automatically returns WorkspaceKindAssetTypeLogo for Type(). +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindLogoInfo struct { + workspaceKindAssetInfo +} + +// Type returns WorkspaceKindAssetTypeLogo for logo assets. +func (WorkspaceKindLogoInfo) Type() string { + return string(WorkspaceKindAssetTypeLogo) +} + +// WorkspaceKindAssetContext contains asset information for both icon and logo. +// This type is used by both workspacekinds and workspaces packages. +type WorkspaceKindAssetContext struct { + // Icon contains metadata about the icon asset. + Icon WorkspaceKindIconInfo + + // Logo contains metadata about the logo asset. + Logo WorkspaceKindLogoInfo +} diff --git a/workspaces/backend/internal/models/workspacekinds/assets/funcs.go b/workspaces/backend/internal/models/workspacekinds/assets/funcs.go new file mode 100644 index 000000000..faa074e51 --- /dev/null +++ b/workspaces/backend/internal/models/workspacekinds/assets/funcs.go @@ -0,0 +1,21 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assets + +// This file is intentionally minimal. Most asset-related functionality has been moved to +// common/assets to be shared between workspacekinds and workspaces packages. +// Only workspacekinds-specific functions would be included here. diff --git a/workspaces/backend/internal/models/workspacekinds/assets/types.go b/workspaces/backend/internal/models/workspacekinds/assets/types.go new file mode 100644 index 000000000..19a7f6dc5 --- /dev/null +++ b/workspaces/backend/internal/models/workspacekinds/assets/types.go @@ -0,0 +1,53 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assets + +import ( + kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + + commonassets "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" +) + +// NewWorkspaceKindAssetFromWorkspaceKind converts a controller WorkspaceKindAsset to a backend model WorkspaceKindAsset. +// This function maintains decoupling between the controller and backend packages. +func NewWorkspaceKindAssetFromWorkspaceKind(wsk *kubefloworgv1beta1.WorkspaceKind, assetType commonassets.WorkspaceKindAssetType) commonassets.WorkspaceKindAsset { + var asset kubefloworgv1beta1.WorkspaceKindAsset + switch assetType { + case commonassets.WorkspaceKindAssetTypeIcon: + asset = wsk.Spec.Spawner.Icon + case commonassets.WorkspaceKindAssetTypeLogo: + asset = wsk.Spec.Spawner.Logo + default: + // Return empty asset if invalid assetType (should not happen with enum) + return commonassets.WorkspaceKindAsset{} + } + + result := commonassets.WorkspaceKindAsset{ + URL: asset.Url, + } + + // Convert ConfigMap if present + if asset.ConfigMap != nil { + result.ConfigMap = &commonassets.WorkspaceKindAssetConfigMap{ + Name: asset.ConfigMap.Name, + Key: asset.ConfigMap.Key, + Namespace: asset.ConfigMap.Namespace, + } + } + + return result +} diff --git a/workspaces/backend/internal/models/workspacekinds/funcs.go b/workspaces/backend/internal/models/workspacekinds/funcs.go index 268b458ed..a1e738a98 100644 --- a/workspaces/backend/internal/models/workspacekinds/funcs.go +++ b/workspaces/backend/internal/models/workspacekinds/funcs.go @@ -17,14 +17,25 @@ limitations under the License. package workspacekinds import ( - "fmt" - kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" "k8s.io/utils/ptr" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" ) // NewWorkspaceKindModelFromWorkspaceKind creates a WorkspaceKind model from a WorkspaceKind object. +// This is a convenience function that calls NewWorkspaceKindModelFromWorkspaceKindWithAssetContext with nil WorkspaceKindAssetContext. +// For cases where asset information is available (e.g., ConfigMap-based assets), use NewWorkspaceKindModelFromWorkspaceKindWithAssetContext instead. func NewWorkspaceKindModelFromWorkspaceKind(wsk *kubefloworgv1beta1.WorkspaceKind) WorkspaceKind { + return NewWorkspaceKindModelFromWorkspaceKindWithAssetContext(wsk, nil) +} + +// NewWorkspaceKindModelFromWorkspaceKindWithAssetContext creates a WorkspaceKind model from a WorkspaceKind object. +// assetCtx contains metadata about icon and logo assets (SHA256 hashes and errors). +// If nil, assets will be built without hash or error information. +// SHA256 hashes will be appended as query parameters to ConfigMap-based asset URLs. +// Errors will be set in ImageRef.Error when ConfigMap retrieval fails. +func NewWorkspaceKindModelFromWorkspaceKindWithAssetContext(wsk *kubefloworgv1beta1.WorkspaceKind, assetCtx *assets.WorkspaceKindAssetContext) WorkspaceKind { podLabels := make(map[string]string) podAnnotations := make(map[string]string) if wsk.Spec.PodTemplate.PodMetadata != nil { @@ -39,19 +50,18 @@ func NewWorkspaceKindModelFromWorkspaceKind(wsk *kubefloworgv1beta1.WorkspaceKin statusImageConfigMap := buildOptionMetricsMap(wsk.Status.PodTemplateOptions.ImageConfig) statusPodConfigMap := buildOptionMetricsMap(wsk.Status.PodTemplateOptions.PodConfig) - // TODO: icons can either be a remote URL or read from a ConfigMap. - // in BOTH cases, we should cache and serve the image under a path on the backend API: - // /api/v1/workspacekinds/{name}/assets/icon - iconRef := ImageRef{ - URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", wsk.Name), - } - - // TODO: logos can either be a remote URL or read from a ConfigMap. - // in BOTH cases, we should cache and serve the image under a path on the backend API: - // /api/v1/workspacekinds/{name}/assets/logo - logoRef := ImageRef{ - URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", wsk.Name), + var iconInfo assets.WorkspaceKindAssetDetails + var logoInfo assets.WorkspaceKindAssetDetails + if assetCtx != nil { + iconInfo = assetCtx.Icon + logoInfo = assetCtx.Logo + } else { + // Create empty AssetInfo with types set when WorkspaceKindAssetContext is nil + iconInfo = assets.NewIconAssetInfo("", nil) + logoInfo = assets.NewLogoAssetInfo("", nil) } + iconRef := buildIconImageRef(wsk, iconInfo) + logoRef := buildLogoImageRef(wsk, logoInfo) return WorkspaceKind{ Name: wsk.Name, @@ -175,3 +185,13 @@ func buildOptionRedirect(redirect *kubefloworgv1beta1.OptionRedirect) *OptionRed Message: message, } } + +// buildIconImageRef creates an ImageRef from the icon asset of a WorkspaceKind. +func buildIconImageRef(wsk *kubefloworgv1beta1.WorkspaceKind, iconInfo assets.WorkspaceKindAssetDetails) assets.ImageRef { + return assets.BuildImageRef(wsk.Spec.Spawner.Icon, wsk.Name, iconInfo) +} + +// buildLogoImageRef creates an ImageRef from the logo asset of a WorkspaceKind. +func buildLogoImageRef(wsk *kubefloworgv1beta1.WorkspaceKind, logoInfo assets.WorkspaceKindAssetDetails) assets.ImageRef { + return assets.BuildImageRef(wsk.Spec.Spawner.Logo, wsk.Name, logoInfo) +} diff --git a/workspaces/backend/internal/models/workspacekinds/types.go b/workspaces/backend/internal/models/workspacekinds/types.go index 2996f4666..715083473 100644 --- a/workspaces/backend/internal/models/workspacekinds/types.go +++ b/workspaces/backend/internal/models/workspacekinds/types.go @@ -16,27 +16,27 @@ limitations under the License. package workspacekinds +import ( + commonassets "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" +) + type WorkspaceKind struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Description string `json:"description"` - Deprecated bool `json:"deprecated"` - DeprecationMessage string `json:"deprecationMessage"` - Hidden bool `json:"hidden"` - Icon ImageRef `json:"icon"` - Logo ImageRef `json:"logo"` - ClusterMetrics clusterMetrics `json:"clusterMetrics,omitempty"` - PodTemplate PodTemplate `json:"podTemplate"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Deprecated bool `json:"deprecated"` + DeprecationMessage string `json:"deprecationMessage"` + Hidden bool `json:"hidden"` + Icon commonassets.ImageRef `json:"icon"` + Logo commonassets.ImageRef `json:"logo"` + ClusterMetrics clusterMetrics `json:"clusterMetrics,omitempty"` + PodTemplate PodTemplate `json:"podTemplate"` } type clusterMetrics struct { Workspaces int32 `json:"workspacesCount"` } -type ImageRef struct { - URL string `json:"url"` -} - type PodTemplate struct { PodMetadata PodMetadata `json:"podMetadata"` VolumeMounts PodVolumeMounts `json:"volumeMounts"` diff --git a/workspaces/backend/internal/models/workspaces/funcs.go b/workspaces/backend/internal/models/workspaces/funcs.go index c4cf3caa6..a90438b94 100644 --- a/workspaces/backend/internal/models/workspaces/funcs.go +++ b/workspaces/backend/internal/models/workspaces/funcs.go @@ -21,6 +21,8 @@ import ( kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" "k8s.io/utils/ptr" + + "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" ) const ( @@ -30,26 +32,40 @@ const ( ) // NewWorkspaceModelFromWorkspace creates a Workspace model from a Workspace and WorkspaceKind object. -// NOTE: the WorkspaceKind might not exist, so we handle the case where it is nil or has no UID. +// This is a convenience function that calls NewWorkspaceModelFromWorkspaceWithAssetContext with nil WorkspaceKindAssetContext. +// For cases where asset information is available (e.g., ConfigMap-based assets), use NewWorkspaceModelFromWorkspaceWithAssetContext instead. func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind) Workspace { + return NewWorkspaceModelFromWorkspaceWithAssetContext(ws, wsk, nil) +} + +// NewWorkspaceModelFromWorkspaceWithAssetContext creates a Workspace model from a Workspace and WorkspaceKind object. +// NOTE: the WorkspaceKind might not exist, so we handle the case where it is nil or has no UID. +// assetCtx contains metadata about icon and logo assets (SHA256 hashes and errors). +// If nil, assets will be built without hash or error information. +// SHA256 hashes will be appended as query parameters to ConfigMap-based asset URLs. +// Errors will be set in ImageRef.Error when ConfigMap retrieval fails. +func NewWorkspaceModelFromWorkspaceWithAssetContext(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind, assetCtx *assets.WorkspaceKindAssetContext) Workspace { // ensure the provided WorkspaceKind matches the Workspace if wskExists(wsk) && ws.Spec.Kind != wsk.Name { panic("provided WorkspaceKind does not match the Workspace") } - // TODO: icons can either be a remote URL or read from a ConfigMap. - // in BOTH cases, we should cache and serve the image under a path on the backend API: - // /api/v1/workspacekinds/{name}/assets/icon - iconRef := ImageRef{ - URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/icon", ws.Spec.Kind), + var iconInfo assets.WorkspaceKindAssetDetails + var logoInfo assets.WorkspaceKindAssetDetails + if assetCtx != nil { + iconInfo = assetCtx.Icon + logoInfo = assetCtx.Logo + } else { + // Create empty AssetInfo with types set when WorkspaceKindAssetContext is nil + iconInfo = assets.NewIconAssetInfo("", nil) + logoInfo = assets.NewLogoAssetInfo("", nil) } - // TODO: logos can either be a remote URL or read from a ConfigMap. - // in BOTH cases, we should cache and serve the image under a path on the backend API: - // /api/v1/workspacekinds/{name}/assets/logo - logoRef := ImageRef{ - URL: fmt.Sprintf("/workspaces/backend/api/v1/workspacekinds/%s/assets/logo", ws.Spec.Kind), - } + // Build icon reference based on source type (URL or ConfigMap) + iconRef := buildIconImageRef(ws, wsk, iconInfo) + + // Build logo reference based on source type (URL or ConfigMap) + logoRef := buildLogoImageRef(ws, wsk, logoInfo) wsState := WorkspaceStateUnknown switch ws.Status.State { @@ -366,3 +382,25 @@ func buildServices(ws *kubefloworgv1beta1.Workspace, wskPodTemplatePorts map[kub return services } + +// buildIconImageRef creates an ImageRef from the icon asset of a WorkspaceKind. +// If WorkspaceKind is nil or missing, returns an empty ImageRef. +// If the asset uses a URL, it returns the URL directly. +// If the asset uses a ConfigMap, it generates a backend API URL with an optional SHA256 hash as a query parameter. +func buildIconImageRef(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind, iconInfo assets.WorkspaceKindAssetDetails) assets.ImageRef { + if !wskExists(wsk) { + return assets.ImageRef{URL: ""} + } + return assets.BuildImageRef(wsk.Spec.Spawner.Icon, ws.Spec.Kind, iconInfo) +} + +// buildLogoImageRef creates an ImageRef from the logo asset of a WorkspaceKind. +// If WorkspaceKind is nil or missing, returns an empty ImageRef. +// If the asset uses a URL, it returns the URL directly. +// If the asset uses a ConfigMap, it generates a backend API URL with an optional SHA256 hash as a query parameter. +func buildLogoImageRef(ws *kubefloworgv1beta1.Workspace, wsk *kubefloworgv1beta1.WorkspaceKind, logoInfo assets.WorkspaceKindAssetDetails) assets.ImageRef { + if !wskExists(wsk) { + return assets.ImageRef{URL: ""} + } + return assets.BuildImageRef(wsk.Spec.Spawner.Logo, ws.Spec.Kind, logoInfo) +} diff --git a/workspaces/backend/internal/models/workspaces/types.go b/workspaces/backend/internal/models/workspaces/types.go index 4e8ddaecd..a2b54120e 100644 --- a/workspaces/backend/internal/models/workspaces/types.go +++ b/workspaces/backend/internal/models/workspaces/types.go @@ -16,6 +16,10 @@ limitations under the License. package workspaces +import ( + commonassets "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" +) + // Workspace represents a workspace in the system, and is returned by GET and LIST operations. // NOTE: this type is not used for CREATE or UPDATE operations, see WorkspaceCreate type Workspace struct { @@ -45,14 +49,10 @@ const ( ) type WorkspaceKindInfo struct { - Name string `json:"name"` - Missing bool `json:"missing"` - Icon ImageRef `json:"icon"` - Logo ImageRef `json:"logo"` -} - -type ImageRef struct { - URL string `json:"url"` + Name string `json:"name"` + Missing bool `json:"missing"` + Icon commonassets.ImageRef `json:"icon"` + Logo commonassets.ImageRef `json:"logo"` } type PodTemplate struct { diff --git a/workspaces/backend/internal/repositories/repositories.go b/workspaces/backend/internal/repositories/repositories.go index 6e7429e2f..b77c9450f 100644 --- a/workspaces/backend/internal/repositories/repositories.go +++ b/workspaces/backend/internal/repositories/repositories.go @@ -34,11 +34,11 @@ type Repositories struct { } // NewRepositories creates a new Repositories instance from a controller-runtime client. -func NewRepositories(cl client.Client) *Repositories { +func NewRepositories(cl client.Client, configMapClient client.Client) *Repositories { return &Repositories{ HealthCheck: health_check.NewHealthCheckRepository(), Namespace: namespaces.NewNamespaceRepository(cl), - Workspace: workspaces.NewWorkspaceRepository(cl), - WorkspaceKind: workspacekinds.NewWorkspaceKindRepository(cl), + Workspace: workspaces.NewWorkspaceRepository(cl, configMapClient), + WorkspaceKind: workspacekinds.NewWorkspaceKindRepository(cl, configMapClient), } } diff --git a/workspaces/backend/internal/repositories/workspacekinds/repo.go b/workspaces/backend/internal/repositories/workspacekinds/repo.go index 02cd208b9..253b638f5 100644 --- a/workspaces/backend/internal/repositories/workspacekinds/repo.go +++ b/workspaces/backend/internal/repositories/workspacekinds/repo.go @@ -18,25 +18,33 @@ package workspacekinds import ( "context" + "crypto/sha256" + "encoding/hex" "errors" + "fmt" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" + commonassets "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" + assetmodels "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds/assets" ) var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists") type WorkspaceKindRepository struct { - client client.Client + client client.Client + configMapClient client.Client // filtered cache client for ConfigMaps with notebooks.kubeflow.org/image-source: true } -func NewWorkspaceKindRepository(cl client.Client) *WorkspaceKindRepository { +func NewWorkspaceKindRepository(cl client.Client, configMapClient client.Client) *WorkspaceKindRepository { return &WorkspaceKindRepository{ - client: cl, + client: cl, + configMapClient: configMapClient, } } @@ -64,12 +72,50 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode workspaceKindsModels := make([]models.WorkspaceKind, len(workspaceKindList.Items)) for i := range workspaceKindList.Items { workspaceKind := &workspaceKindList.Items[i] - workspaceKindsModels[i] = models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) + + // TODO: should we use a cache here to avoid recomputing the hash for the same asset? + // Compute SHA256 hashes for ConfigMap-based assets + // Capture errors to populate ImageRef.Error field + assetCtx := r.getWorkspaceKindAssetContext(ctx, workspaceKind) + + workspaceKindsModels[i] = models.NewWorkspaceKindModelFromWorkspaceKindWithAssetContext(workspaceKind, assetCtx) } return workspaceKindsModels, nil } +// getWorkspaceKindAssetContext computes the SHA256 hashes for both icon and logo assets of a WorkspaceKind. +// Returns a WorkspaceKindAssetContext containing the SHA256 hashes and any errors encountered during retrieval. +func (r *WorkspaceKindRepository) getWorkspaceKindAssetContext(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) *commonassets.WorkspaceKindAssetContext { + iconSHA256, iconErr := r.computeAssetSHA256(ctx, workspaceKind.Spec.Spawner.Icon) + logoSHA256, logoErr := r.computeAssetSHA256(ctx, workspaceKind.Spec.Spawner.Logo) + return commonassets.NewAssetContext(iconSHA256, logoSHA256, iconErr, logoErr) +} + +// computeAssetSHA256 computes the SHA256 hash of a WorkspaceKindAsset if it uses a ConfigMap. +// Returns empty string if the asset does not use a ConfigMap or if there's an error retrieving the ConfigMap. +func (r *WorkspaceKindRepository) computeAssetSHA256(ctx context.Context, asset kubefloworgv1beta1.WorkspaceKindAsset) (string, error) { + if asset.ConfigMap == nil { + return "", nil + } + + assetModel := commonassets.WorkspaceKindAsset{ + ConfigMap: &commonassets.WorkspaceKindAssetConfigMap{ + Name: asset.ConfigMap.Name, + Key: asset.ConfigMap.Key, + Namespace: asset.ConfigMap.Namespace, + }, + } + + content, err := r.GetConfigMapContent(ctx, assetModel) + if err != nil { + return "", err + } + + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]), nil +} + func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) (*models.WorkspaceKind, error) { // create workspace kind if err := r.client.Create(ctx, workspaceKind); err != nil { @@ -92,3 +138,51 @@ func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kub return &createdWorkspaceKindModel, nil } + +// GetWorkspaceKindAssets retrieves both icon and logo assets from a WorkspaceKind. +// It queries for the WorkspaceKind CRD once and converts both assets to the backend model. +// Returns icon as the first value and logo as the second value, matching the order they are defined in the CRD. +func (r *WorkspaceKindRepository) GetWorkspaceKindAssets(ctx context.Context, name string) (commonassets.WorkspaceKindAsset, commonassets.WorkspaceKindAsset, error) { + workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} + err := r.client.Get(ctx, client.ObjectKey{Name: name}, workspaceKind) + if err != nil { + if apierrors.IsNotFound(err) { + return commonassets.WorkspaceKindAsset{}, commonassets.WorkspaceKindAsset{}, ErrWorkspaceKindNotFound + } + return commonassets.WorkspaceKindAsset{}, commonassets.WorkspaceKindAsset{}, err + } + + icon := assetmodels.NewWorkspaceKindAssetFromWorkspaceKind(workspaceKind, commonassets.WorkspaceKindAssetTypeIcon) + logo := assetmodels.NewWorkspaceKindAssetFromWorkspaceKind(workspaceKind, commonassets.WorkspaceKindAssetTypeLogo) + + return icon, logo, nil +} + +// GetConfigMapContent retrieves the content from a ConfigMap referenced by a WorkspaceKindAsset. +// Returns the content as a string, or an error if the ConfigMap or key cannot be found. +func (r *WorkspaceKindRepository) GetConfigMapContent(ctx context.Context, asset commonassets.WorkspaceKindAsset) (string, error) { + if asset.ConfigMap == nil { + return "", fmt.Errorf("asset does not reference a ConfigMap") + } + + configMap := &corev1.ConfigMap{} + err := r.configMapClient.Get(ctx, client.ObjectKey{ + Namespace: asset.ConfigMap.Namespace, + Name: asset.ConfigMap.Name, + }, configMap) + if err != nil { + if apierrors.IsNotFound(err) { + return "", commonassets.ErrWorkspaceKindAssetConfigMapNotFound + } + return "", commonassets.ErrWorkspaceKindAssetConfigMapUnknown + } + + if data, exists := configMap.Data[asset.ConfigMap.Key]; exists { + return data, nil + } + if binaryData, exists := configMap.BinaryData[asset.ConfigMap.Key]; exists { + return string(binaryData), nil + } + + return "", commonassets.ErrWorkspaceKindAssetConfigMapKeyNotFound +} diff --git a/workspaces/backend/internal/repositories/workspaces/repo.go b/workspaces/backend/internal/repositories/workspaces/repo.go index dc4541a27..233e419c0 100644 --- a/workspaces/backend/internal/repositories/workspaces/repo.go +++ b/workspaces/backend/internal/repositories/workspaces/repo.go @@ -18,16 +18,20 @@ package workspaces import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + commonassets "github.com/kubeflow/notebooks/workspaces/backend/internal/models/common/assets" models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces" action_models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspaces/actions" ) @@ -39,12 +43,14 @@ var ( ) type WorkspaceRepository struct { - client client.Client + client client.Client + configMapClient client.Client // filtered cache client for ConfigMaps with notebooks.kubeflow.org/image-source: true } -func NewWorkspaceRepository(cl client.Client) *WorkspaceRepository { +func NewWorkspaceRepository(cl client.Client, configMapClient client.Client) *WorkspaceRepository { return &WorkspaceRepository{ - client: cl, + client: cl, + configMapClient: configMapClient, } } @@ -75,47 +81,26 @@ func (r *WorkspaceRepository) GetWorkspace(ctx context.Context, namespace string } func (r *WorkspaceRepository) GetWorkspaces(ctx context.Context, namespace string) ([]models.Workspace, error) { - // get all workspaces in the namespace - workspaceList := &kubefloworgv1beta1.WorkspaceList{} - listOptions := []client.ListOption{ - client.InNamespace(namespace), - } - err := r.client.List(ctx, workspaceList, listOptions...) - if err != nil { - return nil, err - } - - // convert workspaces to models - workspacesModels := make([]models.Workspace, len(workspaceList.Items)) - for i, workspace := range workspaceList.Items { - - // get workspace kind, if it exists - workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} - workspaceKindName := workspace.Spec.Kind - if err := r.client.Get(ctx, client.ObjectKey{Name: workspaceKindName}, workspaceKind); err != nil { - // ignore error if workspace kind does not exist, as we can still create a model without it - if !apierrors.IsNotFound(err) { - return nil, err - } - } - - workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind) - } - - return workspacesModels, nil + return r.getWorkspaceModels(ctx, client.InNamespace(namespace)) } func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.Workspace, error) { - // get all workspaces in the cluster + return r.getWorkspaceModels(ctx) +} + +// getWorkspaceModels lists workspaces using the provided ListOptions and converts them to models. +// For each workspace, it retrieves the associated WorkspaceKind and computes asset SHA256 hashes +// for ConfigMap-based assets to populate the ImageRef fields. +func (r *WorkspaceRepository) getWorkspaceModels(ctx context.Context, listOptions ...client.ListOption) ([]models.Workspace, error) { + // get workspaces using the provided list options workspaceList := &kubefloworgv1beta1.WorkspaceList{} - if err := r.client.List(ctx, workspaceList); err != nil { + if err := r.client.List(ctx, workspaceList, listOptions...); err != nil { return nil, err } // convert workspaces to models workspacesModels := make([]models.Workspace, len(workspaceList.Items)) for i, workspace := range workspaceList.Items { - // get workspace kind, if it exists workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} workspaceKindName := workspace.Spec.Kind @@ -124,9 +109,18 @@ func (r *WorkspaceRepository) GetAllWorkspaces(ctx context.Context) ([]models.Wo if !apierrors.IsNotFound(err) { return nil, err } + // If not found, set workspaceKind to nil to indicate it doesn't exist + workspaceKind = nil } - workspacesModels[i] = models.NewWorkspaceModelFromWorkspace(&workspace, workspaceKind) + // Compute SHA256 hashes for ConfigMap-based assets if WorkspaceKind exists + // Capture errors to populate ImageRef.Error field + var assetCtx *commonassets.WorkspaceKindAssetContext + if workspaceKind != nil && workspaceKind.UID != "" { + assetCtx = r.getWorkspaceKindAssetContext(ctx, workspaceKind) + } + + workspacesModels[i] = models.NewWorkspaceModelFromWorkspaceWithAssetContext(&workspace, workspaceKind, assetCtx) } return workspacesModels, nil @@ -220,6 +214,67 @@ func (r *WorkspaceRepository) DeleteWorkspace(ctx context.Context, namespace, wo return nil } +// getWorkspaceKindAssetContext computes the SHA256 hashes for both icon and logo assets of a WorkspaceKind. +// Returns a WorkspaceKindAssetContext containing the SHA256 hashes and any errors encountered during retrieval. +func (r *WorkspaceRepository) getWorkspaceKindAssetContext(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) *commonassets.WorkspaceKindAssetContext { + iconSHA256, iconErr := r.computeAssetSHA256(ctx, workspaceKind.Spec.Spawner.Icon) + logoSHA256, logoErr := r.computeAssetSHA256(ctx, workspaceKind.Spec.Spawner.Logo) + return commonassets.NewAssetContext(iconSHA256, logoSHA256, iconErr, logoErr) +} + +// computeAssetSHA256 computes the SHA256 hash of a WorkspaceKindAsset if it uses a ConfigMap. +// Returns empty string if the asset does not use a ConfigMap or if there's an error retrieving the ConfigMap. +func (r *WorkspaceRepository) computeAssetSHA256(ctx context.Context, asset kubefloworgv1beta1.WorkspaceKindAsset) (string, error) { + if asset.ConfigMap == nil { + return "", nil + } + + assetModel := commonassets.WorkspaceKindAsset{ + ConfigMap: &commonassets.WorkspaceKindAssetConfigMap{ + Name: asset.ConfigMap.Name, + Key: asset.ConfigMap.Key, + Namespace: asset.ConfigMap.Namespace, + }, + } + + content, err := r.getConfigMapContent(ctx, assetModel) + if err != nil { + return "", err + } + + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]), nil +} + +// getConfigMapContent retrieves the content from a ConfigMap referenced by a WorkspaceKindAsset. +// Returns the content as a string, or an error if the ConfigMap or key cannot be found. +func (r *WorkspaceRepository) getConfigMapContent(ctx context.Context, asset commonassets.WorkspaceKindAsset) (string, error) { + if asset.ConfigMap == nil { + return "", fmt.Errorf("asset does not reference a ConfigMap") + } + + configMap := &corev1.ConfigMap{} + err := r.configMapClient.Get(ctx, client.ObjectKey{ + Namespace: asset.ConfigMap.Namespace, + Name: asset.ConfigMap.Name, + }, configMap) + if err != nil { + if apierrors.IsNotFound(err) { + return "", commonassets.ErrWorkspaceKindAssetConfigMapNotFound + } + return "", commonassets.ErrWorkspaceKindAssetConfigMapUnknown + } + + if data, exists := configMap.Data[asset.ConfigMap.Key]; exists { + return data, nil + } + if binaryData, exists := configMap.BinaryData[asset.ConfigMap.Key]; exists { + return string(binaryData), nil + } + + return "", commonassets.ErrWorkspaceKindAssetConfigMapKeyNotFound +} + // WorkspacePatchOperation represents a single JSONPatch operation type WorkspacePatchOperation struct { Op string `json:"op"` diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index d0b290754..9b6312c10 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -272,6 +272,96 @@ const docTemplate = `{ } } }, + "/workspacekinds/{name}/assets/icon.svg": { + "get": { + "description": "Returns the icon image for a specific workspace kind. If the icon is stored in a ConfigMap, it serves the image content. If the icon is a remote URL, returns 404 (browser should fetch directly).", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Get workspace kind icon", + "operationId": "getWorkspaceKindIcon", + "parameters": [ + { + "type": "string", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SVG image content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found. Icon uses remote URL or resource does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, + "/workspacekinds/{name}/assets/logo.svg": { + "get": { + "description": "Returns the logo image for a specific workspace kind. If the logo is stored in a ConfigMap, it serves the image content. If the logo is a remote URL, returns 404 (browser should fetch directly).", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Get workspace kind logo", + "operationId": "getWorkspaceKindLogo", + "parameters": [ + { + "type": "string", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SVG image content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found. Logo uses remote URL or resource does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces": { "get": { "description": "Returns a list of all workspaces across all namespaces.", @@ -851,6 +941,35 @@ const docTemplate = `{ } } }, + "assets.ImageRef": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "error": { + "$ref": "#/definitions/assets.ImageRefErrorCode" + }, + "url": { + "type": "string" + } + } + }, + "assets.ImageRefErrorCode": { + "type": "string", + "enum": [ + "CONFIGMAP_MISSING", + "CONFIGMAP_KEY_MISSING", + "CONFIGMAP_UNKNOWN", + "UNKNOWN" + ], + "x-enum-varnames": [ + "ImageRefErrorCodeConfigMapMissing", + "ImageRefErrorCodeConfigMapKeyMissing", + "ImageRefErrorCodeConfigMapUnknown", + "ImageRefErrorCodeUnknown" + ] + }, "field.ErrorType": { "type": "string", "enum": [ @@ -980,17 +1099,6 @@ const docTemplate = `{ } } }, - "workspacekinds.ImageRef": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "workspacekinds.OptionLabel": { "type": "object", "required": [ @@ -1201,10 +1309,10 @@ const docTemplate = `{ "type": "boolean" }, "icon": { - "$ref": "#/definitions/workspacekinds.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "logo": { - "$ref": "#/definitions/workspacekinds.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "name": { "type": "string" @@ -1280,17 +1388,6 @@ const docTemplate = `{ } } }, - "workspaces.ImageRef": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "workspaces.LastProbeInfo": { "type": "object", "required": [ @@ -1768,10 +1865,10 @@ const docTemplate = `{ ], "properties": { "icon": { - "$ref": "#/definitions/workspaces.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "logo": { - "$ref": "#/definitions/workspaces.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "missing": { "type": "boolean" diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index 33669b8a0..e16a36533 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -270,6 +270,96 @@ } } }, + "/workspacekinds/{name}/assets/icon.svg": { + "get": { + "description": "Returns the icon image for a specific workspace kind. If the icon is stored in a ConfigMap, it serves the image content. If the icon is a remote URL, returns 404 (browser should fetch directly).", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Get workspace kind icon", + "operationId": "getWorkspaceKindIcon", + "parameters": [ + { + "type": "string", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SVG image content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found. Icon uses remote URL or resource does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, + "/workspacekinds/{name}/assets/logo.svg": { + "get": { + "description": "Returns the logo image for a specific workspace kind. If the logo is stored in a ConfigMap, it serves the image content. If the logo is a remote URL, returns 404 (browser should fetch directly).", + "consumes": [ + "application/json" + ], + "produces": [ + "image/svg+xml" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Get workspace kind logo", + "operationId": "getWorkspaceKindLogo", + "parameters": [ + { + "type": "string", + "description": "Name of the workspace kind", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SVG image content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found. Logo uses remote URL or resource does not exist.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } + } + }, "/workspaces": { "get": { "description": "Returns a list of all workspaces across all namespaces.", @@ -849,6 +939,35 @@ } } }, + "assets.ImageRef": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "error": { + "$ref": "#/definitions/assets.ImageRefErrorCode" + }, + "url": { + "type": "string" + } + } + }, + "assets.ImageRefErrorCode": { + "type": "string", + "enum": [ + "CONFIGMAP_MISSING", + "CONFIGMAP_KEY_MISSING", + "CONFIGMAP_UNKNOWN", + "UNKNOWN" + ], + "x-enum-varnames": [ + "ImageRefErrorCodeConfigMapMissing", + "ImageRefErrorCodeConfigMapKeyMissing", + "ImageRefErrorCodeConfigMapUnknown", + "ImageRefErrorCodeUnknown" + ] + }, "field.ErrorType": { "type": "string", "enum": [ @@ -978,17 +1097,6 @@ } } }, - "workspacekinds.ImageRef": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "workspacekinds.OptionLabel": { "type": "object", "required": [ @@ -1199,10 +1307,10 @@ "type": "boolean" }, "icon": { - "$ref": "#/definitions/workspacekinds.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "logo": { - "$ref": "#/definitions/workspacekinds.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "name": { "type": "string" @@ -1278,17 +1386,6 @@ } } }, - "workspaces.ImageRef": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string" - } - } - }, "workspaces.LastProbeInfo": { "type": "object", "required": [ @@ -1766,10 +1863,10 @@ ], "properties": { "icon": { - "$ref": "#/definitions/workspaces.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "logo": { - "$ref": "#/definitions/workspaces.ImageRef" + "$ref": "#/definitions/assets.ImageRef" }, "missing": { "type": "boolean" diff --git a/workspaces/controller/api/v1beta1/workspacekind_types.go b/workspaces/controller/api/v1beta1/workspacekind_types.go index 9bdd44da4..ec1635bbd 100644 --- a/workspaces/controller/api/v1beta1/workspacekind_types.go +++ b/workspaces/controller/api/v1beta1/workspacekind_types.go @@ -78,15 +78,15 @@ type WorkspaceKindSpawner struct { // the icon of the WorkspaceKind // - a small (favicon-sized) icon used in the Workspace Spawner UI - Icon WorkspaceKindIcon `json:"icon"` + Icon WorkspaceKindAsset `json:"icon"` // the logo of the WorkspaceKind // - a 1:1 (card size) logo used in the Workspace Spawner UI - Logo WorkspaceKindIcon `json:"logo"` + Logo WorkspaceKindAsset `json:"logo"` } // +kubebuilder:validation:XValidation:message="must specify exactly one of 'url' or 'configMap'",rule="!(has(self.url) && has(self.configMap)) && (has(self.url) || has(self.configMap))" -type WorkspaceKindIcon struct { +type WorkspaceKindAsset struct { // +kubebuilder:validation:Optional // +kubebuilder:example="https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png" Url *string `json:"url,omitempty"` @@ -101,6 +101,13 @@ type WorkspaceKindConfigMap struct { // +kubebuilder:example="apple-touch-icon-152x152.png" Key string `json:"key"` + + // the namespace of the ConfigMap + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=63 + // +kubebuilder:validation:Pattern:=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + // +kubebuilder:example="kubeflow" + Namespace string `json:"namespace"` } type WorkspaceKindPodTemplate struct { diff --git a/workspaces/controller/api/v1beta1/zz_generated.deepcopy.go b/workspaces/controller/api/v1beta1/zz_generated.deepcopy.go index c8e0d99ce..6af8aff14 100644 --- a/workspaces/controller/api/v1beta1/zz_generated.deepcopy.go +++ b/workspaces/controller/api/v1beta1/zz_generated.deepcopy.go @@ -559,6 +559,31 @@ func (in *WorkspaceKind) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceKindAsset) DeepCopyInto(out *WorkspaceKindAsset) { + *out = *in + if in.Url != nil { + in, out := &in.Url, &out.Url + *out = new(string) + **out = **in + } + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(WorkspaceKindConfigMap) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceKindAsset. +func (in *WorkspaceKindAsset) DeepCopy() *WorkspaceKindAsset { + if in == nil { + return nil + } + out := new(WorkspaceKindAsset) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceKindConfigMap) DeepCopyInto(out *WorkspaceKindConfigMap) { *out = *in @@ -600,31 +625,6 @@ func (in *WorkspaceKindCullingConfig) DeepCopy() *WorkspaceKindCullingConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WorkspaceKindIcon) DeepCopyInto(out *WorkspaceKindIcon) { - *out = *in - if in.Url != nil { - in, out := &in.Url, &out.Url - *out = new(string) - **out = **in - } - if in.ConfigMap != nil { - in, out := &in.ConfigMap, &out.ConfigMap - *out = new(WorkspaceKindConfigMap) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceKindIcon. -func (in *WorkspaceKindIcon) DeepCopy() *WorkspaceKindIcon { - if in == nil { - return nil - } - out := new(WorkspaceKindIcon) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceKindList) DeepCopyInto(out *WorkspaceKindList) { *out = *in diff --git a/workspaces/controller/config/crd/bases/kubeflow.org_workspacekinds.yaml b/workspaces/controller/config/crd/bases/kubeflow.org_workspacekinds.yaml index 1f0848945..99d79d2c5 100644 --- a/workspaces/controller/config/crd/bases/kubeflow.org_workspacekinds.yaml +++ b/workspaces/controller/config/crd/bases/kubeflow.org_workspacekinds.yaml @@ -4518,9 +4518,17 @@ spec: name: example: my-logos type: string + namespace: + description: the namespace of the ConfigMap + example: kubeflow + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string required: - key - name + - namespace type: object url: example: https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png @@ -4543,9 +4551,17 @@ spec: name: example: my-logos type: string + namespace: + description: the namespace of the ConfigMap + example: kubeflow + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string required: - key - name + - namespace type: object url: example: https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png diff --git a/workspaces/controller/config/samples/common/kustomization.yaml b/workspaces/controller/config/samples/common/kustomization.yaml index 376fa3448..f816d2618 100644 --- a/workspaces/controller/config/samples/common/kustomization.yaml +++ b/workspaces/controller/config/samples/common/kustomization.yaml @@ -2,4 +2,5 @@ resources: - workspace_data_pvc.yaml - workspace_home_pvc.yaml - workspace_secret.yaml -- workspace_service_account.yaml \ No newline at end of file +- workspace_service_account.yaml +- workspacekind_imagesource_configmap.yaml \ No newline at end of file diff --git a/workspaces/controller/config/samples/common/workspacekind_imagesource_configmap.yaml b/workspaces/controller/config/samples/common/workspacekind_imagesource_configmap.yaml new file mode 100644 index 000000000..cb8bce663 --- /dev/null +++ b/workspaces/controller/config/samples/common/workspacekind_imagesource_configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: workspacekind-image-source + labels: + notebooks.kubeflow.org/image-source: "true" +data: + custom-1: | + + + + + + + + + + + + + diff --git a/workspaces/controller/internal/controller/suite_test.go b/workspaces/controller/internal/controller/suite_test.go index fb441513d..1c6f7f172 100644 --- a/workspaces/controller/internal/controller/suite_test.go +++ b/workspaces/controller/internal/controller/suite_test.go @@ -204,14 +204,11 @@ func NewExampleWorkspaceKind1(name string) *kubefloworgv1beta1.WorkspaceKind { Hidden: ptr.To(false), Deprecated: ptr.To(false), DeprecationMessage: ptr.To("This WorkspaceKind will be removed on 20XX-XX-XX, please use another WorkspaceKind."), - Icon: kubefloworgv1beta1.WorkspaceKindIcon{ + Icon: kubefloworgv1beta1.WorkspaceKindAsset{ Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"), }, - Logo: kubefloworgv1beta1.WorkspaceKindIcon{ - ConfigMap: &kubefloworgv1beta1.WorkspaceKindConfigMap{ - Name: "my-logos", - Key: "apple-touch-icon-152x152.png", - }, + Logo: kubefloworgv1beta1.WorkspaceKindAsset{ + Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"), }, }, PodTemplate: kubefloworgv1beta1.WorkspaceKindPodTemplate{ diff --git a/workspaces/controller/internal/controller/workspacekind_controller_test.go b/workspaces/controller/internal/controller/workspacekind_controller_test.go index 93bad2cea..069f6d61d 100644 --- a/workspaces/controller/internal/controller/workspacekind_controller_test.go +++ b/workspaces/controller/internal/controller/workspacekind_controller_test.go @@ -116,11 +116,12 @@ var _ = Describe("WorkspaceKind Controller", func() { By("only allowing one of `spec.spawner.icon.{url,configMap}` to be set") newWorkspaceKind := workspaceKind.DeepCopy() - newWorkspaceKind.Spec.Spawner.Icon = kubefloworgv1beta1.WorkspaceKindIcon{ + newWorkspaceKind.Spec.Spawner.Icon = kubefloworgv1beta1.WorkspaceKindAsset{ Url: ptr.To("https://example.com/icon.png"), ConfigMap: &kubefloworgv1beta1.WorkspaceKindConfigMap{ - Name: "my-logos", - Key: "icon.png", + Name: "my-logos", + Key: "icon.png", + Namespace: namespaceName, }, } Expect(k8sClient.Patch(ctx, newWorkspaceKind, patch)).NotTo(Succeed()) diff --git a/workspaces/controller/internal/webhook/suite_test.go b/workspaces/controller/internal/webhook/suite_test.go index 8c0302cb6..83e4184d2 100644 --- a/workspaces/controller/internal/webhook/suite_test.go +++ b/workspaces/controller/internal/webhook/suite_test.go @@ -191,14 +191,11 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind { Hidden: ptr.To(false), Deprecated: ptr.To(false), DeprecationMessage: ptr.To("This WorkspaceKind will be removed on 20XX-XX-XX, please use another WorkspaceKind."), - Icon: kubefloworgv1beta1.WorkspaceKindIcon{ + Icon: kubefloworgv1beta1.WorkspaceKindAsset{ Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"), }, - Logo: kubefloworgv1beta1.WorkspaceKindIcon{ - ConfigMap: &kubefloworgv1beta1.WorkspaceKindConfigMap{ - Name: "my-logos", - Key: "apple-touch-icon-152x152.png", - }, + Logo: kubefloworgv1beta1.WorkspaceKindAsset{ + Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"), }, }, PodTemplate: kubefloworgv1beta1.WorkspaceKindPodTemplate{