diff --git a/kagenti-operator/Makefile b/kagenti-operator/Makefile index 07ad96bd..a84fadf6 100644 --- a/kagenti-operator/Makefile +++ b/kagenti-operator/Makefile @@ -188,13 +188,10 @@ docker-push: ## Push docker image with the manager. PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support - # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile - sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name kagenti-operator-builder $(CONTAINER_TOOL) buildx use kagenti-operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} . - $(CONTAINER_TOOL) buildx rm kagenti-operator-builder - rm Dockerfile.cross .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 1ff77cb6..4a321e6d 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -514,6 +514,18 @@ func main() { os.Exit(1) } setupLog.Info("MLflow experiment tracking controller enabled") + + uiConfigBootstrap := &bootstrap.UIConfigBootstrapRunnable{ + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Namespace: getOperatorNamespace(), + Log: ctrl.Log.WithName("bootstrap"), + } + if err := mgr.Add(uiConfigBootstrap); err != nil { + setupLog.Error(err, "unable to add UI config bootstrap runnable") + os.Exit(1) + } + setupLog.Info("MLflow UI config bootstrap enabled") } if enableClientRegistration { diff --git a/kagenti-operator/internal/bootstrap/uiconfig.go b/kagenti-operator/internal/bootstrap/uiconfig.go new file mode 100644 index 00000000..62ebad59 --- /dev/null +++ b/kagenti-operator/internal/bootstrap/uiconfig.go @@ -0,0 +1,105 @@ +/* +Copyright 2026. + +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 bootstrap + +import ( + "context" + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kagenti/operator/internal/mlflow" +) + +const ( + uiConfigMapName = "kagenti-ui-config" + mlflowDashboardURLKey = "MLFLOW_DASHBOARD_URL" +) + +// UIConfigBootstrapRunnable patches the kagenti-ui-config ConfigMap with the +// MLflow dashboard URL. Runs once at startup; skips gracefully if MLflow or +// the UI ConfigMap are not present. +type UIConfigBootstrapRunnable struct { + Client client.Client + APIReader client.Reader + Namespace string + Log logr.Logger +} + +func (r *UIConfigBootstrapRunnable) Start(ctx context.Context) error { + log := r.Log.WithName("ui-config-bootstrap") + + dashboardURL := r.discoverDashboardURL(ctx, log) + if dashboardURL == "" { + return nil + } + + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Name: uiConfigMapName, Namespace: r.Namespace} + if err := r.APIReader.Get(ctx, key, cm); err != nil { + if errors.IsNotFound(err) { + log.V(1).Info("UI ConfigMap not found, skipping (UI likely not deployed)") + return nil + } + log.Error(err, "Failed to read UI ConfigMap") + return nil + } + + if cm.Data != nil && cm.Data[mlflowDashboardURLKey] == dashboardURL { + log.V(1).Info("MLflow dashboard URL already set") + return nil + } + + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data[mlflowDashboardURLKey] = dashboardURL + if err := r.Client.Update(ctx, cm); err != nil { + log.Error(err, "Failed to patch UI ConfigMap with MLflow dashboard URL") + return nil + } + + log.Info("Patched UI ConfigMap with MLflow dashboard URL", "url", dashboardURL) + return nil +} + +func (r *UIConfigBootstrapRunnable) NeedLeaderElection() bool { + return true +} + +func (r *UIConfigBootstrapRunnable) discoverDashboardURL(ctx context.Context, log logr.Logger) string { + list := &mlflow.MLflowList{} + if err := r.APIReader.List(ctx, list); err != nil { + log.V(1).Info("Could not list MLflow CRs, skipping", "error", err) + return "" + } + + for i := range list.Items { + cr := &list.Items[i] + if meta.IsStatusConditionTrue(cr.Status.Conditions, "Available") && cr.Status.URL != "" { + return strings.TrimRight(cr.Status.URL, "/") + "/" + } + } + + log.V(1).Info("No available MLflow CR with external URL found") + return "" +} diff --git a/kagenti-operator/internal/bootstrap/uiconfig_test.go b/kagenti-operator/internal/bootstrap/uiconfig_test.go new file mode 100644 index 00000000..d34aeeb4 --- /dev/null +++ b/kagenti-operator/internal/bootstrap/uiconfig_test.go @@ -0,0 +1,130 @@ +/* +Copyright 2026. + +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 bootstrap + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newUIConfigRunnable(objs ...client.Object) *UIConfigBootstrapRunnable { + scheme := testScheme() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + return &UIConfigBootstrapRunnable{ + Client: cl, + APIReader: cl, + Namespace: testNamespace, + Log: testLogger(), + } +} + +func TestUIConfig_PatchesConfigMap(t *testing.T) { + cr := mlflowCR("mlflow", "", true, "https://mlflow-gateway.apps.example.com") + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: uiConfigMapName, Namespace: testNamespace}, + Data: map[string]string{"DOMAIN_NAME": "example.com"}, + } + + r := newUIConfigRunnable(cr, cm) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start() failed: %v", err) + } + + got := &corev1.ConfigMap{} + _ = r.Client.Get(context.Background(), types.NamespacedName{Name: uiConfigMapName, Namespace: testNamespace}, got) + + if got.Data[mlflowDashboardURLKey] != "https://mlflow-gateway.apps.example.com/" { + t.Errorf("got %q, want %q", got.Data[mlflowDashboardURLKey], "https://mlflow-gateway.apps.example.com/") + } + if got.Data["DOMAIN_NAME"] != "example.com" { + t.Error("existing keys were modified") + } +} + +func TestUIConfig_Idempotent(t *testing.T) { + cr := mlflowCR("mlflow", "", true, "https://mlflow.apps.cluster.com/mlflow") + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: uiConfigMapName, Namespace: testNamespace}, + Data: map[string]string{mlflowDashboardURLKey: "https://mlflow.apps.cluster.com/mlflow/"}, + } + + r := newUIConfigRunnable(cr, cm) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start() failed: %v", err) + } + + got := &corev1.ConfigMap{} + _ = r.Client.Get(context.Background(), types.NamespacedName{Name: uiConfigMapName, Namespace: testNamespace}, got) + + if got.Data[mlflowDashboardURLKey] != "https://mlflow.apps.cluster.com/mlflow/" { + t.Error("URL was unexpectedly modified") + } +} + +func TestUIConfig_SkipsWhenNoMLflow(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: uiConfigMapName, Namespace: testNamespace}, + Data: map[string]string{}, + } + + r := newUIConfigRunnable(cm) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start() failed: %v", err) + } + + got := &corev1.ConfigMap{} + _ = r.Client.Get(context.Background(), types.NamespacedName{Name: uiConfigMapName, Namespace: testNamespace}, got) + + if _, ok := got.Data[mlflowDashboardURLKey]; ok { + t.Error("should not set URL when no MLflow CR exists") + } +} + +func TestUIConfig_SkipsWhenNoConfigMap(t *testing.T) { + cr := mlflowCR("mlflow", "", true, "https://mlflow.apps.cluster.com") + + r := newUIConfigRunnable(cr) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start() failed: %v", err) + } +} + +func TestUIConfig_NormalizesTrailingSlash(t *testing.T) { + cr := mlflowCR("mlflow", "", true, "https://mlflow.apps.cluster.com/mlflow") + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: uiConfigMapName, Namespace: testNamespace}, + Data: map[string]string{}, + } + + r := newUIConfigRunnable(cr, cm) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start() failed: %v", err) + } + + got := &corev1.ConfigMap{} + _ = r.Client.Get(context.Background(), types.NamespacedName{Name: uiConfigMapName, Namespace: testNamespace}, got) + + if got.Data[mlflowDashboardURLKey] != "https://mlflow.apps.cluster.com/mlflow/" { + t.Errorf("got %q, expected trailing slash to be added", got.Data[mlflowDashboardURLKey]) + } +}