From 42f85fa19cc9d5a0a49fb2b552faa929730ea4bc Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 07:58:30 +0000 Subject: [PATCH 1/7] fix(ingress): reconcile repo-managed traefik middleware config --- internal/cli/setup/helpers_test.go | 91 ++++++++++++++++++++++++++++++ internal/cli/setup/platform.go | 60 ++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/internal/cli/setup/helpers_test.go b/internal/cli/setup/helpers_test.go index 550e0859..95f9705f 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -1907,6 +1907,97 @@ func TestDeployOperatorManifestsWithKubectl(t *testing.T) { } } +func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesBaseResources(t *testing.T) { + root := repoRootForTest(t) + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("get working dir: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir to repo root: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(origDir) + }) + + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + cmd := &core.MockCommand{Args: spec.Args} + if commandHasArgs(spec, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { + cmd.RunFunc = func() error { + if cmd.StdoutW != nil { + _, _ = cmd.StdoutW.Write([]byte("traefik")) + } + return nil + } + } + return cmd + }, + } + kubectl := core.NewTestKubectlClient(mock) + + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, zap.NewNop()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var ( + hasLookup bool + hasDynamicConfig bool + hasBaseTraefik bool + ) + for _, cmd := range mock.Commands { + if commandHasArgs(cmd, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { + hasLookup = true + } + if idx := argIndex(cmd.Args, "-f"); idx != -1 && idx+1 < len(cmd.Args) { + path := cmd.Args[idx+1] + if strings.Contains(path, "config/ingress/base/dynamic-config.yaml") { + hasDynamicConfig = true + } + if strings.Contains(path, "config/ingress/base/traefik.yaml") { + hasBaseTraefik = true + } + } + } + if !hasLookup { + t.Fatal("expected traefik deployment lookup") + } + if !hasDynamicConfig { + t.Fatal("expected repo-managed traefik dynamic-config apply") + } + if !hasBaseTraefik { + t.Fatal("expected repo-managed traefik base manifest apply") + } +} + +func TestEnsureRepoManagedTraefikMiddlewareResourcesSkipsWhenMissing(t *testing.T) { + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + cmd := &core.MockCommand{Args: spec.Args} + if commandHasArgs(spec, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { + cmd.RunFunc = func() error { + if cmd.StderrW != nil { + _, _ = cmd.StderrW.Write([]byte("Error from server (NotFound): deployments.apps \"traefik\" not found")) + } + return errors.New("not found") + } + } + return cmd + }, + } + kubectl := core.NewTestKubectlClient(mock) + + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, zap.NewNop()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, cmd := range mock.Commands { + if idx := argIndex(cmd.Args, "-f"); idx != -1 && idx+1 < len(cmd.Args) { + t.Fatalf("did not expect manifest apply when repo-managed traefik is absent, got %v", cmd.Args) + } + } +} + func TestDeployOperatorManifestsWithKubectlUsesIfNotPresentForTestModeImage(t *testing.T) { root := repoRootForTest(t) diff --git a/internal/cli/setup/platform.go b/internal/cli/setup/platform.go index 34fc2d00..acf823f6 100644 --- a/internal/cli/setup/platform.go +++ b/internal/cli/setup/platform.go @@ -1467,6 +1467,10 @@ func deployOperatorManifests(logger *zap.Logger, operatorImage, gatewayProxyImag // deployOperatorManifestsWithKubectl deploys operator manifests without requiring kustomize or controller-gen. // It applies CRD, RBAC, and manager manifests directly, replacing the image name and injecting operator args/env. func deployOperatorManifestsWithKubectl(kubectl core.KubectlRunner, logger *zap.Logger, operatorImage, gatewayProxyImage string, operatorArgs []string) error { + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, logger); err != nil { + return err + } + // Step 1: Apply CRD core.Info("Applying CRD manifests") // #nosec G204 -- fixed directory path from repository. @@ -1694,6 +1698,11 @@ func deployAnalyticsManifests(logger *zap.Logger, images AnalyticsImageSet, stor func deployAnalyticsManifestsWithKubectl(kubectl core.KubectlRunner, logger *zap.Logger, images AnalyticsImageSet, storageMode, platformMode string) error { rolloutTimeout := analyticsRolloutTimeoutString() + + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, logger); err != nil { + return err + } + core.Info("Applying mcp-sentinel namespace and config") manifests := []string{ "k8s/00-namespace.yaml", @@ -1831,6 +1840,57 @@ func deployAnalyticsManifestsWithKubectl(kubectl core.KubectlRunner, logger *zap return core.WrapWithSentinelAndContext(core.ErrOperatorDeploymentFailed, cause, msg, ctx) } +func ensureRepoManagedTraefikMiddlewareResources(kubectl core.KubectlRunner, logger *zap.Logger) error { + exists, err := deploymentExistsWithKubectl(kubectl, "traefik", "traefik") + if err != nil { + return err + } + if !exists { + return nil + } + + if logger != nil { + logger.Info("Reconciling repo-managed Traefik file-provider resources", zap.String("namespace", "traefik")) + } + + manifests := []string{ + "config/ingress/base/dynamic-config.yaml", + "config/ingress/base/traefik.yaml", + } + for _, relPath := range manifests { + resolvedPath, err := assetpath.ResolveRepoAssetPath(relPath) + if err != nil { + return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to resolve Traefik manifest %s: %v", relPath, err)) + } + if err := kubectl.RunWithOutput([]string{"apply", "-f", resolvedPath}, os.Stdout, os.Stderr); err != nil { + return core.WrapWithSentinel( + core.ErrInstallIngressControllerFailed, + err, + fmt.Sprintf("failed to reconcile repo-managed Traefik manifest %s: %v", relPath, err), + ) + } + } + return nil +} + +func deploymentExistsWithKubectl(kubectl core.KubectlRunner, namespace, name string) (bool, error) { + cmd, err := kubectl.CommandArgs([]string{"get", "deployment", name, "-n", namespace, "-o", "jsonpath={.metadata.name}"}) + if err != nil { + return false, err + } + var stdout, stderr bytes.Buffer + cmd.SetStdout(&stdout) + cmd.SetStderr(&stderr) + if err := cmd.Run(); err == nil { + return strings.TrimSpace(stdout.String()) == name, nil + } + detail := strings.ToLower(strings.TrimSpace(stdout.String() + "\n" + stderr.String())) + if strings.Contains(detail, "not found") || strings.Contains(detail, "notfound") { + return false, nil + } + return false, fmt.Errorf("look up deployment %s/%s: %w", namespace, name, err) +} + func applyCatalogNamespaceForMode(kubectl core.KubectlRunner, platformMode string) error { namespace := setupplan.CatalogNamespaceForPlatformMode(platformMode) if strings.TrimSpace(namespace) == "" { From 1915fa72e661cfb33bf2deccc95aab545f1a4491 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 09:17:11 +0000 Subject: [PATCH 2/7] fix(setup): preserve public platform config on redeploy --- internal/cli/setup/helpers_test.go | 212 +++++++++++++++--- internal/cli/setup/platform.go | 344 +++++++++++++++++++++++++++-- 2 files changed, 500 insertions(+), 56 deletions(-) diff --git a/internal/cli/setup/helpers_test.go b/internal/cli/setup/helpers_test.go index 95f9705f..46db5f0f 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -858,6 +858,87 @@ func TestRenderAnalyticsSecretManifestDisablesExistingDevLoginsOutsideTestMode(t } } +func TestRenderAnalyticsConfigManifestPreservesExistingPublicOAuthConfig(t *testing.T) { + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + if commandHasArgs(spec, "get", "configmap", "mcp-sentinel-config", "-n", "mcp-sentinel", "-o", "json") { + return &core.MockCommand{ + Args: spec.Args, + OutputData: []byte(`{"data":{"GOOGLE_CLIENT_ID":"client.apps.googleusercontent.com","OIDC_ISSUER":"https://accounts.google.com","OIDC_AUDIENCE":"client.apps.googleusercontent.com","OIDC_JWKS_URL":"https://www.googleapis.com/oauth2/v3/certs","PLATFORM_MODE":"public"}}`), + } + } + return &core.MockCommand{Args: spec.Args} + }, + } + kubectl := core.NewTestKubectlClient(mock) + + rendered, err := renderAnalyticsConfigManifest(kubectl, `apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-sentinel-config + namespace: mcp-sentinel +data: + GOOGLE_CLIENT_ID: "" + OIDC_ISSUER: "" + OIDC_AUDIENCE: "" + OIDC_JWKS_URL: "" + PLATFORM_MODE: "tenant" +`, setupplan.PlatformModeTenant) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var payload struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal([]byte(rendered), &payload); err != nil { + t.Fatalf("unmarshal rendered config: %v", err) + } + if got := payload.Data["GOOGLE_CLIENT_ID"]; got != "client.apps.googleusercontent.com" { + t.Fatalf("expected GOOGLE_CLIENT_ID to be preserved, got %q", got) + } + if got := payload.Data["PLATFORM_MODE"]; got != setupplan.PlatformModePublic { + t.Fatalf("expected PLATFORM_MODE to stay public, got %q", got) + } +} + +func TestRenderAnalyticsConfigManifestAppliesExplicitPlatformMode(t *testing.T) { + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + if commandHasArgs(spec, "get", "configmap", "mcp-sentinel-config", "-n", "mcp-sentinel", "-o", "json") { + return &core.MockCommand{ + Args: spec.Args, + OutputData: []byte(`{"data":{"PLATFORM_MODE":"public"}}`), + } + } + return &core.MockCommand{Args: spec.Args} + }, + } + kubectl := core.NewTestKubectlClient(mock) + + rendered, err := renderAnalyticsConfigManifest(kubectl, `apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-sentinel-config + namespace: mcp-sentinel +data: + PLATFORM_MODE: "tenant" +`, setupplan.PlatformModeOrg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var payload struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal([]byte(rendered), &payload); err != nil { + t.Fatalf("unmarshal rendered config: %v", err) + } + if got := payload.Data["PLATFORM_MODE"]; got != setupplan.PlatformModeOrg { + t.Fatalf("expected explicit platform mode to win, got %q", got) + } +} + func TestEnsureCSVIncludes(t *testing.T) { tests := []struct { name string @@ -1907,7 +1988,7 @@ func TestDeployOperatorManifestsWithKubectl(t *testing.T) { } } -func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesBaseResources(t *testing.T) { +func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesMiddlewareSupportToRepoManagedController(t *testing.T) { root := repoRootForTest(t) origDir, err := os.Getwd() if err != nil { @@ -1920,16 +2001,16 @@ func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesBaseResources(t *test _ = os.Chdir(origDir) }) + var created []*core.MockCommand mock := &core.MockExecutor{ CommandFunc: func(spec core.ExecSpec) *core.MockCommand { cmd := &core.MockCommand{Args: spec.Args} - if commandHasArgs(spec, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { - cmd.RunFunc = func() error { - if cmd.StdoutW != nil { - _, _ = cmd.StdoutW.Write([]byte("traefik")) - } - return nil - } + created = append(created, cmd) + switch { + case commandHasArgs(spec, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name"): + cmd.OutputData = []byte("traefik traefik\n") + case commandHasArgs(spec, "get", "deployment", "traefik", "-n", "traefik", "-o", "json"): + cmd.OutputData = []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"traefik","args":["--providers.kubernetesingress=true"],"volumeMounts":[]}],"volumes":[]}}}}`) } return cmd }, @@ -1941,32 +2022,104 @@ func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesBaseResources(t *test } var ( - hasLookup bool - hasDynamicConfig bool - hasBaseTraefik bool + hasLookup bool + hasDynamicApply bool + hasPluginApply bool + hasPatch bool ) for _, cmd := range mock.Commands { - if commandHasArgs(cmd, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { + if commandHasArgs(cmd, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name") { hasLookup = true } - if idx := argIndex(cmd.Args, "-f"); idx != -1 && idx+1 < len(cmd.Args) { - path := cmd.Args[idx+1] - if strings.Contains(path, "config/ingress/base/dynamic-config.yaml") { - hasDynamicConfig = true - } - if strings.Contains(path, "config/ingress/base/traefik.yaml") { - hasBaseTraefik = true - } + if commandHasArgs(cmd, "patch", "deployment", "traefik", "-n", "traefik", "--type=json") { + hasPatch = true + } + } + for _, cmd := range created { + if cmd.StdinR == nil { + continue + } + data, err := io.ReadAll(cmd.StdinR) + if err != nil { + t.Fatalf("read stdin: %v", err) + } + body := string(data) + if strings.Contains(body, "name: traefik-dynamic") && strings.Contains(body, "namespace: traefik") { + hasDynamicApply = true + } + if strings.Contains(body, "name: traefik-plugin-pii-redactor") && strings.Contains(body, "namespace: traefik") { + hasPluginApply = true } } if !hasLookup { - t.Fatal("expected traefik deployment lookup") + t.Fatal("expected active traefik deployment lookup") + } + if !hasDynamicApply { + t.Fatal("expected traefik dynamic-config apply") } - if !hasDynamicConfig { - t.Fatal("expected repo-managed traefik dynamic-config apply") + if !hasPluginApply { + t.Fatal("expected traefik plugin-source apply") } - if !hasBaseTraefik { - t.Fatal("expected repo-managed traefik base manifest apply") + if !hasPatch { + t.Fatal("expected traefik deployment patch") + } +} + +func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesMiddlewareSupportToExternalController(t *testing.T) { + var created []*core.MockCommand + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + cmd := &core.MockCommand{Args: spec.Args} + created = append(created, cmd) + switch { + case commandHasArgs(spec, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name"): + cmd.OutputData = []byte("kube-system traefik\n") + case commandHasArgs(spec, "get", "deployment", "traefik", "-n", "kube-system", "-o", "json"): + cmd.OutputData = []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"traefik","args":["--providers.kubernetesingress"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data"}]}}}}`) + } + return cmd + }, + } + kubectl := core.NewTestKubectlClient(mock) + + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, zap.NewNop()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var ( + hasDynamicApply bool + hasPluginApply bool + hasPatch bool + ) + for _, cmd := range mock.Commands { + if commandHasArgs(cmd, "patch", "deployment", "traefik", "-n", "kube-system", "--type=json") { + hasPatch = true + } + } + for _, cmd := range created { + if cmd.StdinR == nil { + continue + } + data, err := io.ReadAll(cmd.StdinR) + if err != nil { + t.Fatalf("read stdin: %v", err) + } + body := string(data) + if strings.Contains(body, "name: traefik-dynamic") && strings.Contains(body, "namespace: kube-system") { + hasDynamicApply = true + } + if strings.Contains(body, "name: traefik-plugin-pii-redactor") && strings.Contains(body, "namespace: kube-system") { + hasPluginApply = true + } + } + if !hasDynamicApply { + t.Fatal("expected external traefik dynamic-config apply") + } + if !hasPluginApply { + t.Fatal("expected external traefik plugin-source apply") + } + if !hasPatch { + t.Fatal("expected external traefik deployment patch") } } @@ -1974,13 +2127,8 @@ func TestEnsureRepoManagedTraefikMiddlewareResourcesSkipsWhenMissing(t *testing. mock := &core.MockExecutor{ CommandFunc: func(spec core.ExecSpec) *core.MockCommand { cmd := &core.MockCommand{Args: spec.Args} - if commandHasArgs(spec, "get", "deployment", "traefik", "-n", "traefik", "-o", "jsonpath={.metadata.name}") { - cmd.RunFunc = func() error { - if cmd.StderrW != nil { - _, _ = cmd.StderrW.Write([]byte("Error from server (NotFound): deployments.apps \"traefik\" not found")) - } - return errors.New("not found") - } + if commandHasArgs(spec, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name") { + cmd.OutputData = []byte("") } return cmd }, diff --git a/internal/cli/setup/platform.go b/internal/cli/setup/platform.go index acf823f6..5fca9e9a 100644 --- a/internal/cli/setup/platform.go +++ b/internal/cli/setup/platform.go @@ -15,6 +15,7 @@ import ( "io" "net/url" "os" + "slices" "strconv" "strings" "sync" @@ -1841,33 +1842,22 @@ func deployAnalyticsManifestsWithKubectl(kubectl core.KubectlRunner, logger *zap } func ensureRepoManagedTraefikMiddlewareResources(kubectl core.KubectlRunner, logger *zap.Logger) error { - exists, err := deploymentExistsWithKubectl(kubectl, "traefik", "traefik") + namespaces, err := activeNamedTraefikDeploymentNamespacesWithKubectl(kubectl) if err != nil { return err } - if !exists { - return nil - } - - if logger != nil { - logger.Info("Reconciling repo-managed Traefik file-provider resources", zap.String("namespace", "traefik")) - } - - manifests := []string{ - "config/ingress/base/dynamic-config.yaml", - "config/ingress/base/traefik.yaml", - } - for _, relPath := range manifests { - resolvedPath, err := assetpath.ResolveRepoAssetPath(relPath) - if err != nil { - return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to resolve Traefik manifest %s: %v", relPath, err)) + for _, namespace := range namespaces { + if logger != nil { + logger.Info("Reconciling Traefik file-provider resources", zap.String("namespace", namespace)) } - if err := kubectl.RunWithOutput([]string{"apply", "-f", resolvedPath}, os.Stdout, os.Stderr); err != nil { - return core.WrapWithSentinel( - core.ErrInstallIngressControllerFailed, - err, - fmt.Sprintf("failed to reconcile repo-managed Traefik manifest %s: %v", relPath, err), - ) + if err := applyTraefikSupportManifest(kubectl, "config/ingress/overlays/http/dynamic-config.yaml", namespace); err != nil { + return err + } + if err := applyTraefikSupportManifest(kubectl, "config/ingress/overlays/http/plugin-source.yaml", namespace); err != nil { + return err + } + if err := patchTraefikDeploymentForFileMiddlewareSupport(kubectl, namespace); err != nil { + return err } } return nil @@ -1891,6 +1881,232 @@ func deploymentExistsWithKubectl(kubectl core.KubectlRunner, namespace, name str return false, fmt.Errorf("look up deployment %s/%s: %w", namespace, name, err) } +func activeNamedTraefikDeploymentNamespacesWithKubectl(kubectl core.KubectlRunner) ([]string, error) { + cmd, err := kubectl.CommandArgs([]string{ + "get", "deployment", "-A", "--no-headers", + "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name", + }) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list traefik deployments: %w", err) + } + seen := map[string]struct{}{} + var namespaces []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[1] != "traefik" { + continue + } + ns := strings.TrimSpace(fields[0]) + if ns == "" { + continue + } + if _, ok := seen[ns]; ok { + continue + } + seen[ns] = struct{}{} + namespaces = append(namespaces, ns) + } + slices.Sort(namespaces) + return namespaces, nil +} + +func applyTraefikSupportManifest(kubectl core.KubectlRunner, relPath, namespace string) error { + resolvedPath, err := assetpath.ResolveRepoAssetPath(relPath) + if err != nil { + return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to resolve Traefik manifest %s: %v", relPath, err)) + } + manifestBytes, err := kube.ReadFileAtPath(resolvedPath) + if err != nil { + return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to read Traefik manifest %s: %v", relPath, err)) + } + manifestContent := strings.ReplaceAll(string(manifestBytes), "namespace: traefik", "namespace: "+namespace) + if err := kube.ApplyManifestContent(kubectl.CommandArgs, manifestContent); err != nil { + return core.WrapWithSentinel( + core.ErrInstallIngressControllerFailed, + err, + fmt.Sprintf("failed to reconcile Traefik manifest %s in namespace %s: %v", relPath, namespace, err), + ) + } + return nil +} + +type traefikDeploymentSpec struct { + Spec struct { + Template struct { + Spec struct { + Containers []struct { + Name string `json:"name"` + Args []string `json:"args"` + VolumeMounts []struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + } `json:"volumeMounts"` + } `json:"containers"` + Volumes []struct { + Name string `json:"name"` + } `json:"volumes"` + } `json:"spec"` + } `json:"template"` + } `json:"spec"` +} + +type jsonPatchOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Value any `json:"value,omitempty"` +} + +func patchTraefikDeploymentForFileMiddlewareSupport(kubectl core.KubectlRunner, namespace string) error { + spec, err := readTraefikDeploymentSpec(kubectl, namespace) + if err != nil { + return err + } + if len(spec.Spec.Template.Spec.Containers) == 0 { + return core.NewWithSentinel(core.ErrInstallIngressControllerFailed, fmt.Sprintf("traefik deployment in namespace %s has no containers", namespace)) + } + container := spec.Spec.Template.Spec.Containers[0] + + var ops []jsonPatchOperation + if !containsString(container.Args, "--providers.file.filename=/etc/traefik/dynamic/dynamic.yml") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/args/-", + Value: "--providers.file.filename=/etc/traefik/dynamic/dynamic.yml", + }) + } + if !containsString(container.Args, "--providers.file.watch=true") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/args/-", + Value: "--providers.file.watch=true", + }) + } + if !containsString(container.Args, "--experimental.localplugins.pii-redactor.modulename=github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/args/-", + Value: "--experimental.localplugins.pii-redactor.modulename=github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor", + }) + } + if !hasVolumeMount(container.VolumeMounts, "traefik-dynamic", "/etc/traefik/dynamic") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/volumeMounts/-", + Value: map[string]any{"name": "traefik-dynamic", "mountPath": "/etc/traefik/dynamic", "readOnly": true}, + }) + } + if !hasVolumeMount(container.VolumeMounts, "traefik-plugin-source", "/plugins-local/src/github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/volumeMounts/-", + Value: map[string]any{"name": "traefik-plugin-source", "mountPath": "/plugins-local/src/github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor", "readOnly": true}, + }) + } + if !hasVolumeMount(container.VolumeMounts, "traefik-plugins", "/plugins-storage") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/containers/0/volumeMounts/-", + Value: map[string]any{"name": "traefik-plugins", "mountPath": "/plugins-storage"}, + }) + } + if !hasVolume(spec.Spec.Template.Spec.Volumes, "traefik-dynamic") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/volumes/-", + Value: map[string]any{ + "name": "traefik-dynamic", + "configMap": map[string]any{ + "name": "traefik-dynamic", + "items": []map[string]any{{"key": "dynamic.yml", "path": "dynamic.yml"}}, + }, + }, + }) + } + if !hasVolume(spec.Spec.Template.Spec.Volumes, "traefik-plugin-source") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/volumes/-", + Value: map[string]any{"name": "traefik-plugin-source", "configMap": map[string]any{"name": "traefik-plugin-pii-redactor"}}, + }) + } + if !hasVolume(spec.Spec.Template.Spec.Volumes, "traefik-plugins") { + ops = append(ops, jsonPatchOperation{ + Op: "add", + Path: "/spec/template/spec/volumes/-", + Value: map[string]any{"name": "traefik-plugins", "emptyDir": map[string]any{}}, + }) + } + if len(ops) == 0 { + return nil + } + patchBytes, err := json.Marshal(ops) + if err != nil { + return fmt.Errorf("marshal traefik deployment patch: %w", err) + } + if err := kubectl.RunWithOutput([]string{ + "patch", "deployment", "traefik", "-n", namespace, "--type=json", "-p", string(patchBytes), + }, os.Stdout, os.Stderr); err != nil { + return core.WrapWithSentinel( + core.ErrInstallIngressControllerFailed, + err, + fmt.Sprintf("failed to patch traefik deployment in namespace %s for file-provider middleware support: %v", namespace, err), + ) + } + return nil +} + +func readTraefikDeploymentSpec(kubectl core.KubectlRunner, namespace string) (traefikDeploymentSpec, error) { + var spec traefikDeploymentSpec + cmd, err := kubectl.CommandArgs([]string{"get", "deployment", "traefik", "-n", namespace, "-o", "json"}) + if err != nil { + return spec, err + } + out, err := cmd.Output() + if err != nil { + return spec, fmt.Errorf("read traefik deployment %s/traefik: %w", namespace, err) + } + if err := json.Unmarshal(out, &spec); err != nil { + return spec, fmt.Errorf("decode traefik deployment %s/traefik: %w", namespace, err) + } + return spec, nil +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} + +func hasVolumeMount(mounts []struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` +}, name, mountPath string) bool { + for _, mount := range mounts { + if mount.Name == name && mount.MountPath == mountPath { + return true + } + } + return false +} + +func hasVolume(volumes []struct { + Name string `json:"name"` +}, name string) bool { + for _, volume := range volumes { + if volume.Name == name { + return true + } + } + return false +} + func applyCatalogNamespaceForMode(kubectl core.KubectlRunner, platformMode string) error { namespace := setupplan.CatalogNamespaceForPlatformMode(platformMode) if strings.TrimSpace(namespace) == "" { @@ -1995,7 +2211,12 @@ func applyRenderedManifest(kubectl core.KubectlRunner, manifestPath string, imag if err != nil { return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to read manifest %s: %v", resolvedManifestPath, err)) } - rendered, err := renderAnalyticsManifest(string(content), images, imagePullSecretName, platformMode) + rendered := "" + if manifestPath == "k8s/01-config.yaml" { + rendered, err = renderAnalyticsConfigManifest(kubectl, string(content), platformMode) + } else { + rendered, err = renderAnalyticsManifest(string(content), images, imagePullSecretName, platformMode) + } if err != nil { return fmt.Errorf("render manifest %s: %w", manifestPath, err) } @@ -2077,6 +2298,81 @@ func renderAnalyticsManifest(content string, images AnalyticsImageSet, imagePull return rendered, nil } +func renderAnalyticsConfigManifest(kubectl core.KubectlRunner, content, platformMode string) (string, error) { + type configMapManifest struct { + Data map[string]string `yaml:"data"` + } + + var manifest configMapManifest + if err := yaml.Unmarshal([]byte(content), &manifest); err != nil { + return "", fmt.Errorf("decode analytics config manifest: %w", err) + } + if manifest.Data == nil { + manifest.Data = map[string]string{} + } + + existingData, err := existingConfigMapData(kubectl, core.DefaultAnalyticsNamespace, "mcp-sentinel-config") + if err != nil { + return "", err + } + for _, key := range []string{ + "GOOGLE_CLIENT_ID", + "OIDC_ISSUER", + "OIDC_AUDIENCE", + "OIDC_JWKS_URL", + "MCP_PLATFORM_DOMAIN", + "MCP_MCP_INGRESS_HOST", + } { + if strings.TrimSpace(manifest.Data[key]) == "" && strings.TrimSpace(existingData[key]) != "" { + manifest.Data[key] = existingData[key] + } + } + if existingMode, ok := setupplan.NormalizePlatformMode(existingData["PLATFORM_MODE"]); ok { + requestedMode, requestedOK := setupplan.NormalizePlatformMode(platformMode) + switch { + case !requestedOK: + manifest.Data["PLATFORM_MODE"] = existingMode + case requestedMode == setupplan.PlatformModeTenant && existingMode != setupplan.PlatformModeTenant: + manifest.Data["PLATFORM_MODE"] = existingMode + default: + manifest.Data["PLATFORM_MODE"] = requestedMode + } + } else if mode, ok := setupplan.NormalizePlatformMode(platformMode); ok { + manifest.Data["PLATFORM_MODE"] = mode + } + + rendered, err := yaml.Marshal(manifest) + if err != nil { + return "", fmt.Errorf("encode analytics config manifest: %w", err) + } + return string(rendered), nil +} + +func existingConfigMapData(kubectl core.KubectlRunner, namespace, name string) (map[string]string, error) { + cmd, err := kubectl.CommandArgs([]string{"get", "configmap", name, "-n", namespace, "-o", "json"}) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + detail := strings.ToLower(strings.TrimSpace(string(out))) + if strings.Contains(detail, "not found") || strings.Contains(detail, "notfound") { + return map[string]string{}, nil + } + return nil, fmt.Errorf("read configmap %s/%s: %w", namespace, name, err) + } + var payload struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(out, &payload); err != nil { + return nil, fmt.Errorf("decode configmap %s/%s: %w", namespace, name, err) + } + if payload.Data == nil { + return map[string]string{}, nil + } + return payload.Data, nil +} + func renderAnalyticsSecretManifest(kubectl core.KubectlRunner) (string, error) { apiKeys, err := existingSecretDataValueOrRandom(kubectl, core.DefaultAnalyticsNamespace, "mcp-sentinel-secrets", "API_KEYS", 16) if err != nil { From 929c56e2a173ce44755a8f5ab79374730d750c97 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 09:27:23 +0000 Subject: [PATCH 3/7] fix(setup): tighten traefik review follow-ups --- internal/cli/core/errors.go | 1 + internal/cli/setup/platform.go | 22 ++-------------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/internal/cli/core/errors.go b/internal/cli/core/errors.go index 9736ec3a..ffacdc48 100644 --- a/internal/cli/core/errors.go +++ b/internal/cli/core/errors.go @@ -151,6 +151,7 @@ var ( ErrEnsureOperatorNamespaceFailed = newSentinelError("failed to ensure operator namespace", errx.CodeSetup, errx.DescSetup) ErrApplyRBACFailed = newSentinelError("failed to apply RBAC", errx.CodeSetup, errx.DescSetup) ErrReadManagerYAMLFailed = newSentinelError("failed to read manager.yaml", errx.CodeSetup, errx.DescSetup) + ErrReadIngressManifestFailed = newSentinelError("failed to read ingress manifest", errx.CodeSetup, errx.DescSetup) ErrParseManagerYAMLFailed = newSentinelError("failed to parse manager.yaml", errx.CodeSetup, errx.DescSetup) ErrSetOperatorImageFailed = newSentinelError("failed to set operator image", errx.CodeSetup, errx.DescSetup) ErrMutateManagerYAMLFailed = newSentinelError("failed to mutate manager.yaml", errx.CodeSetup, errx.DescSetup) diff --git a/internal/cli/setup/platform.go b/internal/cli/setup/platform.go index 5fca9e9a..e3af1886 100644 --- a/internal/cli/setup/platform.go +++ b/internal/cli/setup/platform.go @@ -1863,24 +1863,6 @@ func ensureRepoManagedTraefikMiddlewareResources(kubectl core.KubectlRunner, log return nil } -func deploymentExistsWithKubectl(kubectl core.KubectlRunner, namespace, name string) (bool, error) { - cmd, err := kubectl.CommandArgs([]string{"get", "deployment", name, "-n", namespace, "-o", "jsonpath={.metadata.name}"}) - if err != nil { - return false, err - } - var stdout, stderr bytes.Buffer - cmd.SetStdout(&stdout) - cmd.SetStderr(&stderr) - if err := cmd.Run(); err == nil { - return strings.TrimSpace(stdout.String()) == name, nil - } - detail := strings.ToLower(strings.TrimSpace(stdout.String() + "\n" + stderr.String())) - if strings.Contains(detail, "not found") || strings.Contains(detail, "notfound") { - return false, nil - } - return false, fmt.Errorf("look up deployment %s/%s: %w", namespace, name, err) -} - func activeNamedTraefikDeploymentNamespacesWithKubectl(kubectl core.KubectlRunner) ([]string, error) { cmd, err := kubectl.CommandArgs([]string{ "get", "deployment", "-A", "--no-headers", @@ -1917,11 +1899,11 @@ func activeNamedTraefikDeploymentNamespacesWithKubectl(kubectl core.KubectlRunne func applyTraefikSupportManifest(kubectl core.KubectlRunner, relPath, namespace string) error { resolvedPath, err := assetpath.ResolveRepoAssetPath(relPath) if err != nil { - return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to resolve Traefik manifest %s: %v", relPath, err)) + return core.WrapWithSentinel(core.ErrReadIngressManifestFailed, err, fmt.Sprintf("failed to resolve Traefik manifest %s: %v", relPath, err)) } manifestBytes, err := kube.ReadFileAtPath(resolvedPath) if err != nil { - return core.WrapWithSentinel(core.ErrReadManagerYAMLFailed, err, fmt.Sprintf("failed to read Traefik manifest %s: %v", relPath, err)) + return core.WrapWithSentinel(core.ErrReadIngressManifestFailed, err, fmt.Sprintf("failed to read Traefik manifest %s: %v", relPath, err)) } manifestContent := strings.ReplaceAll(string(manifestBytes), "namespace: traefik", "namespace: "+namespace) if err := kube.ApplyManifestContent(kubectl.CommandArgs, manifestContent); err != nil { From af9e2a39fd0a7412e4744a01943f3a3a61556e48 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 09:35:40 +0000 Subject: [PATCH 4/7] test: format setup helper tests --- internal/cli/setup/helpers_test.go | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/cli/setup/helpers_test.go b/internal/cli/setup/helpers_test.go index 46db5f0f..be7a2395 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -939,6 +939,44 @@ data: } } +func TestRenderAnalyticsConfigManifestHandlesMissingConfigMap(t *testing.T) { + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + if commandHasArgs(spec, "get", "configmap", "mcp-sentinel-config", "-n", "mcp-sentinel", "-o", "json") { + return &core.MockCommand{ + Args: spec.Args, + OutputData: []byte("Error from server (NotFound): configmaps \"mcp-sentinel-config\" not found"), + OutputErr: errors.New("exit status 1"), + } + } + return &core.MockCommand{Args: spec.Args} + }, + } + kubectl := core.NewTestKubectlClient(mock) + + rendered, err := renderAnalyticsConfigManifest(kubectl, `apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-sentinel-config + namespace: mcp-sentinel +data: + PLATFORM_MODE: "tenant" +`, setupplan.PlatformModeTenant) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var payload struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal([]byte(rendered), &payload); err != nil { + t.Fatalf("unmarshal rendered config: %v", err) + } + if got := payload.Data["PLATFORM_MODE"]; got != setupplan.PlatformModeTenant { + t.Fatalf("expected tenant mode on fresh install, got %q", got) + } +} + func TestEnsureCSVIncludes(t *testing.T) { tests := []struct { name string From 1a3b833922dddbc9b83598bfd5d4eadfc052af30 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 09:47:00 +0000 Subject: [PATCH 5/7] doc: sync package reference for setup sentinel --- docs/internals/go-package-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index 57e537e3..a3c21b18 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -2769,6 +2769,7 @@ var ( ErrEnsureOperatorNamespaceFailed = newSentinelError("failed to ensure operator namespace", errx.CodeSetup, errx.DescSetup) ErrApplyRBACFailed = newSentinelError("failed to apply RBAC", errx.CodeSetup, errx.DescSetup) ErrReadManagerYAMLFailed = newSentinelError("failed to read manager.yaml", errx.CodeSetup, errx.DescSetup) + ErrReadIngressManifestFailed = newSentinelError("failed to read ingress manifest", errx.CodeSetup, errx.DescSetup) ErrParseManagerYAMLFailed = newSentinelError("failed to parse manager.yaml", errx.CodeSetup, errx.DescSetup) ErrSetOperatorImageFailed = newSentinelError("failed to set operator image", errx.CodeSetup, errx.DescSetup) ErrMutateManagerYAMLFailed = newSentinelError("failed to mutate manager.yaml", errx.CodeSetup, errx.DescSetup) From 290b3317217d8a7e14de90d1743d9625d9549752 Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 10:13:05 +0000 Subject: [PATCH 6/7] fix(setup): sync traefik and configmap handling --- internal/cli/setup/helpers_test.go | 19 ++++++++++++++++-- internal/cli/setup/platform.go | 31 +++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/internal/cli/setup/helpers_test.go b/internal/cli/setup/helpers_test.go index be7a2395..94cf96d8 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -889,11 +889,20 @@ data: } var payload struct { - Data map[string]string `yaml:"data"` + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]any `yaml:"metadata"` + Data map[string]string `yaml:"data"` } if err := yaml.Unmarshal([]byte(rendered), &payload); err != nil { t.Fatalf("unmarshal rendered config: %v", err) } + if payload.APIVersion != "v1" || payload.Kind != "ConfigMap" { + t.Fatalf("expected manifest envelope to be preserved, got apiVersion=%q kind=%q", payload.APIVersion, payload.Kind) + } + if got := payload.Metadata["name"]; got != "mcp-sentinel-config" { + t.Fatalf("expected metadata.name to be preserved, got %#v", got) + } if got := payload.Data["GOOGLE_CLIENT_ID"]; got != "client.apps.googleusercontent.com" { t.Fatalf("expected GOOGLE_CLIENT_ID to be preserved, got %q", got) } @@ -2113,7 +2122,7 @@ func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesMiddlewareSupportToEx case commandHasArgs(spec, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name"): cmd.OutputData = []byte("kube-system traefik\n") case commandHasArgs(spec, "get", "deployment", "traefik", "-n", "kube-system", "-o", "json"): - cmd.OutputData = []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"traefik","args":["--providers.kubernetesingress"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data"}]}}}}`) + cmd.OutputData = []byte(`{"spec":{"template":{"spec":{"containers":[{"name":"sidecar","args":[],"volumeMounts":[]},{"name":"traefik","args":["--providers.kubernetesingress"],"volumeMounts":[{"name":"data","mountPath":"/data"}]}],"volumes":[{"name":"data"}]}}}}`) } return cmd }, @@ -2132,6 +2141,12 @@ func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesMiddlewareSupportToEx for _, cmd := range mock.Commands { if commandHasArgs(cmd, "patch", "deployment", "traefik", "-n", "kube-system", "--type=json") { hasPatch = true + if idx := argIndex(cmd.Args, "-p"); idx != -1 && idx+1 < len(cmd.Args) { + patchBody := cmd.Args[idx+1] + if !strings.Contains(patchBody, "/spec/template/spec/containers/1/") { + t.Fatalf("expected patch to target traefik container index, got %s", patchBody) + } + } } } for _, cmd := range created { diff --git a/internal/cli/setup/platform.go b/internal/cli/setup/platform.go index e3af1886..13f8fa32 100644 --- a/internal/cli/setup/platform.go +++ b/internal/cli/setup/platform.go @@ -1950,48 +1950,58 @@ func patchTraefikDeploymentForFileMiddlewareSupport(kubectl core.KubectlRunner, if len(spec.Spec.Template.Spec.Containers) == 0 { return core.NewWithSentinel(core.ErrInstallIngressControllerFailed, fmt.Sprintf("traefik deployment in namespace %s has no containers", namespace)) } - container := spec.Spec.Template.Spec.Containers[0] + containerIndex := -1 + for i, candidate := range spec.Spec.Template.Spec.Containers { + if candidate.Name == "traefik" { + containerIndex = i + break + } + } + if containerIndex == -1 { + return core.NewWithSentinel(core.ErrInstallIngressControllerFailed, fmt.Sprintf("traefik deployment in namespace %s has no container named traefik", namespace)) + } + container := spec.Spec.Template.Spec.Containers[containerIndex] var ops []jsonPatchOperation if !containsString(container.Args, "--providers.file.filename=/etc/traefik/dynamic/dynamic.yml") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/args/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/args/-", containerIndex), Value: "--providers.file.filename=/etc/traefik/dynamic/dynamic.yml", }) } if !containsString(container.Args, "--providers.file.watch=true") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/args/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/args/-", containerIndex), Value: "--providers.file.watch=true", }) } if !containsString(container.Args, "--experimental.localplugins.pii-redactor.modulename=github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/args/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/args/-", containerIndex), Value: "--experimental.localplugins.pii-redactor.modulename=github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor", }) } if !hasVolumeMount(container.VolumeMounts, "traefik-dynamic", "/etc/traefik/dynamic") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/volumeMounts/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/volumeMounts/-", containerIndex), Value: map[string]any{"name": "traefik-dynamic", "mountPath": "/etc/traefik/dynamic", "readOnly": true}, }) } if !hasVolumeMount(container.VolumeMounts, "traefik-plugin-source", "/plugins-local/src/github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/volumeMounts/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/volumeMounts/-", containerIndex), Value: map[string]any{"name": "traefik-plugin-source", "mountPath": "/plugins-local/src/github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/pii-redactor", "readOnly": true}, }) } if !hasVolumeMount(container.VolumeMounts, "traefik-plugins", "/plugins-storage") { ops = append(ops, jsonPatchOperation{ Op: "add", - Path: "/spec/template/spec/containers/0/volumeMounts/-", + Path: fmt.Sprintf("/spec/template/spec/containers/%d/volumeMounts/-", containerIndex), Value: map[string]any{"name": "traefik-plugins", "mountPath": "/plugins-storage"}, }) } @@ -2282,7 +2292,10 @@ func renderAnalyticsManifest(content string, images AnalyticsImageSet, imagePull func renderAnalyticsConfigManifest(kubectl core.KubectlRunner, content, platformMode string) (string, error) { type configMapManifest struct { - Data map[string]string `yaml:"data"` + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]any `yaml:"metadata"` + Data map[string]string `yaml:"data"` } var manifest configMapManifest @@ -2335,7 +2348,7 @@ func existingConfigMapData(kubectl core.KubectlRunner, namespace, name string) ( if err != nil { return nil, err } - out, err := cmd.Output() + out, err := cmd.CombinedOutput() if err != nil { detail := strings.ToLower(strings.TrimSpace(string(out))) if strings.Contains(detail, "not found") || strings.Contains(detail, "notfound") { From 6014e76db5b8c8823cf7acf83dcc4c1dc9eb55ba Mon Sep 17 00:00:00 2001 From: Prince Roshan Date: Tue, 19 May 2026 10:20:58 +0000 Subject: [PATCH 7/7] fix(setup): tolerate empty configmap reads --- internal/cli/setup/helpers_test.go | 27 +++++++++++++++++++++++++++ internal/cli/setup/platform.go | 3 +++ 2 files changed, 30 insertions(+) diff --git a/internal/cli/setup/helpers_test.go b/internal/cli/setup/helpers_test.go index 94cf96d8..9c285d99 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -986,6 +986,33 @@ data: } } +func TestRenderAnalyticsConfigManifestHandlesEmptyConfigMapOutput(t *testing.T) { + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + cmd := &core.MockCommand{Args: spec.Args} + if commandHasArgs(spec, "get", "configmap", "mcp-sentinel-config", "-n", core.DefaultAnalyticsNamespace, "-o", "json") { + cmd.OutputData = []byte("") + } + return cmd + }, + } + kubectl := core.NewTestKubectlClient(mock) + + rendered, err := renderAnalyticsConfigManifest(kubectl, `apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-sentinel-config +data: + PLATFORM_MODE: "tenant" +`, setupplan.PlatformModeTenant) + if err != nil { + t.Fatalf("renderAnalyticsConfigManifest returned error: %v", err) + } + if !strings.Contains(rendered, `PLATFORM_MODE: tenant`) { + t.Fatalf("expected rendered config to preserve platform mode, got %q", rendered) + } +} + func TestEnsureCSVIncludes(t *testing.T) { tests := []struct { name string diff --git a/internal/cli/setup/platform.go b/internal/cli/setup/platform.go index 13f8fa32..8546607b 100644 --- a/internal/cli/setup/platform.go +++ b/internal/cli/setup/platform.go @@ -2356,6 +2356,9 @@ func existingConfigMapData(kubectl core.KubectlRunner, namespace, name string) ( } return nil, fmt.Errorf("read configmap %s/%s: %w", namespace, name, err) } + if strings.TrimSpace(string(out)) == "" { + return map[string]string{}, nil + } var payload struct { Data map[string]string `json:"data"` }