Skip to content
Closed
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
29 changes: 25 additions & 4 deletions clients/ui/bff/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"program": "${workspaceFolder}/bin/bff",
"args": [
"--port=4000",
"--auth-method=internal",
"--auth-method=user_token",
"--auth-token-header=Authorization",
"--auth-token-prefix=Bearer",
"--static-assets-dir=./static",
Expand All @@ -30,7 +30,7 @@
"program": "${workspaceFolder}/bin/bff",
"args": [
"--port=4000",
"--auth-method=internal",
"--auth-method=user_token",
"--auth-token-header=Authorization",
"--auth-token-prefix=Bearer",
"--static-assets-dir=./static",
Expand All @@ -51,13 +51,34 @@
"program": "${workspaceFolder}/bin/bff",
"args": [
"--port=4000",
"--auth-method=internal",
"--auth-method=user_token",
"--auth-token-header=Authorization",
"--auth-token-prefix=Bearer",
"--static-assets-dir=./static",
"--mock-k8s-client=false",
"--mock-mr-client=false",
"--dev-mode=false",
"--dev-mode=true",
"--dev-mode-port=8080",
"--standalone-mode=true",
"--log-level=info"
],
"preLaunchTask": "make build debug"
},
{
"name": "XFW Debug BFF (MOCK_K8S_CLIENT=false, MOCK_MR_CLIENT=false)",
"type": "go",
"request": "launch",
"mode": "exec",
"program": "${workspaceFolder}/bin/bff",
"args": [
"--port=4000",
"--auth-method=user_token_red_hat",
"--auth-token-header=X-Forwarded-Access-Token",
"--auth-token-prefix=",
"--static-assets-dir=./static",
"--mock-k8s-client=false",
"--mock-mr-client=false",
"--dev-mode=true",
"--dev-mode-port=8080",
"--standalone-mode=true",
"--log-level=info"
Expand Down
3 changes: 3 additions & 0 deletions clients/ui/bff/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
"github.com/kubeflow/model-registry/ui/bff/internal/api"
"github.com/kubeflow/model-registry/ui/bff/internal/config"

// Import redhat handlers to register handler overrides via init()
_ "github.com/kubeflow/model-registry/ui/bff/internal/redhat/handlers"

"log/slog"
"net/http"
"os"
Expand Down
44 changes: 39 additions & 5 deletions clients/ui/bff/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ const (
CatalogSourcePreviewPath = ModelCatalogSettingsPathPrefix + "/source_preview"
)

const (
// TODO(upstream): Keep handler IDs unexported so the extension mechanism stays agnostic of downstream overrides.
handlerModelRegistrySettingsListID HandlerID = "modelRegistrySettings:list"
handlerModelRegistrySettingsCreateID HandlerID = "modelRegistrySettings:create"
handlerModelRegistrySettingsGetID HandlerID = "modelRegistrySettings:get"
handlerModelRegistrySettingsUpdateID HandlerID = "modelRegistrySettings:update"
handlerModelRegistrySettingsDeleteID HandlerID = "modelRegistrySettings:delete"
)

type App struct {
config config.EnvConfig
logger *slog.Logger
Expand Down Expand Up @@ -251,11 +260,36 @@ func (app *App) Routes() http.Handler {
// SettingsPath endpoints are used to manage the model registry settings and create new model registries
// We are still discussing the best way to create model registries in the community
// But in the meantime, those endpoints are STUBs endpoints used to unblock the frontend development
apiRouter.GET(ModelRegistrySettingsListPath, app.AttachNamespace(app.GetAllModelRegistriesSettingsHandler))
apiRouter.POST(ModelRegistrySettingsListPath, app.AttachNamespace(app.CreateModelRegistrySettingsHandler))
apiRouter.GET(ModelRegistrySettingsPath, app.AttachNamespace(app.GetModelRegistrySettingsHandler))
apiRouter.PATCH(ModelRegistrySettingsPath, app.AttachNamespace(app.UpdateModelRegistrySettingsHandler))
apiRouter.DELETE(ModelRegistrySettingsPath, app.AttachNamespace(app.DeleteModelRegistrySettingsHandler))
apiRouter.GET(
ModelRegistrySettingsListPath,
app.handlerWithOverride(handlerModelRegistrySettingsListID, func() httprouter.Handle {
return app.AttachNamespace(app.GetAllModelRegistriesSettingsHandler)
}),
)
apiRouter.POST(
ModelRegistrySettingsListPath,
app.handlerWithOverride(handlerModelRegistrySettingsCreateID, func() httprouter.Handle {
return app.AttachNamespace(app.CreateModelRegistrySettingsHandler)
}),
)
apiRouter.GET(
ModelRegistrySettingsPath,
app.handlerWithOverride(handlerModelRegistrySettingsGetID, func() httprouter.Handle {
return app.AttachNamespace(app.GetModelRegistrySettingsHandler)
}),
)
apiRouter.PATCH(
ModelRegistrySettingsPath,
app.handlerWithOverride(handlerModelRegistrySettingsUpdateID, func() httprouter.Handle {
return app.AttachNamespace(app.UpdateModelRegistrySettingsHandler)
}),
)
apiRouter.DELETE(
ModelRegistrySettingsPath,
app.handlerWithOverride(handlerModelRegistrySettingsDeleteID, func() httprouter.Handle {
return app.AttachNamespace(app.DeleteModelRegistrySettingsHandler)
}),
)

//SettingsPath: Certificate endpoints
apiRouter.GET(CertificatesPath, app.AttachNamespace(app.GetCertificatesHandler))
Expand Down
54 changes: 54 additions & 0 deletions clients/ui/bff/internal/api/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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
}

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.
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()
}

func (app *App) logHandlerOverride(id HandlerID) {
if app == nil || app.logger == nil {
return
}
app.logger.Debug("Using handler override", slog.String("handlerID", string(id)))
}
40 changes: 10 additions & 30 deletions clients/ui/bff/internal/api/model_registry_settings_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ type ModelRegistryAndCredentialsSettingsEnvelope Envelope[models.ModelRegistryAn
type ModelRegistrySettingsPayloadEnvelope Envelope[models.ModelRegistrySettingsPayload, None]

func (app *App) GetAllModelRegistriesSettingsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ctxLogger := helper.GetContextLoggerFromReq(r)
ctxLogger.Info("This functionality is not implement yet. This is a STUB API to unblock frontend development")

namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string)
if !ok || namespace == "" {
Expand All @@ -36,17 +34,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)
}

Expand All @@ -73,9 +71,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)
}
}
Expand All @@ -94,31 +90,18 @@ 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{
Data: registry,
}

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) {
Expand All @@ -137,8 +120,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
}
Expand All @@ -161,9 +143,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)
}

Expand Down
53 changes: 53 additions & 0 deletions clients/ui/bff/internal/api/public_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package api

import (
"fmt"
"net/http"

"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"
)

// 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))
}

// 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
}
25 changes: 25 additions & 0 deletions clients/ui/bff/internal/api/test_app.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
1 change: 1 addition & 0 deletions clients/ui/bff/internal/integrations/kubernetes/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kubernetes

import (
"context"

corev1 "k8s.io/api/core/v1"
)

Expand Down
Loading
Loading