Skip to content

Commit 15f0998

Browse files
committed
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 <astonebe@redhat.com>
1 parent 3c1de72 commit 15f0998

File tree

27 files changed

+1046
-109
lines changed

27 files changed

+1046
-109
lines changed

workspaces/backend/api/app.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939

4040
MediaTypeJson = "application/json"
4141
MediaTypeYaml = "application/yaml"
42+
MediaTypeSVG = "image/svg+xml"
4243

4344
NamespacePathParam = "namespace"
4445
ResourceNamePathParam = "name"
@@ -56,6 +57,9 @@ const (
5657
// workspacekinds
5758
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
5859
WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + ResourceNamePathParam
60+
WorkspaceKindsAssetsPath = WorkspaceKindsByNamePath + "/assets"
61+
WorkspaceKindIconPath = WorkspaceKindsAssetsPath + "/icon.svg"
62+
WorkspaceKindLogoPath = WorkspaceKindsAssetsPath + "/logo.svg"
5963

6064
// namespaces
6165
AllNamespacesPath = PathPrefix + "/namespaces"
@@ -76,7 +80,7 @@ type App struct {
7680
}
7781

7882
// NewApp creates a new instance of the app
79-
func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) {
83+
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) {
8084

8185
// TODO: log the configuration on startup
8286

@@ -90,7 +94,7 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme
9094
app := &App{
9195
Config: cfg,
9296
logger: logger,
93-
repositories: repositories.NewRepositories(cl),
97+
repositories: repositories.NewRepositories(cl, configMapClient),
9498
Scheme: scheme,
9599
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
96100
RequestAuthN: reqAuthN,
@@ -124,6 +128,8 @@ func (a *App) Routes() http.Handler {
124128
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
125129
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
126130
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
131+
router.GET(WorkspaceKindIconPath, a.GetWorkspaceKindIconHandler)
132+
router.GET(WorkspaceKindLogoPath, a.GetWorkspaceKindLogoHandler)
127133

128134
// swagger
129135
router.GET(SwaggerPath, a.GetSwaggerHandler)

workspaces/backend/api/helpers.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
5757
return nil
5858
}
5959

60+
// WriteSVG writes an SVG response with the given status code, content, and headers.
61+
func (a *App) WriteSVG(w http.ResponseWriter, status int, content []byte, headers http.Header) error {
62+
for key, value := range headers {
63+
w.Header()[key] = value
64+
}
65+
66+
w.Header().Set("Content-Type", MediaTypeSVG)
67+
w.WriteHeader(status)
68+
_, err := w.Write(content)
69+
if err != nil {
70+
return err
71+
}
72+
73+
return nil
74+
}
75+
6076
// DecodeJSON decodes the JSON request body into the given value.
6177
func (a *App) DecodeJSON(r *http.Request, v any) error {
6278
decoder := json.NewDecoder(r.Body)

workspaces/backend/api/response_success.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ func (a *App) dataResponse(w http.ResponseWriter, r *http.Request, body any) {
2626
}
2727
}
2828

29+
// HTTP: 200
30+
// Note: SVG images are the only type of image that is served by this API.
31+
func (a *App) imageResponse(w http.ResponseWriter, r *http.Request, content []byte) {
32+
err := a.WriteSVG(w, http.StatusOK, content, nil)
33+
if err != nil {
34+
a.serverErrorResponse(w, r, err)
35+
}
36+
}
37+
2938
// HTTP: 201
3039
func (a *App) createdResponse(w http.ResponseWriter, r *http.Request, body any, location string) {
3140
w.Header().Set("Location", location)

workspaces/backend/api/suite_test.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,14 @@ import (
3535
"k8s.io/client-go/kubernetes/scheme"
3636
"k8s.io/client-go/rest"
3737
"k8s.io/utils/ptr"
38-
ctrl "sigs.k8s.io/controller-runtime"
3938
"sigs.k8s.io/controller-runtime/pkg/client"
4039
"sigs.k8s.io/controller-runtime/pkg/envtest"
4140
logf "sigs.k8s.io/controller-runtime/pkg/log"
4241
"sigs.k8s.io/controller-runtime/pkg/log/zap"
43-
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
4442

4543
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
4644
"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
45+
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
4746
)
4847

4948
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
@@ -128,12 +127,7 @@ var _ = BeforeSuite(func() {
128127
})).To(Succeed())
129128

130129
By("setting up the controller manager")
131-
k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
132-
Scheme: scheme.Scheme,
133-
Metrics: metricsserver.Options{
134-
BindAddress: "0", // disable metrics serving
135-
},
136-
})
130+
k8sManager, err := helper.NewManager(cfg, scheme.Scheme)
137131
Expect(err).NotTo(HaveOccurred())
138132

