diff --git a/clients/ui/bff/docs/extensions.md b/clients/ui/bff/docs/extensions.md new file mode 100644 index 0000000000..7366907f8c --- /dev/null +++ b/clients/ui/bff/docs/extensions.md @@ -0,0 +1,397 @@ +# BFF Handler Extensions + +Some downstream builds need to add behavior that does not belong upstream yet. Instead of maintaining long-lived forks, the BFF exposes a simple extension registry so downstream code can override individual handlers while reusing the rest of the stack. + +## Table of Contents + +- [Core Concepts](#core-concepts) +- [Ownership Boundaries](#ownership-boundaries) +- [Available App Methods](#available-app-methods) +- [Step-by-Step: Overriding an Existing Handler](#step-by-step-overriding-an-existing-handler) +- [Step-by-Step: Adding a Downstream-Only Route](#step-by-step-adding-a-downstream-only-route) +- [Accessing the Kubernetes Client](#accessing-the-kubernetes-client) +- [Importing Upstream Types](#importing-upstream-types) +- [Testing Downstream Overrides](#testing-downstream-overrides) +- [Current Handler ID Coverage](#current-handler-id-coverage) + +--- + +## Core Concepts + +- **Handler IDs** – Each overridable endpoint has a stable `HandlerID` string constant defined upstream in `internal/api/app.go`. Downstream code references these IDs to register overrides. +- **Handler Factories** – Downstream packages call `api.RegisterHandlerOverride(id, factory)` inside `init()`. Factories receive the `*api.App` plus a `buildDefault` function, so you can either replace the handler entirely or fall back to upstream logic conditionally. +- **Downstream-Only Routes** – Some endpoints exist upstream only as stubs returning 501 Not Implemented. These are designed to be fully implemented downstream. The route is wired upstream, but the real logic lives in the override. +- **Dependencies** – The `App` exposes read-only accessors for configuration, repositories, and the Kubernetes client factory. It also exposes helper methods such as `BadRequest`, `ServerError`, and `WriteJSON` so overrides can follow the same conventions as upstream handlers. + +--- + +## Ownership Boundaries + +- **Upstream-only artifacts** live under `internal/api`, `internal/repositories`, and the default handler tree. These packages must remain vendor-neutral and keep their existing contracts intact so downstream imports keep compiling. +- **Downstream-only artifacts** live under `clients/ui/bff/internal//` (e.g., `internal/redhat/`). Any logic that assumes vendor-specific credentials, namespaces, or controllers must stay here so other distributions do not pick it up accidentally. +- **Shared interfaces** (for example, repository interfaces or the handler override registry itself) stay upstream. Only implementations specific to a vendor move downstream. + +**Rule of thumb:** If a change requires vendor-only RBAC, Kubernetes resources, or APIs invisible to open-source users, keep it downstream. Everything else should be proposed upstream. + +--- + +## Available App Methods + +The `*api.App` instance provides these exported methods for use in downstream handlers: + +| Method | Description | +|--------|-------------| +| `app.Config()` | Returns `config.EnvConfig` with deployment settings | +| `app.Logger()` | Returns `*slog.Logger` for structured logging | +| `app.KubernetesClientFactory()` | Returns factory to build Kubernetes clients | +| `app.Repositories()` | Returns `*repositories.Repositories` for data access | +| `app.WriteJSON(w, status, data, headers)` | Writes JSON response with proper content-type | +| `app.ReadJSON(w, r, dst)` | Parses JSON request body into destination struct | +| `app.BadRequest(w, r, err)` | Returns HTTP 400 with error message | +| `app.ServerError(w, r, err)` | Returns HTTP 500 with error message | +| `app.NotImplemented(w, r, feature)` | Returns HTTP 501 for unimplemented features | +| `app.AttachNamespace(handler)` | Middleware that extracts `namespace` query param into context | + +--- + +## Step-by-Step: Overriding an Existing Handler + +This workflow overrides an upstream handler with downstream-specific logic. + +### 1. Add the blank import in `main.go` + +In `clients/ui/bff/cmd/main.go`, add a blank import for your handlers package: + +```go +import ( + // ... other imports ... + + // Import downstream handlers to register overrides via init() + _ "github.com/kubeflow/model-registry/ui/bff/internal/redhat/handlers" +) +``` + +### 2. Locate the Handler ID upstream + +Handler IDs are defined in `internal/api/app.go`. For example, Model Registry Settings endpoints: + +```go +const ( + handlerModelRegistrySettingsListID HandlerID = "modelRegistrySettings:list" + handlerModelRegistrySettingsCreateID HandlerID = "modelRegistrySettings:create" + handlerModelRegistrySettingsGetID HandlerID = "modelRegistrySettings:get" + handlerModelRegistrySettingsUpdateID HandlerID = "modelRegistrySettings:update" + handlerModelRegistrySettingsDeleteID HandlerID = "modelRegistrySettings:delete" +) +``` + +### 3. Create the downstream handler file + +Create a file in `internal//handlers/` (e.g., `internal/redhat/handlers/my_handler.go`): + +```go +package handlers + +import ( + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + + "github.com/kubeflow/model-registry/ui/bff/internal/api" + "github.com/kubeflow/model-registry/ui/bff/internal/constants" +) + +// Mirror the upstream handler ID string +const myHandlerID = api.HandlerID("modelRegistrySettings:list") + +func init() { + api.RegisterHandlerOverride(myHandlerID, myOverrideFactory) +} + +func myOverrideFactory(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { + // Optionally fall back to upstream when mocking + if app.Config().MockK8Client { + return buildDefault() + } + + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.BadRequest(w, r, fmt.Errorf("missing namespace")) + return + } + + // Your downstream implementation here + resp := api.ModelRegistrySettingsListEnvelope{Data: nil} + if err := app.WriteJSON(w, http.StatusOK, resp, nil); err != nil { + app.ServerError(w, r, err) + } + }) +} +``` + +--- + +## Step-by-Step: Adding a Downstream-Only Route + +For routes that have **no real upstream implementation** (upstream returns 501 Not Implemented), use this pattern. + +### 1. Upstream defines the route and stub handler + +In `internal/api/app.go`, the route is defined with a handler that returns 501: + +```go +// Path constant +const KubernetesServicesListPath = SettingsPath + "/services" + +// Handler ID +const handlerKubernetesServicesListID HandlerID = "kubernetes:services:list" + +// Route registration in Routes() +apiRouter.GET( + KubernetesServicesListPath, + app.handlerWithOverride(handlerKubernetesServicesListID, func() httprouter.Handle { + return app.AttachNamespace(app.kubernetesServicesNotImplementedHandler) + }), +) +``` + +The stub handler in `internal/api/kubernetes_resources_handler.go`: + +```go +// Generic handler for endpoints not implemented upstream +func (app *App) endpointNotImplementedHandler(feature string) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + app.NotImplemented(w, r, feature) + } +} +``` + +### 2. Downstream provides the real implementation + +In `internal/redhat/handlers/kubernetes_services.go`: + +```go +package handlers + +import ( + "fmt" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/kubeflow/model-registry/ui/bff/internal/api" + "github.com/kubeflow/model-registry/ui/bff/internal/constants" +) + +const kubernetesServicesListHandlerID = api.HandlerID("kubernetes:services:list") + +func init() { + api.RegisterHandlerOverride(kubernetesServicesListHandlerID, overrideKubernetesServicesList) +} + +func overrideKubernetesServicesList(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { + // Fall back to stub when K8s client is mocked + if app.Config().MockK8Client { + return buildDefault() + } + + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) + if !ok || namespace == "" { + app.BadRequest(w, r, fmt.Errorf("missing namespace")) + return + } + + client, err := app.KubernetesClientFactory().GetClient(r.Context()) + if err != nil { + app.ServerError(w, r, fmt.Errorf("failed to get client: %w", err)) + return + } + + // Real implementation using the Kubernetes client + services, err := client.GetServiceDetails(r.Context(), namespace) + if err != nil { + app.ServerError(w, r, err) + return + } + + // Build and return response + items := make([]api.KubernetesServiceItem, 0, len(services)) + for _, svc := range services { + items = append(items, api.KubernetesServiceItem{ + Name: svc.Name, + Namespace: namespace, + }) + } + + resp := api.KubernetesServicesListEnvelope{Data: items} + if err := app.WriteJSON(w, http.StatusOK, resp, nil); err != nil { + app.ServerError(w, r, err) + } + }) +} +``` + +--- + +## Accessing the Kubernetes Client + +To interact with Kubernetes resources in your handler: + +```go +func myHandler(app *api.App) httprouter.Handle { + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + // Get the Kubernetes client from the factory + client, err := app.KubernetesClientFactory().GetClient(r.Context()) + if err != nil { + app.ServerError(w, r, fmt.Errorf("failed to get Kubernetes client: %w", err)) + return + } + + // Use the client to interact with Kubernetes + // The client interface is defined in internal/integrations/kubernetes + namespace := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) + + // Example: Get service details + services, err := client.GetServiceDetails(r.Context(), namespace) + if err != nil { + app.ServerError(w, r, err) + return + } + + // Process services... + }) +} +``` + +The `KubernetesClientInterface` provides methods for common operations. Check `internal/integrations/kubernetes/client.go` for available methods. + +--- + +## Importing Upstream Types + +When building downstream handlers, you'll commonly import these packages: + +```go +import ( + // Core API types and App + "github.com/kubeflow/model-registry/ui/bff/internal/api" + + // Configuration types + "github.com/kubeflow/model-registry/ui/bff/internal/config" + + // Context keys for namespace, identity, etc. + "github.com/kubeflow/model-registry/ui/bff/internal/constants" + + // Kubernetes client interface + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes" + + // Data models + "github.com/kubeflow/model-registry/ui/bff/internal/models" + + // Upstream repositories (if needed) + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" + + // Router + "github.com/julienschmidt/httprouter" +) +``` + +**Commonly used types from `api` package:** + +- `api.App` – Main application instance +- `api.HandlerID` – Type for handler identifiers +- `api.HandlerFactory` – Signature for override factories +- `api.RegisterHandlerOverride()` – Register an override +- Envelope types like `api.ModelRegistrySettingsListEnvelope`, `api.KubernetesServicesListEnvelope` + +--- + +## Testing Downstream Overrides + +Keep unit tests next to your override implementations in the downstream package. + +### Testing with mocked Kubernetes client + +When `MockK8Client=true`, your handlers should fall back to upstream stubs or return mock data: + +```go +func TestMyHandler_MockMode(t *testing.T) { + // Create app with mocked K8s client + cfg := config.EnvConfig{ + MockK8Client: true, + } + + // Your handler factory should return buildDefault() or mock behavior + // when app.Config().MockK8Client is true +} +``` + +### Pattern for conditional override activation + +```go +func shouldUseDownstreamOverrides(app *api.App) bool { + if app == nil { + return false + } + // When K8s client is mocked, use upstream stub handlers + return !app.Config().MockK8Client +} + +func myOverrideFactory(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { + // Fall back to upstream stub when K8s is mocked + if !shouldUseDownstreamOverrides(app) { + return buildDefault() + } + + // Real implementation + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + // ... + } +} +``` + +### Test file structure + +``` +internal/redhat/handlers/ +├── model_registry_settings.go +├── model_registry_settings_test.go +├── kubernetes_services.go +└── kubernetes_services_test.go +``` + +--- + +## Current Handler ID Coverage + +These handler IDs are currently wired with `handlerWithOverride()` upstream: + +| Handler ID | HTTP Method | Path | Description | +|------------|-------------|------|-------------| +| `modelRegistrySettings:list` | GET | `/api/v1/settings/model_registry` | List all model registries | +| `modelRegistrySettings:create` | POST | `/api/v1/settings/model_registry` | Create a model registry | +| `modelRegistrySettings:get` | GET | `/api/v1/settings/model_registry/:model_registry_id` | Get a model registry | +| `modelRegistrySettings:update` | PATCH | `/api/v1/settings/model_registry/:model_registry_id` | Update a model registry | +| `modelRegistrySettings:delete` | DELETE | `/api/v1/settings/model_registry/:model_registry_id` | Delete a model registry | +| `kubernetes:services:list` | GET | `/api/v1/settings/services` | List Kubernetes services (downstream-only) | + +--- + +## Change Workflow Summary + +### To override an existing handler: + +1. Find the handler ID in `internal/api/app.go` +2. Create handler file in `internal//handlers/` +3. Register override in `init()` with `api.RegisterHandlerOverride()` +4. Add blank import in `cmd/main.go` (if not already present) + +### To add a downstream-only route: + +1. Define route path constant and handler ID in `internal/api/app.go` +2. Create stub handler returning 501 in `internal/api/` +3. Wire route with `handlerWithOverride()` in `Routes()` +4. Implement real handler in `internal//handlers/` +5. Register override in `init()` with `api.RegisterHandlerOverride()` diff --git a/clients/ui/bff/internal/api/extensions.go b/clients/ui/bff/internal/api/extensions.go new file mode 100644 index 0000000000..7b90895ccf --- /dev/null +++ b/clients/ui/bff/internal/api/extensions.go @@ -0,0 +1,58 @@ +package api + +import ( + "log/slog" + "sync" + + "github.com/julienschmidt/httprouter" +) + +// HandlerID identifies an overridable HTTP handler. +// TODO(upstream): Keep this type exported so downstream code can reference arbitrary handler keys without +// requiring upstream to enumerate them. +type HandlerID string + +// HandlerFactory builds a router handler that has access to the App instance. +// Implementations can opt to call buildDefault() to reuse the default upstream handler. +type HandlerFactory func(app *App, buildDefault func() httprouter.Handle) httprouter.Handle + +var ( + handlerOverrideMu sync.RWMutex + handlerOverrides = map[HandlerID]HandlerFactory{} +) + +// RegisterHandlerOverride allows downstream code to override a specific handler. +// TODO(downstream): Call this from vendor packages (e.g., internal/redhat/handlers) to inject custom behavior. +// Calling this function multiple times for the same id will replace the previous override. +func RegisterHandlerOverride(id HandlerID, factory HandlerFactory) { + handlerOverrideMu.Lock() + defer handlerOverrideMu.Unlock() + handlerOverrides[id] = factory +} + +//nolint:unused // Used by downstream implementations +func getHandlerOverride(id HandlerID) HandlerFactory { + handlerOverrideMu.RLock() + defer handlerOverrideMu.RUnlock() + return handlerOverrides[id] +} + +// handlerWithOverride returns the handler registered for the given id or builds the default one. +// TODO(upstream): This glue stays upstream so the router keeps working even when no downstream overrides exist. +// +//nolint:unused // Used by downstream implementations +func (app *App) handlerWithOverride(id HandlerID, buildDefault func() httprouter.Handle) httprouter.Handle { + if factory := getHandlerOverride(id); factory != nil { + app.logHandlerOverride(id) + return factory(app, buildDefault) + } + return buildDefault() +} + +//nolint:unused // Used by downstream implementations +func (app *App) logHandlerOverride(id HandlerID) { + if app == nil || app.logger == nil { + return + } + app.logger.Debug("Using handler override", slog.String("handlerID", string(id))) +} diff --git a/clients/ui/bff/internal/api/model_registry_settings_handler.go b/clients/ui/bff/internal/api/model_registry_settings_handler.go index e991e6b1fe..82792c5a6c 100644 --- a/clients/ui/bff/internal/api/model_registry_settings_handler.go +++ b/clients/ui/bff/internal/api/model_registry_settings_handler.go @@ -36,17 +36,17 @@ func (app *App) GetAllModelRegistriesSettingsHandler(w http.ResponseWriter, r *h Key: "ssl-secret-key", } - registries := []models.ModelRegistryKind{createSampleModelRegistry("model-registry", namespace, &sslRootCertificateConfigMap, nil), + registries := []models.ModelRegistryKind{ + createSampleModelRegistry("model-registry", namespace, &sslRootCertificateConfigMap, nil), createSampleModelRegistry("model-registry-dora", namespace, nil, &sslRootCertificateSecret), - createSampleModelRegistry("model-registry-bella", namespace, nil, nil)} + createSampleModelRegistry("model-registry-bella", namespace, nil, nil), + } modelRegistryRes := ModelRegistrySettingsListEnvelope{ Data: registries, } - err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) - - if err != nil { + if err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil); err != nil { app.serverErrorResponse(w, r, err) } @@ -73,9 +73,7 @@ func (app *App) GetModelRegistrySettingsHandler(w http.ResponseWriter, r *http.R Data: modelRegistryWithCreds, } - err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) - - if err != nil { + if err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil); err != nil { app.serverErrorResponse(w, r, err) } } @@ -94,18 +92,7 @@ func (app *App) CreateModelRegistrySettingsHandler(w http.ResponseWriter, r *htt app.serverErrorResponse(w, r, fmt.Errorf("error decoding JSON:: %v", err.Error())) return } - - var modelRegistryName = envelope.Data.ModelRegistry.Metadata.Name - - if modelRegistryName == "" { - app.badRequestResponse(w, r, fmt.Errorf("model registry name is required")) - return - } - - ctxLogger.Info("Creating model registry", "name", modelRegistryName) - - // For now, we're using the stub implementation, but we'd use envelope.Data.ModelRegistry - // and other fields from the payload in a real implementation + modelRegistryName := envelope.Data.ModelRegistry.Metadata.Name registry := createSampleModelRegistry(modelRegistryName, namespace, nil, nil) modelRegistryRes := ModelRegistrySettingsEnvelope{ @@ -113,12 +100,10 @@ func (app *App) CreateModelRegistrySettingsHandler(w http.ResponseWriter, r *htt } w.Header().Set("Location", r.URL.JoinPath(modelRegistryRes.Data.Metadata.Name).String()) - writeErr := app.WriteJSON(w, http.StatusCreated, modelRegistryRes, nil) - if writeErr != nil { + if err := app.WriteJSON(w, http.StatusCreated, modelRegistryRes, nil); err != nil { app.serverErrorResponse(w, r, fmt.Errorf("error writing JSON")) return } - } func (app *App) UpdateModelRegistrySettingsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -137,8 +122,7 @@ func (app *App) UpdateModelRegistrySettingsHandler(w http.ResponseWriter, r *htt Data: registry, } - err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) - if err != nil { + if err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil); err != nil { app.serverErrorResponse(w, r, fmt.Errorf("error writing JSON")) return } @@ -161,9 +145,7 @@ func (app *App) DeleteModelRegistrySettingsHandler(w http.ResponseWriter, r *htt Data: registry, } - err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil) - - if err != nil { + if err := app.WriteJSON(w, http.StatusOK, modelRegistryRes, nil); err != nil { app.serverErrorResponse(w, r, err) } diff --git a/clients/ui/bff/internal/api/public_helpers.go b/clients/ui/bff/internal/api/public_helpers.go new file mode 100644 index 0000000000..7254cc8f89 --- /dev/null +++ b/clients/ui/bff/internal/api/public_helpers.go @@ -0,0 +1,64 @@ +package api + +import ( + "fmt" + "net/http" + + "log/slog" + + "github.com/julienschmidt/httprouter" + "github.com/kubeflow/model-registry/ui/bff/internal/config" + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes" + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" +) + +// BadRequest exposes the internal bad request helper for extensions. +func (app *App) BadRequest(w http.ResponseWriter, r *http.Request, err error) { + if app == nil { + return + } + app.badRequestResponse(w, r, err) +} + +// ServerError exposes the internal server error helper for extensions. +func (app *App) ServerError(w http.ResponseWriter, r *http.Request, err error) { + if app == nil { + return + } + app.serverErrorResponse(w, r, err) +} + +// NotImplemented writes a standard placeholder response for unimplemented endpoints. +func (app *App) NotImplemented(w http.ResponseWriter, r *http.Request, feature string) { + app.serverErrorResponse(w, r, fmt.Errorf("%s is not implemented", feature)) +} + +// EndpointNotImplementedHandler returns a generic 501 Not Implemented handler. +// Use this for endpoints that are defined upstream but require a downstream override to function. +// Downstream packages must register an override via api.RegisterHandlerOverride() to provide +// the real implementation. +func (app *App) EndpointNotImplementedHandler(feature string) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + app.NotImplemented(w, r, feature) + } +} + +// Config exposes the application configuration for extensions. +func (app *App) Config() config.EnvConfig { + return app.config +} + +// Logger exposes the application logger for extensions. +func (app *App) Logger() *slog.Logger { + return app.logger +} + +// KubernetesClientFactory exposes the k8s factory for extensions. +func (app *App) KubernetesClientFactory() k8s.KubernetesClientFactory { + return app.kubernetesClientFactory +} + +// Repositories exposes the repositories container for extensions. +func (app *App) Repositories() *repositories.Repositories { + return app.repositories +} diff --git a/clients/ui/bff/internal/api/test_app.go b/clients/ui/bff/internal/api/test_app.go new file mode 100644 index 0000000000..776c0f3650 --- /dev/null +++ b/clients/ui/bff/internal/api/test_app.go @@ -0,0 +1,25 @@ +package api + +import ( + "io" + "log/slog" + + "github.com/kubeflow/model-registry/ui/bff/internal/config" + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes" + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" +) + +// NewTestApp exposes a minimal constructor that allows tests and downstream +// extensions to configure specific App dependencies without invoking the +// production bootstrap logic. +func NewTestApp(cfg config.EnvConfig, logger *slog.Logger, factory k8s.KubernetesClientFactory, repos *repositories.Repositories) *App { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return &App{ + config: cfg, + logger: logger, + kubernetesClientFactory: factory, + repositories: repos, + } +} diff --git a/clients/ui/bff/internal/integrations/kubernetes/factory.go b/clients/ui/bff/internal/integrations/kubernetes/factory.go index e99a8f54bd..d3d4e0d3a1 100644 --- a/clients/ui/bff/internal/integrations/kubernetes/factory.go +++ b/clients/ui/bff/internal/integrations/kubernetes/factory.go @@ -4,31 +4,13 @@ import ( "context" "errors" "fmt" - "github.com/kubeflow/model-registry/ui/bff/internal/config" - "github.com/kubeflow/model-registry/ui/bff/internal/constants" "log/slog" "net/http" "strings" -) - -func NewKubernetesClientFactory(cfg config.EnvConfig, logger *slog.Logger) (KubernetesClientFactory, error) { - switch cfg.AuthMethod { - case config.AuthMethodInternal: - k8sFactory, err := NewStaticClientFactory(logger) - if err != nil { - return nil, fmt.Errorf("failed to create static client factory: %w", err) - } - return k8sFactory, nil - - case config.AuthMethodUser: - k8sFactory := NewTokenClientFactory(logger, cfg) - return k8sFactory, nil - - default: - return nil, fmt.Errorf("invalid auth method: %q", cfg.AuthMethod) - } -} + "github.com/kubeflow/model-registry/ui/bff/internal/config" + "github.com/kubeflow/model-registry/ui/bff/internal/constants" +) // ─── STATIC FACTORY (INTERNAL) ────────────────────────────────────────── // uses the credentials of the running backend to create a single instance of the client @@ -102,16 +84,18 @@ func (f *StaticClientFactory) ValidateRequestIdentity(identity *RequestIdentity) // type TokenClientFactory struct { - Logger *slog.Logger - Header string - Prefix string + Logger *slog.Logger + Header string + Prefix string + NewTokenKubernetesClientFn func(token string, logger *slog.Logger) (KubernetesClientInterface, error) } func NewTokenClientFactory(logger *slog.Logger, cfg config.EnvConfig) KubernetesClientFactory { return &TokenClientFactory{ - Logger: logger, - Header: cfg.AuthTokenHeader, - Prefix: cfg.AuthTokenPrefix, + Logger: logger, + Header: cfg.AuthTokenHeader, + Prefix: cfg.AuthTokenPrefix, + NewTokenKubernetesClientFn: NewTokenKubernetesClient, } } @@ -158,5 +142,16 @@ func (f *TokenClientFactory) GetClient(ctx context.Context) (KubernetesClientInt return nil, fmt.Errorf("invalid or missing identity token") } - return newTokenKubernetesClient(identity.Token, f.Logger) + return f.NewTokenKubernetesClientFn(identity.Token, f.Logger) +} + +func NewKubernetesClientFactory(cfg config.EnvConfig, logger *slog.Logger) (KubernetesClientFactory, error) { + switch cfg.AuthMethod { + case config.AuthMethodInternal: + return NewStaticClientFactory(logger) + case config.AuthMethodUser: + return NewTokenClientFactory(logger, cfg), nil + default: + return nil, fmt.Errorf("invalid auth method: %q", cfg.AuthMethod) + } } diff --git a/clients/ui/bff/internal/integrations/kubernetes/token_k8s_client.go b/clients/ui/bff/internal/integrations/kubernetes/token_k8s_client.go index c3dce6de93..a079fd66e4 100644 --- a/clients/ui/bff/internal/integrations/kubernetes/token_k8s_client.go +++ b/clients/ui/bff/internal/integrations/kubernetes/token_k8s_client.go @@ -18,6 +18,7 @@ import ( type TokenKubernetesClient struct { SharedClientLogic + restConfig *rest.Config } func (kc *TokenKubernetesClient) IsClusterAdmin(_ *RequestIdentity) (bool, error) { @@ -54,7 +55,7 @@ func (kc *TokenKubernetesClient) IsClusterAdmin(_ *RequestIdentity) (bool, error } // newTokenKubernetesClient creates a Kubernetes client using a user bearer token. -func newTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClientInterface, error) { +func NewTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClientInterface, error) { baseConfig, err := helper.GetKubeconfig() if err != nil { logger.Error("failed to get kubeconfig", "error", err) @@ -63,10 +64,7 @@ func newTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClie // Start with an anonymous config to avoid preloaded auth cfg := rest.AnonymousClientConfig(baseConfig) - if err != nil { - logger.Error("failed to create anonymous config", "error", err) - return nil, fmt.Errorf("failed to create anonymous config: %w", err) - } + cfg.BearerToken = token // Explicitly clear all other auth mechanisms @@ -89,6 +87,7 @@ func newTokenKubernetesClient(token string, logger *slog.Logger) (KubernetesClie // Token is retained for follow-up calls; do not log it. Token: NewBearerToken(token), }, + restConfig: cfg, }, nil } @@ -252,3 +251,7 @@ func (kc *TokenKubernetesClient) GetUser(_ *RequestIdentity) (string, error) { return username, nil } + +func (kc *TokenKubernetesClient) RESTConfig() *rest.Config { + return kc.restConfig +} diff --git a/clients/ui/bff/internal/models/model_registry_kind.go b/clients/ui/bff/internal/models/model_registry_kind.go index 82a11577a0..241c7b6d8b 100644 --- a/clients/ui/bff/internal/models/model_registry_kind.go +++ b/clients/ui/bff/internal/models/model_registry_kind.go @@ -76,8 +76,9 @@ type DatabaseConfig struct { } type PasswordSecret struct { - Key string `json:"key"` - Name string `json:"name"` + Key string `json:"key"` + Name string `json:"name"` + Value string `json:"value,omitempty"` // Populated only when explicitly extracted } type Status struct {