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) 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/helpers_test.go b/internal/cli/setup/helpers_test.go index 550e0859..9c285d99 100644 --- a/internal/cli/setup/helpers_test.go +++ b/internal/cli/setup/helpers_test.go @@ -858,6 +858,161 @@ 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 { + 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) + } + 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 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 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 @@ -1907,6 +2062,170 @@ func TestDeployOperatorManifestsWithKubectl(t *testing.T) { } } +func TestEnsureRepoManagedTraefikMiddlewareResourcesAppliesMiddlewareSupportToRepoManagedController(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) + }) + + 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("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 + }, + } + kubectl := core.NewTestKubectlClient(mock) + + if err := ensureRepoManagedTraefikMiddlewareResources(kubectl, zap.NewNop()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var ( + hasLookup bool + hasDynamicApply bool + hasPluginApply bool + hasPatch bool + ) + for _, cmd := range mock.Commands { + if commandHasArgs(cmd, "get", "deployment", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name") { + hasLookup = 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 active traefik deployment lookup") + } + if !hasDynamicApply { + t.Fatal("expected traefik dynamic-config apply") + } + if !hasPluginApply { + t.Fatal("expected traefik plugin-source 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":"sidecar","args":[],"volumeMounts":[]},{"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 + 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 { + 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") + } +} + +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", "-A", "--no-headers", "-o", "custom-columns=NS:.metadata.namespace,NAME:.metadata.name") { + cmd.OutputData = []byte("") + } + 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..8546607b 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" @@ -1467,6 +1468,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 +1699,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 +1841,264 @@ func deployAnalyticsManifestsWithKubectl(kubectl core.KubectlRunner, logger *zap return core.WrapWithSentinelAndContext(core.ErrOperatorDeploymentFailed, cause, msg, ctx) } +func ensureRepoManagedTraefikMiddlewareResources(kubectl core.KubectlRunner, logger *zap.Logger) error { + namespaces, err := activeNamedTraefikDeploymentNamespacesWithKubectl(kubectl) + if err != nil { + return err + } + for _, namespace := range namespaces { + if logger != nil { + logger.Info("Reconciling Traefik file-provider resources", zap.String("namespace", namespace)) + } + 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 +} + +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.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.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 { + 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)) + } + 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: 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: 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: 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: 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: 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: fmt.Sprintf("/spec/template/spec/containers/%d/volumeMounts/-", containerIndex), + 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) == "" { @@ -1935,7 +2203,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) } @@ -2017,6 +2290,87 @@ func renderAnalyticsManifest(content string, images AnalyticsImageSet, imagePull return rendered, nil } +func renderAnalyticsConfigManifest(kubectl core.KubectlRunner, content, platformMode string) (string, error) { + type configMapManifest struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]any `yaml:"metadata"` + 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.CombinedOutput() + 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) + } + if strings.TrimSpace(string(out)) == "" { + return map[string]string{}, nil + } + 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 {