Skip to content

Commit 37c0693

Browse files
committed
feat: serve workspacekind assets (logos + icons)
_WORK IN PROGRESS_ Signed-off-by: Andy Stoneberg <astonebe@redhat.com>
1 parent 3c1de72 commit 37c0693

File tree

21 files changed

+827
-81
lines changed

21 files changed

+827
-81
lines changed

workspaces/backend/api/app.go

Lines changed: 9 additions & 1 deletion
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 = PathPrefix + "/workspacekinds/:" + NamespacePathParam + "/:" + ResourceNamePathParam + "/assets"
61+
WorkspaceKindIconPath = WorkspaceKindsAssetsPath + "/icon.svg"
62+
WorkspaceKindLogoPath = WorkspaceKindsAssetsPath + "/logo.svg"
5963

6064
// namespaces
6165
AllNamespacesPath = PathPrefix + "/namespaces"
@@ -73,10 +77,11 @@ type App struct {
7377
StrictYamlSerializer runtime.Serializer
7478
RequestAuthN authenticator.Request
7579
RequestAuthZ authorizer.Authorizer
80+
ConfigMapClient client.Client // filtered cache client for ConfigMaps with notebooks.kubeflow.org/image-source: true
7681
}
7782

7883
// 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) {
84+
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) {
8085

8186
// TODO: log the configuration on startup
8287

@@ -95,6 +100,7 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme
95100
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
96101
RequestAuthN: reqAuthN,
97102
RequestAuthZ: reqAuthZ,
103+
ConfigMapClient: configMapClient,
98104
}
99105
return app, nil
100106
}
@@ -124,6 +130,8 @@ func (a *App) Routes() http.Handler {
124130
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
125131
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
126132
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
133+
router.GET(WorkspaceKindIconPath, a.GetWorkspaceKindIconHandler)
134+
router.GET(WorkspaceKindLogoPath, a.GetWorkspaceKindLogoHandler)
127135

128136
// swagger
129137
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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
corev1 "k8s.io/api/core/v1"
27+
apierrors "k8s.io/apimachinery/pkg/api/errors"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/util/validation/field"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
32+
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
33+
"github.com/kubeflow/notebooks/workspaces/backend/internal/helper"
34+
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds/assets"
35+
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
36+
)
37+
38+
// getWorkspaceKindAssetHandler is a helper function that handles common logic for retrieving
39+
// and serving workspace kind assets (icon or logo). It validates path parameters, performs
40+
// authentication, retrieves the asset, and serves it.
41+
func (a *App) getWorkspaceKindAssetHandler(
42+
w http.ResponseWriter,
43+
r *http.Request,
44+
ps httprouter.Params,
45+
getAsset func(icon, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset,
46+
) {
47+
namespace := ps.ByName(NamespacePathParam)
48+
name := ps.ByName(ResourceNamePathParam)
49+
50+
// validate path parameters
51+
var valErrs field.ErrorList
52+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(NamespacePathParam), namespace)...)
53+
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(field.NewPath(ResourceNamePathParam), name)...)
54+
if len(valErrs) > 0 {
55+
a.failedValidationResponse(w, r, errMsgPathParamsInvalid, valErrs, nil)
56+
return
57+
}
58+
59+
// =========================== AUTH ===========================
60+
authPolicies := []*auth.ResourcePolicy{
61+
auth.NewResourcePolicy(
62+
auth.ResourceVerbGet,
63+
&kubefloworgv1beta1.WorkspaceKind{
64+
ObjectMeta: metav1.ObjectMeta{Name: name},
65+
},
66+
),
67+
}
68+
if success := a.requireAuth(w, r, authPolicies); !success {
69+
return
70+
}
71+
// ============================================================
72+
73+
// Get both assets using the helper function
74+
icon, logo, err := a.repositories.WorkspaceKind.GetWorkspaceKindAssets(r.Context(), name)
75+
if err != nil {
76+
if errors.Is(err, repository.ErrWorkspaceKindNotFound) {
77+
a.notFoundResponse(w, r)
78+
return
79+
}
80+
a.serverErrorResponse(w, r, err)
81+
return
82+
}
83+
84+
// Get the appropriate asset (icon or logo) using the provided function
85+
asset := getAsset(icon, logo)
86+
87+
// Serve the asset
88+
a.serveWorkspaceKindAsset(w, r, asset)
89+
}
90+
91+
// GetWorkspaceKindIconHandler serves the icon image for a WorkspaceKind.
92+
//
93+
// @Summary Get workspace kind icon
94+
// @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).
95+
// @Tags workspacekinds
96+
// @ID getWorkspaceKindIcon
97+
// @Accept json
98+
// @Produce image/svg+xml
99+
// @Param namespace path string true "Namespace of the ConfigMap (if using ConfigMap source)"
100+
// @Param name path string true "Name of the workspace kind"
101+
// @Success 200 {string} string "SVG image content"
102+
// @Failure 404 {object} ErrorEnvelope "Not Found. Icon uses remote URL or resource does not exist."
103+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
104+
// @Router /workspacekinds/{namespace}/{name}/assets/icon.svg [get]
105+
func (a *App) GetWorkspaceKindIconHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
106+
a.getWorkspaceKindAssetHandler(w, r, ps, func(icon, _ models.WorkspaceKindAsset) models.WorkspaceKindAsset {
107+
return icon
108+
})
109+
}
110+
111+
// GetWorkspaceKindLogoHandler serves the logo image for a WorkspaceKind.
112+
//
113+
// @Summary Get workspace kind logo
114+
// @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).
115+
// @Tags workspacekinds
116+
// @ID getWorkspaceKindLogo
117+
// @Accept json
118+
// @Produce image/svg+xml
119+
// @Param namespace path string true "Namespace of the ConfigMap (if using ConfigMap source)"
120+
// @Param name path string true "Name of the workspace kind"
121+
// @Success 200 {string} string "SVG image content"
122+
// @Failure 404 {object} ErrorEnvelope "Not Found. Logo uses remote URL or resource does not exist."
123+
// @Failure 500 {object} ErrorEnvelope "Internal server error."
124+
// @Router /workspacekinds/{namespace}/{name}/assets/logo.svg [get]
125+
func (a *App) GetWorkspaceKindLogoHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
126+
a.getWorkspaceKindAssetHandler(w, r, ps, func(_, logo models.WorkspaceKindAsset) models.WorkspaceKindAsset {
127+
return logo
128+
})
129+
}
130+
131+
// serveWorkspaceKindAsset serves an icon or logo asset from a WorkspaceKind.
132+
// If the asset uses a remote URL, it returns 404 (browser should fetch directly).
133+
// If the asset uses a ConfigMap, it retrieves and serves the content with proper headers.
134+
func (a *App) serveWorkspaceKindAsset(w http.ResponseWriter, r *http.Request, asset models.WorkspaceKindAsset) {
135+
// If URL is set, return 404 - browser should fetch directly from source
136+
if asset.URL != nil && *asset.URL != "" {
137+
a.notFoundResponse(w, r)
138+
return
139+
}
140+
141+
// If ConfigMap is not set, return 404
142+
if asset.ConfigMap == nil {
143+
a.notFoundResponse(w, r)
144+
return
145+
}
146+
147+
configMapRef := asset.ConfigMap
148+
149+
// Get the ConfigMap using the filtered client (only ConfigMaps with notebooks.kubeflow.org/image-source: true)
150+
configMap := &corev1.ConfigMap{}
151+
err := a.ConfigMapClient.Get(r.Context(), client.ObjectKey{
152+
Namespace: configMapRef.Namespace,
153+
Name: configMapRef.Name,
154+
}, configMap)
155+
if err != nil {
156+
if apierrors.IsNotFound(err) {
157+
a.notFoundResponse(w, r)
158+
return
159+
}
160+
a.serverErrorResponse(w, r, fmt.Errorf("error retrieving ConfigMap: %w", err))
161+
return
162+
}
163+
164+
// Get the image content from the ConfigMap
165+
imageContent, exists := configMap.Data[configMapRef.Key]
166+
if !exists {
167+
// Try BinaryData as fallback
168+
// TODO: determine if we should support binary data
169+
if binaryData, exists := configMap.BinaryData[configMapRef.Key]; exists {
170+
imageContent = string(binaryData)
171+
} else {
172+
a.notFoundResponse(w, r)
173+
return
174+
}
175+
}
176+
177+
// Write the SVG response
178+
a.imageResponse(w, r, []byte(imageContent))
179+
}

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)