139133
By("initializing the application logger")
@@ -147,9 +141,13 @@ var _ = BeforeSuite(func() {
147141
reqAuthZ, err := auth.NewRequestAuthorizer(k8sManager.GetConfig(), k8sManager.GetHTTPClient())
148142
Expect(err).NotTo(HaveOccurred())
149143

144+
By("creating the image source ConfigMap client")
145+
imageSourceConfigMapClient, err := helper.BuildImageSourceConfigMapClient(k8sManager)
146+
Expect(err).NotTo(HaveOccurred())
147+
150148
By("creating the application")
151149
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
152-
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
150+
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, imageSourceConfigMapClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
153151
Expect(err).NotTo(HaveOccurred())
154152

155153
go func() {
@@ -217,13 +215,14 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
217215
Hidden: ptr.To(false),
218216
Deprecated: ptr.To(false),
219217
DeprecationMessage: ptr.To("This WorkspaceKind will be removed on 20XX-XX-XX, please use another WorkspaceKind."),
220-
Icon: kubefloworgv1beta1.WorkspaceKindIcon{
218+
Icon: kubefloworgv1beta1.WorkspaceKindAsset{
221219
Url: ptr.To("https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png"),
222220
},
223-
Logo: kubefloworgv1beta1.WorkspaceKindIcon{
221+
Logo: kubefloworgv1beta1.WorkspaceKindAsset{
224222
ConfigMap: &kubefloworgv1beta1.WorkspaceKindConfigMap{
225-
Name: "my-logos",
226-
Key: "apple-touch-icon-152x152.png",
223+
Name: "my-logos",
224+
Key: "apple-touch-icon-152x152.png",
225+
Namespace: "default",
227226
},
228227
},
229228
},
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
24+
"github.com/julienschmidt/httprouter"
25+
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/util/validation/field"
29+
30+
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
31+
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
32+
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds/assets"
33+
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
34+
)
35+
36+
// getWorkspaceKindAssetHandler is a helper function that handles common logic for retrieving
37+
// and serving workspace kind assets (icon or logo). It validates path parameters, performs
38+
// authentication, retrieves the asset, and serves it.
39+
func (a *App) getWorkspaceKindAssetHandler(
40+
w http.ResponseWriter,
41+
r *http.Request,
42+
ps httprouter.Params,
43+
getAsset func(icon, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset,
44+
) {
45+
name := ps.ByName(ResourceNamePathParam)
46+
47+
// validate path parameters
48+
var valErrs field.ErrorList
49+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), name)...)
50+
if len(valErrs) > 0 {
51+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
52+
return
53+
}
54+
55+
// =========================== AUTH ===========================
56+
authPolicies := []*auth.ResourcePolicy{
57+
auth.NewResourcePolicy(
58+
auth.ResourceVerbGet,
59+
&kubefloworgv1beta1.WorkspaceKind{
60+
ObjectMeta: metav1.ObjectMeta{Name: name},
61+
},
62+
),
63+
}
64+
if success := a.requireAuth(w, r, authPolicies); !success {
65+
return
66+
}
67+
// ============================================================
68+
69+
// Get both assets using the helper function
70+
icon, logo, err := a.repositories.WorkspaceKind.GetWorkspaceKindAssets(r.Context(), name)
71+
if err != nil {
72+
if errors.Is(err, repository.ErrWorkspaceKindNotFound) {
73+
a.notFoundResponse(w, r)
74+
return
75+
}
76+
a.serverErrorResponse(w, r, err)
77+
return
78+
}
79+
80+
// Get the appropriate asset (icon or logo) using the provided function
81+
asset := getAsset(icon, logo)
82+
83+
// Serve the asset
84+
a.serveWorkspaceKindAsset(w, r, asset)
85+
}
86+
87+
// GetWorkspaceKindIconHandler serves the icon image for a WorkspaceKind.
88+
//
89+
// @Summary Get workspace kind icon
90+
// @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).
91+
// @Tags workspacekinds
92+
// @ID getWorkspaceKindIcon
93+
// @Accept json
94+
// @Produce image/svg+xml
95+
// @Param name path string true "Name of the workspace kind"
96+
// @Success 200 {string} string "SVG image content"
97+
// @Failure 404 {object} ErrorEnvelope "Not Found. Icon uses remote URL or resource does not exist."
98+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
99+
// @Router /workspacekinds/{name}/assets/icon.svg [get]
100+
func (a *App) GetWorkspaceKindIconHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
101+
a.getWorkspaceKindAssetHandler(w, r, ps, func(icon, _ models.WorkspaceKindAsset) models.WorkspaceKindAsset {
102+
return icon
103+
})
104+
}
105+
106+
// GetWorkspaceKindLogoHandler serves the logo image for a WorkspaceKind.
107+
//
108+
// @Summary Get workspace kind logo
109+
// @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).
110+
// @Tags workspacekinds
111+
// @ID getWorkspaceKindLogo
112+
// @Accept json
113+
// @Produce image/svg+xml
114+
// @Param name path string true "Name of the workspace kind"
115+
// @Success 200 {string} string "SVG image content"
116+
// @Failure 404 {object} ErrorEnvelope "Not Found. Logo uses remote URL or resource does not exist."
117+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
118+
// @Router /workspacekinds/{name}/assets/logo.svg [get]
119+
func (a *App) GetWorkspaceKindLogoHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
120+
a.getWorkspaceKindAssetHandler(w, r, ps, func(_, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset {
121+
return logo
122+
})
123+
}
124+
125+
// serveWorkspaceKindAsset serves an icon or logo asset from a WorkspaceKind.
126+
// If the asset uses a remote URL, it returns 404 (browser should fetch directly).
127+
// If the asset uses a ConfigMap, it retrieves and serves the content with proper headers.
128+
func (a *App) serveWorkspaceKindAsset(w http.ResponseWriter, r *http.Request, asset models.WorkspaceKindAsset) {
129+
// If URL is set, return 404 - browser should fetch directly from source
130+
if asset.URL != nil && *asset.URL != "" {
131+
a.notFoundResponse(w, r)
132+
return
133+
}
134+
135+
// If ConfigMap is not set, return 404
136+
if asset.ConfigMap == nil {
137+
a.notFoundResponse(w, r)
138+
return
139+
}
140+
141+
imageContent, err := a.repositories.WorkspaceKind.GetConfigMapContent(r.Context(), asset)
142+
if err != nil {
143+
// TODO: determine how to handle the CONFIGMAP_MISSING error
144+
if apierrors.IsNotFound(err) {
145+
a.notFoundResponse(w, r)
146+
return
147+
}
148+
a.serverErrorResponse(w, r, fmt.Errorf("error retrieving ConfigMap content: %w", err))
149+
return
150+
}
151+
152+
// Write the SVG response
153+
a.imageResponse(w, r, []byte(imageContent))
154+
}

