Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (

MediaTypeJson = "application/json"
MediaTypeYaml = "application/yaml"
MediaTypeSVG = "image/svg+xml"

NamespacePathParam = "namespace"
ResourceNamePathParam = "name"
Expand All @@ -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"
Expand All @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions workspaces/backend/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions workspaces/backend/api/response_success.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 10 additions & 15 deletions workspaces/backend/api/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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() {
Expand Down Expand Up @@ -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{
Expand Down
153 changes: 153 additions & 0 deletions workspaces/backend/api/workspacekind_assets_handler.go
Original file line number Diff line number Diff line change
@@ -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))
}
2 changes: 2 additions & 0 deletions workspaces/backend/api/workspacekinds_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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))

Expand Down
27 changes: 17 additions & 10 deletions workspaces/backend/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading