diff --git a/operator/internal/webhook/injector/container_builder.go b/operator/internal/webhook/injector/container_builder.go index 62d17b0a..3e09bf61 100644 --- a/operator/internal/webhook/injector/container_builder.go +++ b/operator/internal/webhook/injector/container_builder.go @@ -100,7 +100,7 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled }, { Name: "authproxy-routes", - MountPath: "/etc/authproxy", + MountPath: AuthProxyMountPath, ReadOnly: true, }, { @@ -266,7 +266,7 @@ func (b *ContainerBuilder) buildProxySidecarContainer(spireEnabled bool, image, }, { Name: AuthproxyRoutesConfigMapName, - MountPath: "/etc/authproxy", + MountPath: AuthProxyMountPath, ReadOnly: true, }, } @@ -345,7 +345,7 @@ func (b *ContainerBuilder) buildEnvoyProxyEnvResolved() []corev1.EnvVar { {Name: "TARGET_SCOPES", Value: b.resolved.TargetScopes}, {Name: "CLIENT_ID_FILE", Value: "/shared/client-id.txt"}, {Name: "CLIENT_SECRET_FILE", Value: "/shared/client-secret.txt"}, - {Name: "ROUTES_CONFIG_PATH", Value: "/etc/authproxy/routes.yaml"}, + {Name: "ROUTES_CONFIG_PATH", Value: AuthProxyRoutesFile}, {Name: "DEFAULT_OUTBOUND_POLICY", Value: b.resolved.DefaultOutboundPolicy}, } } @@ -433,7 +433,7 @@ func (b *ContainerBuilder) buildEnvoyProxyEnvLegacy() []corev1.EnvVar { }, { Name: "ROUTES_CONFIG_PATH", - Value: "/etc/authproxy/routes.yaml", + Value: AuthProxyRoutesFile, }, { Name: "DEFAULT_OUTBOUND_POLICY", diff --git a/operator/internal/webhook/injector/namespace_config.go b/operator/internal/webhook/injector/namespace_config.go index 00d81332..0218e8fd 100644 --- a/operator/internal/webhook/injector/namespace_config.go +++ b/operator/internal/webhook/injector/namespace_config.go @@ -37,6 +37,12 @@ const ( AuthproxyRoutesConfigMapName = "authproxy-routes" ) +// AuthBridge sidecar container paths. +const ( + AuthProxyMountPath = "/etc/authproxy" + AuthProxyRoutesFile = AuthProxyMountPath + "/routes.yaml" +) + // NamespaceConfig holds resolved values from namespace ConfigMaps/Secrets. type NamespaceConfig struct { // From "authbridge-config" ConfigMap diff --git a/operator/internal/webhook/injector/pod_mutator.go b/operator/internal/webhook/injector/pod_mutator.go index 7a41df5b..16152c1d 100644 --- a/operator/internal/webhook/injector/pod_mutator.go +++ b/operator/internal/webhook/injector/pod_mutator.go @@ -773,7 +773,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp listenerOverrides["reverse_proxy_addr"] = fmt.Sprintf(":%d", originalAgentPort) listenerOverrides["reverse_proxy_backend"] = fmt.Sprintf("http://127.0.0.1:%d", newAgentPort) } - perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, + perAgentCMName, routesCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, ModeProxySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, listenerOverrides, mtlsMode, tlsBridgeMode, spireEnabled, agentRuntime) @@ -875,6 +875,12 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // requiredVolumes is always set above (resolved or legacy path) before // the mode switch, so it is never nil here. proxyVolumes := overrideAuthBridgeConfigMapInVolumes(requiredVolumes, perAgentCMName) + + // Override authproxy-routes volume if routes ConfigMap was created + if routesCMName != "" { + proxyVolumes = overrideRoutesConfigMapInVolumes(proxyVolumes, routesCMName) + } + for i := range proxyVolumes { if !volumeExists(podSpec.Volumes, proxyVolumes[i].Name) { podSpec.Volumes = append(podSpec.Volumes, proxyVolumes[i]) @@ -945,13 +951,18 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // data plane terminates the actual TLS — DownstreamTlsContext on the // inbound listener (gated on MTLSEnabled) and UpstreamTlsContext on // original_destination_tls (strict only). - perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, + perAgentCMName, routesCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil, mtlsMode, "", spireEnabled, agentRuntime) // bridge never runs under envoy-sidecar if err != nil { return false, fmt.Errorf("envoy-sidecar per-agent ConfigMap: %w", err) } requiredVolumes = overrideAuthBridgeConfigMapInVolumes(requiredVolumes, perAgentCMName) + // Override authproxy-routes volume if routes ConfigMap was created + if routesCMName != "" { + requiredVolumes = overrideRoutesConfigMapInVolumes(requiredVolumes, routesCMName) + } + resolvedForEnvoy := ResolveConfig(currentConfig, nsConfig) resolvedForEnvoy.MTLSMode = mtlsMode envoyCMName, err := m.ensurePerAgentEnvoyConfigMap(ctx, namespace, crName, resolvedForEnvoy) @@ -1174,7 +1185,7 @@ func (m *PodMutator) ensurePerAgentConfigMap( tlsBridgeMode string, spireEnabled bool, agentRuntime *agentv1alpha1.AgentRuntime, -) (string, error) { +) (configCMName string, routesCMName string, err error) { cmName := perAgentConfigMapName(crName) // Parse the base YAML into a generic map @@ -1263,10 +1274,14 @@ func (m *PodMutator) ensurePerAgentConfigMap( // Routes tell AuthBridge which audiences to request when calling specific // destinations. Routes are only effective when the namespace is configured // with SPIFFE authentication (CLIENT_AUTH_TYPE=federated-jwt). + // + // Routes are written to a separate ConfigMap and mounted at /etc/authproxy/routes.yaml. + // The config.yaml references the file path rather than containing routes inline. + var routesData []byte if agentRuntime != nil && agentRuntime.Spec.Auth != nil && len(agentRuntime.Spec.Auth.Outbound) > 0 { - // Navigate to pipeline.outbound.plugins[token-exchange].config + // Configure token-exchange plugin to read routes from file pipeline, _ := cfg["pipeline"].(map[string]interface{}) if pipeline == nil { mutatorLog.Info("WARN: no pipeline block found, cannot inject routes", @@ -1282,7 +1297,7 @@ func (m *PodMutator) ensurePerAgentConfigMap( mutatorLog.Info("WARN: no outbound plugins found, cannot inject routes", "namespace", namespace, "crName", crName) } else { - // Find the token-exchange plugin + // Find the token-exchange plugin and configure it to read routes from file for i := range plugins { plugin, _ := plugins[i].(map[string]interface{}) if plugin == nil { @@ -1296,41 +1311,71 @@ func (m *PodMutator) ensurePerAgentConfigMap( plugin["config"] = pluginConfig } - // Generate routes from spec.auth.outbound - routes := make([]interface{}, 0, len(agentRuntime.Spec.Auth.Outbound)) - for _, outboundRoute := range agentRuntime.Spec.Auth.Outbound { - route := map[string]interface{}{ - "audiences": outboundRoute.Audiences, - } - - // Add destination match (host or hostRegex) - destination := make(map[string]interface{}) - if outboundRoute.Destination.Host != "" { - destination["host"] = outboundRoute.Destination.Host - } - if outboundRoute.Destination.HostRegex != "" { - destination["hostRegex"] = outboundRoute.Destination.HostRegex - } - route["destination"] = destination - - routes = append(routes, route) + // Set routes to reference external file + pluginConfig["routes"] = map[string]interface{}{ + "file": AuthProxyRoutesFile, } - pluginConfig["routes"] = routes - mutatorLog.Info("injected token-exchange routes from AgentRuntime spec.auth", - "namespace", namespace, "crName", crName, "routeCount", len(routes)) + mutatorLog.Info("configured token-exchange to read routes from file", + "namespace", namespace, "crName", crName, "routeCount", len(agentRuntime.Spec.Auth.Outbound)) break } } } } } + + // Generate routes.yaml content in AuthBridge's routing.Route format: + // - host: "hostname" (flat, not nested under destination) + // - target_audience: "audience" (single string, not audiences array) + routes := make([]interface{}, 0, len(agentRuntime.Spec.Auth.Outbound)) + for _, outboundRoute := range agentRuntime.Spec.Auth.Outbound { + route := make(map[string]interface{}) + + // Host or HostRegex (flat fields, not nested) + if outboundRoute.Destination.Host != "" { + route["host"] = outboundRoute.Destination.Host + } + if outboundRoute.Destination.HostRegex != "" { + // AuthBridge router uses glob patterns, not regex. The CRD field name + // "hostRegex" is misleading - it should contain glob syntax (*.example.com), + // not regex syntax (.*\.example\.com). Warn users about this. + mutatorLog.Info("hostRegex field is mapped to AuthBridge glob pattern (not regex)", + "namespace", namespace, "crName", crName, + "hostRegex", outboundRoute.Destination.HostRegex, + "note", "use glob syntax like '*.team1.svc.cluster.local', not regex '.*\\.team1\\.svc\\.cluster\\.local'") + route["host"] = outboundRoute.Destination.HostRegex + } + + // target_audience is a single string, not array + // Take first audience if multiple specified + if len(outboundRoute.Audiences) > 0 { + route["target_audience"] = outboundRoute.Audiences[0] + + // Warn if multiple audiences specified (only first is used) + // See https://github.com/rossoctl/operator/issues/518 + if len(outboundRoute.Audiences) > 1 { + mutatorLog.Info("multiple audiences specified but only first will be used", + "namespace", namespace, "crName", crName, + "route", outboundRoute.Destination.Host, + "audiences", outboundRoute.Audiences, + "using", outboundRoute.Audiences[0]) + } + } + + routes = append(routes, route) + } + + routesData, err = yaml.Marshal(routes) + if err != nil { + return "", "", fmt.Errorf("failed to marshal routes for %s/%s: %w", namespace, crName, err) + } } // Marshal back to YAML data, err := yaml.Marshal(cfg) if err != nil { - return "", fmt.Errorf("failed to marshal per-agent config for %s/%s: %w", namespace, crName, err) + return "", "", fmt.Errorf("failed to marshal per-agent config for %s/%s: %w", namespace, crName, err) } // Server-side apply: atomic create-or-update in a single API call. @@ -1346,12 +1391,32 @@ func (m *PodMutator) ensurePerAgentConfigMap( } if err := m.Client.Apply(ctx, cmApply, client.FieldOwner("rossoctl-webhook"), client.ForceOwnership); err != nil { - return "", fmt.Errorf("failed to apply per-agent ConfigMap %s/%s: %w", namespace, cmName, err) + return "", "", fmt.Errorf("failed to apply per-agent ConfigMap %s/%s: %w", namespace, cmName, err) } mutatorLog.Info("Applied per-agent ConfigMap", "namespace", namespace, "name", cmName, "mode", mode, "mtlsMode", mtlsMode) - return cmName, nil + // Create separate routes ConfigMap if routes are present + if len(routesData) > 0 { + routesCMName := "authbridge-routes-" + crName + routesCMApply := applyconfigscorev1.ConfigMap(routesCMName, namespace). + WithLabels(map[string]string{managedByLabel: managedByValue}). + WithData(map[string]string{"routes.yaml": string(routesData)}) + + // Set same OwnerReference for garbage collection + if ownerRef := m.buildOwnerReference(ctx, namespace, crName); ownerRef != nil { + routesCMApply = routesCMApply.WithOwnerReferences(ownerRef) + } + + if err := m.Client.Apply(ctx, routesCMApply, client.FieldOwner("rossoctl-webhook"), client.ForceOwnership); err != nil { + return "", "", fmt.Errorf("failed to apply routes ConfigMap %s/%s: %w", namespace, routesCMName, err) + } + mutatorLog.Info("Applied routes ConfigMap", + "namespace", namespace, "name", routesCMName, "routeCount", len(agentRuntime.Spec.Auth.Outbound)) + return cmName, routesCMName, nil + } + + return cmName, "", nil } // ensurePerAgentEnvoyConfigMap renders an envoy.yaml from the diff --git a/operator/internal/webhook/injector/pod_mutator_auth_test.go b/operator/internal/webhook/injector/pod_mutator_auth_test.go index eacfb60f..34384385 100644 --- a/operator/internal/webhook/injector/pod_mutator_auth_test.go +++ b/operator/internal/webhook/injector/pod_mutator_auth_test.go @@ -126,7 +126,7 @@ pipeline: } // Call ensurePerAgentConfigMap with the AgentRuntime - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-agent", + cmName, routesCMName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-agent", ModeProxySidecar, baseYAML, nsConfig, nil, "", "", true, agentRuntime) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -181,9 +181,33 @@ pipeline: t.Fatal("token-exchange plugin not found") } - routes, ok := tokenExchangeConfig["routes"].([]interface{}) + // Verify routes is a file reference, not an inline array + routesRef, ok := tokenExchangeConfig["routes"].(map[string]interface{}) if !ok { - t.Fatal("routes not found or not an array") + t.Fatal("routes not found or not a map") + } + if routesRef["file"] != AuthProxyRoutesFile { + t.Errorf("routes file mismatch: got %v, want %s", routesRef["file"], AuthProxyRoutesFile) + } + + // Fetch the routes ConfigMap + if routesCMName == "" { + t.Fatal("routesCMName is empty") + } + routesCM := &corev1.ConfigMap{} + if err := fakeClient.Get(ctx, client.ObjectKey{Namespace: "team1", Name: routesCMName}, routesCM); err != nil { + t.Fatalf("failed to get routes ConfigMap: %v", err) + } + + // Parse routes.yaml + routesYAML, ok := routesCM.Data["routes.yaml"] + if !ok { + t.Fatal("routes ConfigMap missing routes.yaml key") + } + + var routes []interface{} + if err := yaml.Unmarshal([]byte(routesYAML), &routes); err != nil { + t.Fatalf("failed to parse routes.yaml: %v", err) } // Verify we have 2 routes @@ -191,26 +215,22 @@ pipeline: t.Fatalf("expected 2 routes, got %d", len(routes)) } - // Verify first route (exact host match) + // Verify first route (exact host match) - now in flat format route1, _ := routes[0].(map[string]interface{}) - dest1, _ := route1["destination"].(map[string]interface{}) - if dest1["host"] != "weather-tool-mcp.team1.svc.cluster.local" { - t.Errorf("route 1 host mismatch: got %v", dest1["host"]) + if route1["host"] != "weather-tool-mcp.team1.svc.cluster.local" { + t.Errorf("route 1 host mismatch: got %v", route1["host"]) } - audiences1, _ := route1["audiences"].([]interface{}) - if len(audiences1) != 1 || audiences1[0] != "spiffe://localtest.me/ns/team1/sa/weather-tool" { - t.Errorf("route 1 audiences mismatch: got %v", audiences1) + if route1["target_audience"] != "spiffe://localtest.me/ns/team1/sa/weather-tool" { + t.Errorf("route 1 target_audience mismatch: got %v", route1["target_audience"]) } - // Verify second route (regex match) + // Verify second route (regex match) - now in flat format route2, _ := routes[1].(map[string]interface{}) - dest2, _ := route2["destination"].(map[string]interface{}) - if dest2["hostRegex"] != `.*\.team1\.svc\.cluster\.local` { - t.Errorf("route 2 hostRegex mismatch: got %v", dest2["hostRegex"]) + if route2["host"] != `.*\.team1\.svc\.cluster\.local` { + t.Errorf("route 2 host mismatch: got %v", route2["host"]) } - audiences2, _ := route2["audiences"].([]interface{}) - if len(audiences2) != 1 || audiences2[0] != "spiffe://localtest.me/ns/team1/sa/default" { - t.Errorf("route 2 audiences mismatch: got %v", audiences2) + if route2["target_audience"] != "spiffe://localtest.me/ns/team1/sa/default" { + t.Errorf("route 2 target_audience mismatch: got %v", route2["target_audience"]) } } @@ -250,7 +270,7 @@ pipeline: nsConfig := &NamespaceConfig{} // Call with nil agentRuntime - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", ModeProxySidecar, baseYAML, nsConfig, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/operator/internal/webhook/injector/pod_mutator_test.go b/operator/internal/webhook/injector/pod_mutator_test.go index eee6bd31..357dde77 100644 --- a/operator/internal/webhook/injector/pod_mutator_test.go +++ b/operator/internal/webhook/injector/pod_mutator_test.go @@ -1295,7 +1295,7 @@ func TestEnsurePerAgentConfigMap_EmptyBaseYAML_FallbackFromNsConfig(t *testing.T ClientAuthType: "client-secret", } - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", ModeProxySidecar, "", nsConfig, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1412,7 +1412,7 @@ pipeline: type: spiffe ` - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", ModeEnvoySidecar, baseYAML, &NamespaceConfig{}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1471,7 +1471,7 @@ pipeline: "forward_proxy_addr": ":8081", } - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", ModeProxySidecar, baseYAML, &NamespaceConfig{}, overrides, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1507,7 +1507,7 @@ func TestEnsurePerAgentConfigMap_ExistingCM_OwnedByWebhook_Updated(t *testing.T) m := newTestMutator(existingCM) ctx := context.Background() - _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + _, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1535,7 +1535,7 @@ func TestEnsurePerAgentConfigMap_ExistingCM_OverwrittenBySSA(t *testing.T) { m := newTestMutator(existingCM) ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-agent", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1563,7 +1563,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_SetFromDeployment(t *testing.T) m := newTestMutator(deploy) ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "weather-service", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1590,7 +1590,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_SetFromStatefulSet(t *testing.T) m := newTestMutator(sts) ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-stateful-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-stateful-agent", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1631,7 +1631,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_SetFromSandbox(t *testing.T) { } ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-sandbox-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "my-sandbox-agent", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1652,7 +1652,7 @@ func TestEnsurePerAgentConfigMap_OwnerReference_NoWorkload_Skipped(t *testing.T) m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "bare-pod-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "bare-pod-agent", ModeEnvoySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1675,7 +1675,7 @@ func TestEnsurePerAgentConfigMap_FederatedJWT_MapsToSpiffe(t *testing.T) { ClientAuthType: "federated-jwt", } - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", ModeEnvoySidecar, "", nsConfig, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1712,7 +1712,7 @@ func TestEnsurePerAgentConfigMap_FederatedJWT_SetsJWTAudience(t *testing.T) { JWTAudience: "http://keycloak:8080/realms/rossoctl", } - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", ModeEnvoySidecar, "", nsConfig, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1735,7 +1735,7 @@ func TestEnsurePerAgentConfigMap_SpireEnabled_InjectsSpiffeBlock(t *testing.T) { m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "spiffe-agent", ModeProxySidecar, "", &NamespaceConfig{}, nil, "", "", true, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1757,7 +1757,7 @@ func TestEnsurePerAgentConfigMap_SpireDisabled_NoSpiffeBlock(t *testing.T) { m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-spiffe-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-spiffe-agent", ModeProxySidecar, "", &NamespaceConfig{}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1788,7 +1788,7 @@ func TestEnsurePerAgentConfigMap_MTLSStrict_RendersBlock(t *testing.T) { m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeStrict, "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1819,7 +1819,7 @@ func TestEnsurePerAgentConfigMap_MTLSPermissive_RendersBlock(t *testing.T) { m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "mtls-agent", ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModePermissive, "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1854,7 +1854,7 @@ func TestEnsurePerAgentConfigMap_MTLSDisabled_OmitsBlock(t *testing.T) { m := newTestMutator() ctx := context.Background() - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-mtls-"+tt.name, + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-mtls-"+tt.name, ModeProxySidecar, "", &NamespaceConfig{ClientAuthType: "client-secret"}, nil, tt.mtlsMode, "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1882,7 +1882,7 @@ func TestEnsurePerAgentConfigMap_MTLSScrubsStaleBlock(t *testing.T) { // ConfigMap that was rendered earlier with mtls on. baseYAML := "mode: proxy-sidecar\nmtls:\n mode: strict\n" - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "scrub-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "scrub-agent", ModeProxySidecar, baseYAML, &NamespaceConfig{ClientAuthType: "client-secret"}, nil, MTLSModeDisabled, "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -2103,7 +2103,7 @@ func TestEnsurePerAgentConfigMap_TLSBridgeBlock(t *testing.T) { ctx := context.Background() // enabled => tls_bridge: {mode: enabled, ca_dir: } - cmName, err := m.ensurePerAgentConfigMap(ctx, "team1", "bridge-agent", + cmName, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "bridge-agent", ModeProxySidecar, "", &NamespaceConfig{}, nil, "", "enabled", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -2121,7 +2121,7 @@ func TestEnsurePerAgentConfigMap_TLSBridgeBlock(t *testing.T) { } // disabled ("") => no tls_bridge block - cmName2, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-bridge", + cmName2, _, err := m.ensurePerAgentConfigMap(ctx, "team1", "no-bridge", ModeProxySidecar, "", &NamespaceConfig{}, nil, "", "", false, nil) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/operator/internal/webhook/injector/volume_builder.go b/operator/internal/webhook/injector/volume_builder.go index c69fdaaa..cf0e581a 100644 --- a/operator/internal/webhook/injector/volume_builder.go +++ b/operator/internal/webhook/injector/volume_builder.go @@ -355,6 +355,24 @@ func overrideAuthBridgeConfigMapInVolumes(volumes []corev1.Volume, cmName string return result } +// overrideRoutesConfigMapInVolumes returns a copy of the volume list with +// the authproxy-routes volume pointing at the given ConfigMap name. Used when +// AgentRuntime has spec.auth.outbound configured: the per-agent routes live +// in authbridge-routes-, replacing the namespace-level "authproxy-routes". +func overrideRoutesConfigMapInVolumes(volumes []corev1.Volume, routesCMName string) []corev1.Volume { + result := make([]corev1.Volume, len(volumes)) + copy(result, volumes) + for i := range result { + if result[i].Name == AuthproxyRoutesConfigMapName && result[i].ConfigMap != nil { + cmCopy := *result[i].ConfigMap + cmCopy.Name = routesCMName + cmCopy.Optional = ptr.To(false) // Routes are required when specified + result[i].ConfigMap = &cmCopy + } + } + return result +} + // overrideEnvoyConfigMapInVolumes returns a copy of the volume list with // the envoy-config volume pointing at the given ConfigMap name. Used by // the envoy-sidecar mTLS path: the rendered per-agent envoy.yaml lives diff --git a/operator/internal/webhook/injector/volume_builder_test.go b/operator/internal/webhook/injector/volume_builder_test.go index af34e54f..14608da3 100644 --- a/operator/internal/webhook/injector/volume_builder_test.go +++ b/operator/internal/webhook/injector/volume_builder_test.go @@ -35,7 +35,7 @@ func TestBuildResolvedVolumes_SpireDisabled(t *testing.T) { names[v.Name] = true } - for _, expected := range []string{"shared-data", "envoy-config", "authproxy-routes", "authbridge-runtime-config"} { + for _, expected := range []string{"shared-data", "envoy-config", AuthproxyRoutesConfigMapName, "authbridge-runtime-config"} { if !names[expected] { t.Errorf("missing volume %q", expected) } @@ -62,7 +62,7 @@ func TestBuildResolvedVolumes_SpireEnabled(t *testing.T) { names[v.Name] = true } - for _, expected := range []string{"shared-data", "spire-agent-socket", "spiffe-helper-config", "svid-output", "envoy-config", "authproxy-routes", "authbridge-runtime-config"} { + for _, expected := range []string{"shared-data", "spire-agent-socket", "spiffe-helper-config", "svid-output", "envoy-config", AuthproxyRoutesConfigMapName, "authbridge-runtime-config"} { if !names[expected] { t.Errorf("missing volume %q", expected) } @@ -246,3 +246,77 @@ func TestOverrideEnvoyConfigMapInVolumes(t *testing.T) { }) } } + +// TestOverrideRoutesConfigMapInVolumes verifies the per-agent routes override: +// when AgentRuntime has spec.auth.outbound routes, the authproxy-routes volume +// is redirected to authbridge-routes- without mutating the input. +func TestOverrideRoutesConfigMapInVolumes(t *testing.T) { + tests := []struct { + name string + volumes func() []corev1.Volume + newCM string + found bool + }{ + { + name: "volume found, name swapped", + volumes: func() []corev1.Volume { + return BuildRequiredVolumes() + }, + newCM: "authbridge-routes-my-agent", + found: true, + }, + { + name: "no authproxy-routes volume, list unchanged", + volumes: func() []corev1.Volume { + return []corev1.Volume{{ + Name: "shared-data", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }} + }, + newCM: "authbridge-routes-my-agent", + found: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := tt.volumes() + overridden := overrideRoutesConfigMapInVolumes(original, tt.newCM) + + // Original must not be mutated + for _, v := range original { + if v.Name == AuthproxyRoutesConfigMapName && v.ConfigMap != nil { + if v.ConfigMap.Name != AuthproxyRoutesConfigMapName { + t.Errorf("original was mutated: got %q", v.ConfigMap.Name) + } + } + } + + // Output length matches input length + if len(overridden) != len(original) { + t.Fatalf("overridden length = %d, want %d", len(overridden), len(original)) + } + + // Find-and-swap behavior + swappedFound := false + for _, v := range overridden { + if v.Name == AuthproxyRoutesConfigMapName && v.ConfigMap != nil { + swappedFound = true + if v.ConfigMap.Name != tt.newCM { + t.Errorf("%s CM name = %q, want %q", AuthproxyRoutesConfigMapName, v.ConfigMap.Name, tt.newCM) + } + // Verify Optional is set to false for per-agent routes + if v.ConfigMap.Optional == nil || *v.ConfigMap.Optional != false { + t.Errorf("%s Optional should be false when overridden", AuthproxyRoutesConfigMapName) + } + } + } + if tt.found && !swappedFound { + t.Fatalf("expected %s volume in overridden but didn't find it", AuthproxyRoutesConfigMapName) + } + if !tt.found && swappedFound { + t.Fatalf("%s volume should not have been added", AuthproxyRoutesConfigMapName) + } + }) + } +}