workspaces/backend/api/workspacekinds_handler_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
137137
Expect(k8sClient.Get(ctx, workspaceKind2Key, workspacekind2)).To(Succeed())
138138

139139
By("ensuring the response contains the expected WorkspaceKinds")
140+
// TODO: determine if/how we want to handle SHA256 hashes for ConfigMap-based assets in the tests
140141
Expect(response.Data).To(ConsistOf(
141142
models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1),
142143
models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind2),
@@ -185,6 +186,7 @@ var _ = Describe("WorkspaceKinds Handler", func() {
185186
Expect(k8sClient.Get(ctx, workspaceKind1Key, workspacekind1)).To(Succeed())
186187

187188
By("ensuring the response matches the expected WorkspaceKind")
189+
// TODO: determine if/how we want to handle SHA256 hashes for ConfigMap-based assets in the tests
188190
expectedWorkspaceKind := models.NewWorkspaceKindModelFromWorkspaceKind(workspacekind1)
189191
Expect(response.Data).To(BeComparableTo(expectedWorkspaceKind))
190192

workspaces/backend/cmd/main.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"strconv"
2424

2525
ctrl "sigs.k8s.io/controller-runtime"
26-
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
2726

2827
application "github.com/kubeflow/notebooks/workspaces/backend/api"
2928
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
@@ -113,14 +112,7 @@ func main() {
113112
}
114113

115114
// Create the controller manager
116-
mgr, err := ctrl.NewManager(kubeconfig, ctrl.Options{
117-
Scheme: scheme,
118-
Metrics: metricsserver.Options{
119-
BindAddress: "0", // disable metrics serving
120-
},
121-
HealthProbeBindAddress: "0", // disable health probe serving
122-
LeaderElection: false,
123-
})
115+
mgr, err := helper.NewManager(kubeconfig, scheme)
124116
if err != nil {
125117
logger.Error("unable to create manager", "error", err)
126118
os.Exit(1)
@@ -139,8 +131,23 @@ func main() {
139131
logger.Error("failed to create request authorizer", "error", err)
140132
}
141133

134+
// Create a filtered cache client for ConfigMaps with the label notebooks.kubeflow.org/image-source: true
135+
imageSourceConfigMapClient, err := helper.BuildImageSourceConfigMapClient(mgr)
136+
if err != nil {
137+
logger.Error("failed to create image source ConfigMap client", "error", err)
138+
os.Exit(1)
139+
}
140+
142141
// Create the application and server
143-
app, err := application.NewApp(cfg, logger, mgr.GetClient(), mgr.GetScheme(), reqAuthN, reqAuthZ)
142+
app, err := application.NewApp(
143+
cfg,
144+
logger,
145+
mgr.GetClient(),
146+
imageSourceConfigMapClient,
147+
mgr.GetScheme(),
148+
reqAuthN,
149+
reqAuthZ,
150+
)
144151
if err != nil {
145152
logger.Error("failed to create app", "error", err)
146153
os.Exit(1)

0 commit comments

Comments
 (0)