diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index 8e71e5b4..1c26c5a5 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -120,11 +120,14 @@ signatureVerification: # Feature gates — highest-priority layer in the injection precedence chain. # Set globalEnabled to false to disable ALL sidecar injection (kill switch). # Set individual gates to false to disable specific sidecars cluster-wide. +# +# spiffe-helper and client-registration no longer have feature gates: +# * spiffe-helper is bundled inside the combined authbridge images and +# gated per-workload by the SPIRE_ENABLED env var. +# * client-registration is operator-managed (no in-pod sidecar). featureGates: globalEnabled: true envoyProxy: true - spiffeHelper: true - clientRegistration: true # injectTools controls whether tool workloads (kagenti.io/type=tool) receive # sidecar injection. Defaults to false — tools are not injected by default. injectTools: false @@ -133,23 +136,24 @@ featureGates: # Default false — cached mode is faster and sufficient when namespace ConfigMaps # rarely change. Cache is cleared on webhook pod restart. perWorkloadConfigResolution: false - # combinedSidecar controls whether injection uses a single combined authbridge - # container instead of separate envoy-proxy + spiffe-helper + client-registration. - # Requires the authbridge image. Default false — separate sidecars. - combinedSidecar: false # Platform defaults for AuthBridge sidecar injection. # These are the lowest-priority layer — overridden by feature gates, # namespace labels/ConfigMaps, and workload labels/AgentRuntime CRs. +# +# Three combined images, selected per workload by deployment mode: +# * envoyProxy (envoy-sidecar mode): Envoy + ext_proc authbridge + spiffe-helper. +# * authbridge (proxy-sidecar mode, default): authbridge-proxy (full plugin set) + spiffe-helper. +# * authbridgeLite (lite mode): authbridge-lite (jwt-validation + token-exchange only, +# parsers dropped) + spiffe-helper. Same listener layout +# as authbridge; for size-constrained deployments. +# proxy-init applies to envoy-sidecar mode only. defaults: - # Container images with version tags images: envoyProxy: ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest - authbridgeLight: ghcr.io/kagenti/kagenti-extensions/authbridge-light:latest - proxyInit: ghcr.io/kagenti/kagenti-extensions/proxy-init:latest - spiffeHelper: ghcr.io/kagenti/kagenti-extensions/spiffe-helper:latest - clientRegistration: ghcr.io/kagenti/kagenti-extensions/client-registration:latest authbridge: ghcr.io/kagenti/kagenti-extensions/authbridge:latest + authbridgeLite: ghcr.io/kagenti/kagenti-extensions/authbridge-lite:latest + proxyInit: ghcr.io/kagenti/kagenti-extensions/proxy-init:latest pullPolicy: IfNotPresent # Proxy settings @@ -176,20 +180,6 @@ defaults: limits: cpu: 10m memory: 10Mi - spiffeHelper: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 100m - memory: 128Mi - clientRegistration: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 100m - memory: 128Mi authbridge: requests: cpu: 100m @@ -197,13 +187,3 @@ defaults: limits: cpu: 300m memory: 384Mi - - # Per-sidecar enable/disable at the platform level (lowest-priority layer). - # These are overridden by feature gates, namespace labels, and workload labels. - sidecars: - envoyProxy: - enabled: true - spiffeHelper: - enabled: true - clientRegistration: - enabled: true diff --git a/kagenti-operator/api/v1alpha1/agentruntime_types.go b/kagenti-operator/api/v1alpha1/agentruntime_types.go index 69bc1a9d..f658b9d2 100644 --- a/kagenti-operator/api/v1alpha1/agentruntime_types.go +++ b/kagenti-operator/api/v1alpha1/agentruntime_types.go @@ -60,6 +60,36 @@ type AgentRuntimeSpec struct { // Trace specifies optional per-workload observability overrides // +optional Trace *TraceSpec `json:"trace,omitempty"` + + // AuthBridgeMode selects the deployment shape for this workload's + // authbridge sidecar. When unset, the namespace-level + // authbridge-runtime-config ConfigMap's mode is used; if that is + // also unset, the operator falls back to "proxy-sidecar". + // + // Four valid values: + // + // proxy-sidecar HTTP_PROXY env + authbridge-proxy (full plugin + // set, including a2a/mcp/inference parsers) + + // spiffe-helper bundled. No Envoy, no iptables. + // Default mode. + // envoy-sidecar Envoy + ext_proc authbridge + spiffe-helper + // bundled. Requires the proxy-init iptables + // container. + // lite Same listener layout as proxy-sidecar but uses + // the authbridge-lite image (jwt-validation + + // token-exchange only, parsers dropped to shrink + // the binary). For size-constrained deployments + // that don't need protocol-aware abctl events. + // waypoint Standalone deployment, not injected as a + // sidecar. Used by Istio ambient mesh. + // + // Set this when a single workload needs a different shape than the + // namespace default. Most deployments leave it unset and let the + // namespace ConfigMap drive the choice. + // + // +optional + // +kubebuilder:validation:Enum=proxy-sidecar;envoy-sidecar;lite;waypoint + AuthBridgeMode string `json:"authBridgeMode,omitempty"` } // IdentitySpec configures workload identity for an AgentRuntime. diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index ebf4feb0..096c1823 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -190,8 +190,8 @@ func main() { setupLog.Info("Feature gates updated", "globalEnabled", fg.GlobalEnabled, "envoyProxy", fg.EnvoyProxy, - "spiffeHelper", fg.SpiffeHelper, - "clientRegistration", fg.ClientRegistration) + "injectTools", fg.InjectTools, + "perWorkloadConfigResolution", fg.PerWorkloadConfigResolution) }) if err := featureGateLoader.Watch(ctx); err != nil { setupLog.Error(err, "Failed to start feature gates watcher") @@ -425,12 +425,9 @@ func main() { // AuthBridge sidecar injection webhook if authBridgeWebhooksEnabled() { - // Pass false to disable legacy client-registration sidecar injection. - // Client registration is now handled by the operator controller. podMutator := injector.NewPodMutator( mgr.GetClient(), mgr.GetAPIReader(), - false, // disableLegacyClientRegistrationSidecar configLoader.Get, featureGateLoader.Get, ) diff --git a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml index 97f3b0f1..6cfac75c 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml @@ -61,6 +61,39 @@ spec: spec: description: AgentRuntimeSpec defines the desired state of AgentRuntime. properties: + authBridgeMode: + description: |- + AuthBridgeMode selects the deployment shape for this workload's + authbridge sidecar. When unset, the namespace-level + authbridge-runtime-config ConfigMap's mode is used; if that is + also unset, the operator falls back to "proxy-sidecar". + + Four valid values: + + proxy-sidecar HTTP_PROXY env + authbridge-proxy (full plugin + set, including a2a/mcp/inference parsers) + + spiffe-helper bundled. No Envoy, no iptables. + Default mode. + envoy-sidecar Envoy + ext_proc authbridge + spiffe-helper + bundled. Requires the proxy-init iptables + container. + lite Same listener layout as proxy-sidecar but uses + the authbridge-lite image (jwt-validation + + token-exchange only, parsers dropped to shrink + the binary). For size-constrained deployments + that don't need protocol-aware abctl events. + waypoint Standalone deployment, not injected as a + sidecar. Used by Istio ambient mesh. + + Set this when a single workload needs a different shape than the + namespace default. Most deployments leave it unset and let the + namespace ConfigMap drive the choice. + enum: + - proxy-sidecar + - envoy-sidecar + - lite + - waypoint + type: string identity: description: Identity specifies optional per-workload identity overrides properties: diff --git a/kagenti-operator/internal/webhook/config/defaults.go b/kagenti-operator/internal/webhook/config/defaults.go index 962f582b..1b824a77 100644 --- a/kagenti-operator/internal/webhook/config/defaults.go +++ b/kagenti-operator/internal/webhook/config/defaults.go @@ -11,13 +11,21 @@ func CompiledDefaults() *PlatformConfig { // Compiled defaults are overridden at runtime by the platform-config // ConfigMap (kagenti-platform-config). These serve as fallbacks only. Images: ImageConfig{ - EnvoyProxy: "ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest", - AuthBridgeLight: "ghcr.io/kagenti/kagenti-extensions/authbridge-light:latest", - ProxyInit: "ghcr.io/kagenti/kagenti-extensions/proxy-init:latest", - SpiffeHelper: "ghcr.io/kagenti/kagenti-extensions/spiffe-helper:latest", - ClientRegistration: "ghcr.io/kagenti/kagenti-extensions/client-registration:latest", - AuthBridge: "ghcr.io/kagenti/kagenti-extensions/authbridge:latest", - PullPolicy: corev1.PullIfNotPresent, + // authbridge-envoy: combined image for envoy-sidecar mode + // (Envoy + ext_proc authbridge + spiffe-helper bundled). + EnvoyProxy: "ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest", + // authbridge: combined image for proxy-sidecar mode (default + // deployment shape) — authbridge-proxy + spiffe-helper + // bundled, no Envoy, no gRPC. + AuthBridge: "ghcr.io/kagenti/kagenti-extensions/authbridge:latest", + // authbridge-lite: size-optimized variant for the "lite" + // mode. Same listener layout as AuthBridge but parsers + // (a2a/mcp/inference) are dropped. + AuthBridgeLite: "ghcr.io/kagenti/kagenti-extensions/authbridge-lite:latest", + // proxy-init: iptables init container, used by + // envoy-sidecar mode only. + ProxyInit: "ghcr.io/kagenti/kagenti-extensions/proxy-init:latest", + PullPolicy: corev1.PullIfNotPresent, }, Proxy: ProxyConfig{ Port: 15123, @@ -46,26 +54,6 @@ func CompiledDefaults() *PlatformConfig { corev1.ResourceMemory: resource.MustParse("10Mi"), }, }, - SpiffeHelper: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - corev1.ResourceMemory: resource.MustParse("64Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - corev1.ResourceMemory: resource.MustParse("128Mi"), - }, - }, - ClientRegistration: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("50m"), - corev1.ResourceMemory: resource.MustParse("64Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - corev1.ResourceMemory: resource.MustParse("128Mi"), - }, - }, AuthBridge: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), @@ -89,10 +77,5 @@ func CompiledDefaults() *PlatformConfig { EnableMetrics: true, EnableTracing: false, }, - Sidecars: SidecarDefaults{ - EnvoyProxy: SidecarDefault{Enabled: true}, - SpiffeHelper: SidecarDefault{Enabled: true}, - ClientRegistration: SidecarDefault{Enabled: true}, - }, } } diff --git a/kagenti-operator/internal/webhook/config/feature_gate_loader.go b/kagenti-operator/internal/webhook/config/feature_gate_loader.go index 10329584..54ad071d 100644 --- a/kagenti-operator/internal/webhook/config/feature_gate_loader.go +++ b/kagenti-operator/internal/webhook/config/feature_gate_loader.go @@ -178,11 +178,8 @@ func logFeatureGates(fg *FeatureGates, source string) { log.Info("[feature-gates] gates", "globalEnabled", fg.GlobalEnabled, "envoyProxy", fg.EnvoyProxy, - "spiffeHelper", fg.SpiffeHelper, - "clientRegistration", fg.ClientRegistration, "injectTools", fg.InjectTools, "perWorkloadConfigResolution", fg.PerWorkloadConfigResolution, - "combinedSidecar", fg.CombinedSidecar, ) log.Info("=============================================") } diff --git a/kagenti-operator/internal/webhook/config/feature_gates.go b/kagenti-operator/internal/webhook/config/feature_gates.go index 105c33bd..f6e8eb56 100644 --- a/kagenti-operator/internal/webhook/config/feature_gates.go +++ b/kagenti-operator/internal/webhook/config/feature_gates.go @@ -2,11 +2,17 @@ package config // FeatureGates controls which sidecars are globally enabled/disabled. // This is the highest-priority layer in the injection precedence chain. +// +// Spiffe-helper and client-registration are no longer separate-sidecar +// features: +// - spiffe-helper is bundled inside the EnvoyProxy and AuthBridge +// combined images and starts conditionally on the per-workload +// SPIRE_ENABLED env var. +// - client-registration is now operator-managed entirely (the in-pod +// sidecar path is gone). See operator-managed-client-registration.md. type FeatureGates struct { - GlobalEnabled bool `json:"globalEnabled" yaml:"globalEnabled"` - EnvoyProxy bool `json:"envoyProxy" yaml:"envoyProxy"` - SpiffeHelper bool `json:"spiffeHelper" yaml:"spiffeHelper"` - ClientRegistration bool `json:"clientRegistration" yaml:"clientRegistration"` + GlobalEnabled bool `json:"globalEnabled" yaml:"globalEnabled"` + EnvoyProxy bool `json:"envoyProxy" yaml:"envoyProxy"` // InjectTools controls whether tool workloads (kagenti.io/type=tool) receive // sidecar injection. Defaults to false — tools are not injected by default. InjectTools bool `json:"injectTools" yaml:"injectTools"` @@ -16,9 +22,6 @@ type FeatureGates struct { // true → resolved path: webhook reads namespace ConfigMaps at // admission time and injects literal env var values. PerWorkloadConfigResolution bool `json:"perWorkloadConfigResolution" yaml:"perWorkloadConfigResolution"` - // CombinedSidecar controls whether injection uses a single combined authbridge - // container instead of separate envoy-proxy + spiffe-helper + client-registration sidecars. - CombinedSidecar bool `json:"combinedSidecar" yaml:"combinedSidecar"` } // DefaultFeatureGates returns feature gates with sidecar injection enabled for @@ -27,11 +30,8 @@ func DefaultFeatureGates() *FeatureGates { return &FeatureGates{ GlobalEnabled: true, EnvoyProxy: true, - SpiffeHelper: true, - ClientRegistration: true, InjectTools: false, PerWorkloadConfigResolution: false, - CombinedSidecar: false, } } diff --git a/kagenti-operator/internal/webhook/config/loader.go b/kagenti-operator/internal/webhook/config/loader.go index 4f8958d4..3a2d8167 100644 --- a/kagenti-operator/internal/webhook/config/loader.go +++ b/kagenti-operator/internal/webhook/config/loader.go @@ -189,9 +189,9 @@ func logConfig(cfg *PlatformConfig, source string) { log.Info("[config] source", "source", source) log.Info("[config] images", "envoyProxy", cfg.Images.EnvoyProxy, + "authBridge", cfg.Images.AuthBridge, + "authBridgeLite", cfg.Images.AuthBridgeLite, "proxyInit", cfg.Images.ProxyInit, - "spiffeHelper", cfg.Images.SpiffeHelper, - "clientRegistration", cfg.Images.ClientRegistration, "pullPolicy", cfg.Images.PullPolicy, ) log.Info("[config] proxy", @@ -208,13 +208,9 @@ func logConfig(cfg *PlatformConfig, source string) { "requests", cfg.Resources.ProxyInit.Requests, "limits", cfg.Resources.ProxyInit.Limits, ) - log.Info("[config] resources.spiffeHelper", - "requests", cfg.Resources.SpiffeHelper.Requests, - "limits", cfg.Resources.SpiffeHelper.Limits, - ) - log.Info("[config] resources.clientRegistration", - "requests", cfg.Resources.ClientRegistration.Requests, - "limits", cfg.Resources.ClientRegistration.Limits, + log.Info("[config] resources.authBridge", + "requests", cfg.Resources.AuthBridge.Requests, + "limits", cfg.Resources.AuthBridge.Limits, ) log.Info("[config] tokenExchange", "tokenUrl", cfg.TokenExchange.TokenURL, @@ -225,10 +221,5 @@ func logConfig(cfg *PlatformConfig, source string) { "trustDomain", cfg.Spiffe.TrustDomain, "socketPath", cfg.Spiffe.SocketPath, ) - log.Info("[config] sidecars", - "envoyProxy.enabled", cfg.Sidecars.EnvoyProxy.Enabled, - "spiffeHelper.enabled", cfg.Sidecars.SpiffeHelper.Enabled, - "clientRegistration.enabled", cfg.Sidecars.ClientRegistration.Enabled, - ) log.Info("=============================================") } diff --git a/kagenti-operator/internal/webhook/config/types.go b/kagenti-operator/internal/webhook/config/types.go index 139a93a9..96291ee9 100644 --- a/kagenti-operator/internal/webhook/config/types.go +++ b/kagenti-operator/internal/webhook/config/types.go @@ -14,17 +14,30 @@ type PlatformConfig struct { TokenExchange TokenExchangeDefaults `json:"tokenExchange" yaml:"tokenExchange"` Spiffe SpiffeConfig `json:"spiffe" yaml:"spiffe"` Observability ObservabilityConfig `json:"observability" yaml:"observability"` - Sidecars SidecarDefaults `json:"sidecars" yaml:"sidecars"` } type ImageConfig struct { - EnvoyProxy string `json:"envoyProxy" yaml:"envoyProxy"` - AuthBridgeLight string `json:"authbridgeLight" yaml:"authbridgeLight"` - ProxyInit string `json:"proxyInit" yaml:"proxyInit"` - SpiffeHelper string `json:"spiffeHelper" yaml:"spiffeHelper"` - ClientRegistration string `json:"clientRegistration" yaml:"clientRegistration"` - AuthBridge string `json:"authbridge" yaml:"authbridge"` - PullPolicy corev1.PullPolicy `json:"pullPolicy" yaml:"pullPolicy"` + // EnvoyProxy is the combined image for envoy-sidecar mode: + // Envoy + authbridge (ext_proc) + spiffe-helper bundled. + // Spiffe-helper starts conditionally based on SPIRE_ENABLED. + EnvoyProxy string `json:"envoyProxy" yaml:"envoyProxy"` + + // AuthBridge is the combined image for proxy-sidecar mode (default): + // authbridge-proxy + spiffe-helper bundled. No Envoy, no gRPC. + // Spiffe-helper starts conditionally based on SPIRE_ENABLED. + AuthBridge string `json:"authbridge" yaml:"authbridge"` + + // AuthBridgeLite is the size-optimized variant of AuthBridge: + // authbridge-lite (jwt-validation + token-exchange only, parsers + // dropped) + spiffe-helper bundled. Same listener layout as + // AuthBridge, used for the "lite" mode. + AuthBridgeLite string `json:"authbridgeLite" yaml:"authbridgeLite"` + + // ProxyInit is the iptables init container, used by envoy-sidecar + // mode only. + ProxyInit string `json:"proxyInit" yaml:"proxyInit"` + + PullPolicy corev1.PullPolicy `json:"pullPolicy" yaml:"pullPolicy"` } type ProxyConfig struct { @@ -35,11 +48,9 @@ type ProxyConfig struct { } type ResourcesConfig struct { - EnvoyProxy corev1.ResourceRequirements `json:"envoyProxy" yaml:"envoyProxy"` - ProxyInit corev1.ResourceRequirements `json:"proxyInit" yaml:"proxyInit"` - SpiffeHelper corev1.ResourceRequirements `json:"spiffeHelper" yaml:"spiffeHelper"` - ClientRegistration corev1.ResourceRequirements `json:"clientRegistration" yaml:"clientRegistration"` - AuthBridge corev1.ResourceRequirements `json:"authbridge" yaml:"authbridge"` + EnvoyProxy corev1.ResourceRequirements `json:"envoyProxy" yaml:"envoyProxy"` + ProxyInit corev1.ResourceRequirements `json:"proxyInit" yaml:"proxyInit"` + AuthBridge corev1.ResourceRequirements `json:"authbridge" yaml:"authbridge"` } type TokenExchangeDefaults struct { @@ -60,18 +71,6 @@ type ObservabilityConfig struct { TracingBackend string `json:"tracingBackend" yaml:"tracingBackend"` } -// SidecarDefaults controls per-sidecar enable/disable at the platform level. -// This is the lowest-priority layer in the injection precedence chain. -type SidecarDefaults struct { - EnvoyProxy SidecarDefault `json:"envoyProxy" yaml:"envoyProxy"` - SpiffeHelper SidecarDefault `json:"spiffeHelper" yaml:"spiffeHelper"` - ClientRegistration SidecarDefault `json:"clientRegistration" yaml:"clientRegistration"` -} - -type SidecarDefault struct { - Enabled bool `json:"enabled" yaml:"enabled"` -} - // DeepCopy creates a copy of the config func (c *PlatformConfig) DeepCopy() *PlatformConfig { if c == nil { @@ -87,8 +86,6 @@ func (c *PlatformConfig) DeepCopy() *PlatformConfig { // Deep copy ResourceRequirements — ResourceList is a map that would be shared result.Resources.EnvoyProxy = deepCopyResourceRequirements(c.Resources.EnvoyProxy) result.Resources.ProxyInit = deepCopyResourceRequirements(c.Resources.ProxyInit) - result.Resources.SpiffeHelper = deepCopyResourceRequirements(c.Resources.SpiffeHelper) - result.Resources.ClientRegistration = deepCopyResourceRequirements(c.Resources.ClientRegistration) result.Resources.AuthBridge = deepCopyResourceRequirements(c.Resources.AuthBridge) return &result @@ -125,14 +122,14 @@ func (c *PlatformConfig) Validate() error { if c.Images.EnvoyProxy == "" { return fmt.Errorf("images.envoyProxy is required") } - if c.Images.ProxyInit == "" { - return fmt.Errorf("images.proxyInit is required") + if c.Images.AuthBridge == "" { + return fmt.Errorf("images.authbridge is required") } - if c.Images.SpiffeHelper == "" { - return fmt.Errorf("images.spiffeHelper is required") + if c.Images.AuthBridgeLite == "" { + return fmt.Errorf("images.authbridgeLite is required") } - if c.Images.ClientRegistration == "" { - return fmt.Errorf("images.clientRegistration is required") + if c.Images.ProxyInit == "" { + return fmt.Errorf("images.proxyInit is required") } return nil } diff --git a/kagenti-operator/internal/webhook/injector/agentruntime_config.go b/kagenti-operator/internal/webhook/injector/agentruntime_config.go index 16e1cb28..9a6b5722 100644 --- a/kagenti-operator/internal/webhook/injector/agentruntime_config.go +++ b/kagenti-operator/internal/webhook/injector/agentruntime_config.go @@ -48,6 +48,12 @@ type AgentRuntimeOverrides struct { TraceEndpoint *string TraceProtocol *string // "grpc" or "http" TraceSamplingRate *float64 // 0.0–1.0 + + // AuthBridge deployment shape — from .spec.authBridgeMode + // Nil = no per-workload override; the namespace's + // authbridge-runtime-config mode (if set) or the cluster fallback + // applies. + AuthBridgeMode *string } // ReadAgentRuntimeOverrides reads the AgentRuntime CR for a given workload @@ -115,10 +121,17 @@ func extractOverrides(rt *agentv1alpha1.AgentRuntime) *AgentRuntimeOverrides { overrides.TraceSamplingRate = &rate } + // .spec.authBridgeMode + if rt.Spec.AuthBridgeMode != "" { + mode := rt.Spec.AuthBridgeMode + overrides.AuthBridgeMode = &mode + } + arConfigLog.Info("AgentRuntime overrides extracted", "hasSpiffeTrustDomain", overrides.SpiffeTrustDomain != nil, "hasClientRegistration", overrides.ClientRegistrationProvider != nil, - "hasTrace", overrides.TraceEndpoint != nil) + "hasTrace", overrides.TraceEndpoint != nil, + "hasAuthBridgeMode", overrides.AuthBridgeMode != nil) return overrides } diff --git a/kagenti-operator/internal/webhook/injector/constants.go b/kagenti-operator/internal/webhook/injector/constants.go index 81834bd5..627bfb64 100644 --- a/kagenti-operator/internal/webhook/injector/constants.go +++ b/kagenti-operator/internal/webhook/injector/constants.go @@ -11,16 +11,25 @@ const ( LabelClientRegistrationInject = "kagenti.io/client-registration-inject" ) -// AuthBridge deployment mode annotation. -// Controls which image variant and injection pattern is used. +// AuthBridge deployment modes. Selected per workload via AgentRuntime +// CR `Spec.AuthBridgeMode`, falling back to the namespace +// `authbridge-runtime-config` ConfigMap's `mode` field, the deprecated +// per-pod annotation, then ModeProxySidecar as the cluster-wide default. const ( - AnnotationAuthBridgeMode = "kagenti.io/authbridge-mode" - - // Mode values - ModeEnvoySidecar = "envoy-sidecar" // default: iptables + Envoy + ext_proc - ModeProxySidecar = "proxy-sidecar" // HTTP_PROXY env + lightweight authbridge + ModeEnvoySidecar = "envoy-sidecar" // iptables + Envoy + ext_proc + ModeProxySidecar = "proxy-sidecar" // default: HTTP_PROXY env + authbridge proxy (full plugins) + ModeLite = "lite" // same shape as proxy-sidecar; uses authbridge-lite image (auth-only) ModeWaypoint = "waypoint" // standalone deployment (not injected) + // AnnotationAuthBridgeMode is the legacy per-pod mode selector. The + // canonical surface is now AgentRuntime.Spec.AuthBridgeMode and the + // namespace authbridge-runtime-config ConfigMap; this annotation is + // only honored as a deprecated fallback so existing deployments do + // not silently shape-shift to a different mode on first redeploy. + // + // Deprecated: set Spec.AuthBridgeMode on the AgentRuntime CR. + AnnotationAuthBridgeMode = "kagenti.io/authbridge-mode" + // Container name for proxy-sidecar mode AuthBridgeProxyContainerName = "authbridge-proxy" diff --git a/kagenti-operator/internal/webhook/injector/container_builder.go b/kagenti-operator/internal/webhook/injector/container_builder.go index c6a02be8..d8cc69d3 100644 --- a/kagenti-operator/internal/webhook/injector/container_builder.go +++ b/kagenti-operator/internal/webhook/injector/container_builder.go @@ -33,7 +33,6 @@ const ( // Container names for AuthBridge sidecars EnvoyProxyContainerName = "envoy-proxy" ProxyInitContainerName = "proxy-init" - AuthBridgeContainerName = "authbridge" SharedVolumesFSGroup = 0 ) @@ -70,289 +69,6 @@ func NewResolvedContainerBuilder(resolved *ResolvedConfig) *ContainerBuilder { } } -func (b *ContainerBuilder) BuildSpiffeHelperContainer() corev1.Container { - builderLog.Info("building SpiffeHelper Container") - - return corev1.Container{ - Name: SpiffeHelperContainerName, - Image: b.cfg.Images.SpiffeHelper, - ImagePullPolicy: b.cfg.Images.PullPolicy, - Resources: b.cfg.Resources.SpiffeHelper, - Command: []string{ - "/spiffe-helper", - "-config=/etc/spiffe-helper/helper.conf", - "run", - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "spiffe-helper-config", - MountPath: "/etc/spiffe-helper", - }, - { - Name: "spire-agent-socket", - MountPath: "/spiffe-workload-api", - }, - { - Name: "svid-output", - MountPath: "/opt", - }, - { - Name: "shared-data", - MountPath: "/shared", - }, - }, - // No hardcoded UID/GID — let the platform assign the user. - // On OpenShift, MustRunAsRange assigns a UID from the namespace range. - // On vanilla Kubernetes, the container runs as the image's default UID. - // fsGroup=0 on the pod ensures all containers share GID 0 for file access. - SecurityContext: &corev1.SecurityContext{ - RunAsNonRoot: ptr.To(true), - AllowPrivilegeEscalation: ptr.To(false), - }, - } -} - -func (b *ContainerBuilder) BuildClientRegistrationContainer(name, namespace string) corev1.Container { - // Default to SPIRE enabled for backward compatibility - return b.BuildClientRegistrationContainerWithSpireOption(name, namespace, true) -} - -// BuildClientRegistrationContainerWithSpireOption creates the client registration container -// with optional SPIRE support -func (b *ContainerBuilder) BuildClientRegistrationContainerWithSpireOption(name, namespace string, spireEnabled bool) corev1.Container { - builderLog.Info("building ClientRegistration Container", "spireEnabled", spireEnabled) - - clientName := namespace + "/" + name - - var env []corev1.EnvVar - if b.resolved != nil { - // Resolved mode: literal values - env = b.buildClientRegistrationEnvResolved(clientName, spireEnabled) - } else { - // Legacy mode: ValueFrom refs - env = b.buildClientRegistrationEnvLegacy(clientName, spireEnabled) - } - - // Volume mounts depend on SPIRE enablement - var volumeMounts []corev1.VolumeMount - if spireEnabled { - volumeMounts = []corev1.VolumeMount{ - { - Name: "svid-output", - MountPath: "/opt", - }, - { - Name: "shared-data", - MountPath: "/shared", - }, - } - } else { - volumeMounts = []corev1.VolumeMount{ - { - Name: "shared-data", - MountPath: "/shared", - }, - } - } - - // Build the command based on SPIRE enablement - var command string - if spireEnabled { - command = ` -echo "Waiting for SPIFFE credentials..." -while [ ! -f /opt/jwt_svid.token ]; do - echo "waiting for SVID" - sleep 1 -done -echo "SPIFFE credentials ready!" - -# Extract client ID (SPIFFE ID) from JWT and save to file -JWT_PAYLOAD=$(cat /opt/jwt_svid.token | cut -d'.' -f2) -if ! CLIENT_ID=$(echo "${JWT_PAYLOAD}==" | base64 -d | python -c "import sys,json; print(json.load(sys.stdin).get('sub',''))"); then - echo "Error: Failed to decode JWT payload or extract client ID" >&2 - exit 1 -fi -if [ -z "$CLIENT_ID" ]; then - echo "Error: Extracted client ID is empty" >&2 - exit 1 -fi -echo "$CLIENT_ID" > /shared/client-id.txt -echo "Client ID (SPIFFE ID): $CLIENT_ID" - -echo "Starting client registration..." -python client_registration.py -echo "Client registration complete!" -tail -f /dev/null -` - } else { - command = ` -echo "SPIRE disabled - using static client ID" - -# Use CLIENT_NAME as the client ID -echo "$CLIENT_NAME" > /shared/client-id.txt -echo "Client ID: $CLIENT_NAME" - -echo "Starting client registration..." -python client_registration.py -echo "Client registration complete!" -tail -f /dev/null -` - } - - return corev1.Container{ - Name: ClientRegistrationContainerName, - Image: b.cfg.Images.ClientRegistration, - ImagePullPolicy: b.cfg.Images.PullPolicy, - Resources: b.cfg.Resources.ClientRegistration, - Command: []string{ - "/bin/sh", - "-c", - command, - }, - Env: env, - VolumeMounts: volumeMounts, - SecurityContext: &corev1.SecurityContext{ - RunAsNonRoot: ptr.To(true), - AllowPrivilegeEscalation: ptr.To(false), - }, - } -} - -// buildClientRegistrationEnvResolved returns env vars from resolved config. -// Non-sensitive values (URLs, realm, client name) are injected as literals. -// Sensitive values (KEYCLOAK_ADMIN_USERNAME/PASSWORD) use SecretKeyRef to keep -// credentials out of the Pod spec — only a reference to the Secret is stored. -func (b *ContainerBuilder) buildClientRegistrationEnvResolved(clientName string, spireEnabled bool) []corev1.EnvVar { - secretName := b.resolved.AdminCredentialsSecretName - if secretName == "" { - secretName = KeycloakAdminSecretName - } - return []corev1.EnvVar{ - {Name: "SPIRE_ENABLED", Value: fmt.Sprintf("%t", spireEnabled)}, - {Name: "KEYCLOAK_URL", Value: b.resolved.KeycloakURL}, - {Name: "KEYCLOAK_REALM", Value: b.resolved.KeycloakRealm}, - { - Name: "KEYCLOAK_ADMIN_USERNAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, - Key: "KEYCLOAK_ADMIN_USERNAME", - }, - }, - }, - { - Name: "KEYCLOAK_ADMIN_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, - Key: "KEYCLOAK_ADMIN_PASSWORD", - }, - }, - }, - {Name: "CLIENT_NAME", Value: clientName}, - {Name: "SECRET_FILE_PATH", Value: "/shared/client-secret.txt"}, - {Name: "PLATFORM_CLIENT_IDS", Value: b.resolved.PlatformClientIDs}, - {Name: "CLIENT_AUTH_TYPE", Value: b.resolved.ClientAuthType}, - {Name: "SPIFFE_IDP_ALIAS", Value: b.resolved.SpiffeIdpAlias}, - {Name: "JWT_AUDIENCE", Value: b.resolved.JWTAudience}, - } -} - -// buildClientRegistrationEnvLegacy returns ValueFrom-based env vars (backward compat). -func (b *ContainerBuilder) buildClientRegistrationEnvLegacy(clientName string, spireEnabled bool) []corev1.EnvVar { - return []corev1.EnvVar{ - { - Name: "SPIRE_ENABLED", - Value: fmt.Sprintf("%t", spireEnabled), - }, - { - Name: "KEYCLOAK_URL", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "KEYCLOAK_URL", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "KEYCLOAK_REALM", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "KEYCLOAK_REALM", - }, - }, - }, - { - Name: "KEYCLOAK_ADMIN_USERNAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "keycloak-admin-secret"}, - Key: "KEYCLOAK_ADMIN_USERNAME", - }, - }, - }, - { - Name: "KEYCLOAK_ADMIN_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "keycloak-admin-secret"}, - Key: "KEYCLOAK_ADMIN_PASSWORD", - }, - }, - }, - { - Name: "CLIENT_NAME", - Value: clientName, - }, - { - Name: "SECRET_FILE_PATH", - Value: "/shared/client-secret.txt", - }, - { - Name: "PLATFORM_CLIENT_IDS", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "PLATFORM_CLIENT_IDS", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "CLIENT_AUTH_TYPE", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "CLIENT_AUTH_TYPE", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "SPIFFE_IDP_ALIAS", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "SPIFFE_IDP_ALIAS", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "JWT_AUDIENCE", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "JWT_AUDIENCE", - Optional: ptr.To(true), - }, - }, - }, - } -} - // BuildEnvoyProxyContainer creates the envoy-proxy sidecar container with SPIRE enabled (default). func (b *ContainerBuilder) BuildEnvoyProxyContainer() corev1.Container { return b.BuildEnvoyProxyContainerWithSpireOption(true) @@ -372,9 +88,14 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled ReadOnly: true, }, { + // Not ReadOnly: subPath mounts of /shared/client-id.txt and + // /shared/client-secret.txt (added later by + // ApplyKeycloakClientCredentialsSecretVolumes) need to create + // their targets inside this mount. The combined authbridge + // images use a read-only base (ubi9-micro), so /shared must + // be mounted RW for runc to create the subPath mountpoints. Name: "shared-data", MountPath: "/shared", - ReadOnly: true, }, { Name: "authproxy-routes", @@ -395,12 +116,31 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled }) } + if spireEnabled { + // authbridge-envoy bundles spiffe-helper; the entrypoint reads + // helper.conf from this mount. Without it, the bundled + // spiffe-helper would fail to start on SPIRE_ENABLED=true. + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "spiffe-helper-config", + MountPath: "/etc/spiffe-helper", + ReadOnly: true, + }) + } + var env []corev1.EnvVar if b.resolved != nil { env = b.buildEnvoyProxyEnvResolved() } else { env = b.buildEnvoyProxyEnvLegacy() } + // SPIRE_ENABLED gates the bundled spiffe-helper inside the + // combined image's entrypoint. Always set explicitly so the + // container's behavior is deterministic regardless of the image's + // own default. + env = append(env, corev1.EnvVar{ + Name: "SPIRE_ENABLED", + Value: spireEnabledStr(spireEnabled), + }) return corev1.Container{ Name: EnvoyProxyContainerName, @@ -441,18 +181,31 @@ func (b *ContainerBuilder) BuildEnvoyProxyContainerWithSpireOption(spireEnabled } } -// BuildProxySidecarContainer creates a lightweight authbridge container for proxy-sidecar mode. -// Uses authbridge-light image (no Envoy). The app uses HTTP_PROXY env vars to route -// outbound traffic through the forward proxy. Inbound traffic goes through the reverse proxy. +func spireEnabledStr(b bool) string { + if b { + return "true" + } + return "false" +} + +// BuildProxySidecarContainer creates a combined authbridge container for proxy-sidecar mode. +// Uses the authbridge image (authbridge-proxy + spiffe-helper bundled, no Envoy). +// The app uses HTTP_PROXY env vars to route outbound traffic through the forward proxy. +// Inbound traffic goes through the reverse proxy. func (b *ContainerBuilder) BuildProxySidecarContainer(spireEnabled bool) corev1.Container { - return b.BuildProxySidecarContainerWithPorts(spireEnabled, 8080, 8000, 8081) + return b.BuildProxySidecarContainerWithPorts(spireEnabled, b.cfg.Images.AuthBridge, 8080, 8000, 8081) } // BuildProxySidecarContainerWithPorts creates a proxy-sidecar container with dynamic ports. +// image: container image to run — Images.AuthBridge (full plugin set) or +// +// Images.AuthBridgeLite (auth-only). Both images expose the same listeners +// on the same ports; only the plugin set compiled into the binary differs. +// // reverseProxyPort: where the reverse proxy listens (takes over the agent's original port) // agentBackendPort: where the agent actually listens (moved to a free port) // forwardProxyPort: where the forward proxy listens (HTTP_PROXY target) -func (b *ContainerBuilder) BuildProxySidecarContainerWithPorts(spireEnabled bool, reverseProxyPort, agentBackendPort, forwardProxyPort int32) corev1.Container { +func (b *ContainerBuilder) BuildProxySidecarContainerWithPorts(spireEnabled bool, image string, reverseProxyPort, agentBackendPort, forwardProxyPort int32) corev1.Container { volumeMounts := []corev1.VolumeMount{ { Name: "shared-data", @@ -470,19 +223,33 @@ func (b *ContainerBuilder) BuildProxySidecarContainerWithPorts(spireEnabled bool }, } if spireEnabled { - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: "svid-output", - MountPath: "/opt", - }) + volumeMounts = append(volumeMounts, + corev1.VolumeMount{ + Name: "svid-output", + MountPath: "/opt", + }, + // authbridge bundles spiffe-helper; the entrypoint reads + // helper.conf from this mount. Without it, the bundled + // spiffe-helper would fail to start on SPIRE_ENABLED=true. + corev1.VolumeMount{ + Name: "spiffe-helper-config", + MountPath: "/etc/spiffe-helper", + ReadOnly: true, + }, + ) } return corev1.Container{ Name: AuthBridgeProxyContainerName, - Image: b.cfg.Images.AuthBridgeLight, + Image: image, ImagePullPolicy: b.cfg.Images.PullPolicy, Args: []string{ "--config", "/etc/authbridge/config.yaml", }, + Env: []corev1.EnvVar{ + // Gates the bundled spiffe-helper inside the combined image. + {Name: "SPIRE_ENABLED", Value: spireEnabledStr(spireEnabled)}, + }, Ports: []corev1.ContainerPort{ { Name: "reverse-proxy", @@ -495,7 +262,7 @@ func (b *ContainerBuilder) BuildProxySidecarContainerWithPorts(spireEnabled bool Protocol: corev1.ProtocolTCP, }, }, - Resources: b.cfg.Resources.EnvoyProxy, + Resources: b.cfg.Resources.AuthBridge, SecurityContext: &corev1.SecurityContext{ RunAsUser: ptr.To(int64(1001)), RunAsNonRoot: ptr.To(true), @@ -620,272 +387,6 @@ func (b *ContainerBuilder) buildEnvoyProxyEnvLegacy() []corev1.EnvVar { } } -// BuildAuthBridgeContainer creates the combined authbridge sidecar container -// that includes envoy-proxy, go-processor, spiffe-helper, and client-registration -// in a single container. This is used when the CombinedSidecar feature gate is enabled. -func (b *ContainerBuilder) BuildAuthBridgeContainer(name, namespace string, spireEnabled, clientRegistrationEnabled bool) corev1.Container { - builderLog.Info("building AuthBridge combined Container", - "spireEnabled", spireEnabled, - "clientRegistrationEnabled", clientRegistrationEnabled) - - clientName := namespace + "/" + name - - var env []corev1.EnvVar - if b.resolved != nil { - env = b.buildAuthBridgeEnvResolved(clientName, spireEnabled, clientRegistrationEnabled) - } else { - env = b.buildAuthBridgeEnvLegacy(clientName, spireEnabled, clientRegistrationEnabled) - } - - // Volume mounts: union of envoy-proxy + spiffe-helper + client-registration mounts. - // shared-data and svid-output are read-write (same container reads and writes). - volumeMounts := []corev1.VolumeMount{ - { - Name: "envoy-config", - MountPath: "/etc/envoy", - ReadOnly: true, - }, - { - Name: "authproxy-routes", - MountPath: "/etc/authproxy", - ReadOnly: true, - }, - { - Name: "shared-data", - MountPath: "/shared", - }, - } - if spireEnabled { - volumeMounts = append(volumeMounts, - corev1.VolumeMount{ - Name: "svid-output", - MountPath: "/opt", - }, - corev1.VolumeMount{ - Name: "spiffe-helper-config", - MountPath: "/etc/spiffe-helper", - ReadOnly: true, - }, - corev1.VolumeMount{ - Name: "spire-agent-socket", - MountPath: "/spiffe-workload-api", - ReadOnly: true, - }, - ) - } - - return corev1.Container{ - Name: AuthBridgeContainerName, - Image: b.cfg.Images.AuthBridge, - ImagePullPolicy: b.cfg.Images.PullPolicy, - Resources: b.cfg.Resources.AuthBridge, - Ports: []corev1.ContainerPort{ - { - Name: "envoy-outbound", - ContainerPort: b.cfg.Proxy.Port, - Protocol: corev1.ProtocolTCP, - }, - { - Name: "envoy-inbound", - ContainerPort: b.cfg.Proxy.InboundProxyPort, - Protocol: corev1.ProtocolTCP, - }, - { - Name: "envoy-admin", - ContainerPort: b.cfg.Proxy.AdminPort, - Protocol: corev1.ProtocolTCP, - }, - { - Name: "ext-proc", - ContainerPort: 9090, - Protocol: corev1.ProtocolTCP, - }, - }, - Env: env, - SecurityContext: &corev1.SecurityContext{ - RunAsUser: ptr.To(b.cfg.Proxy.UID), - RunAsGroup: ptr.To(b.cfg.Proxy.UID), - RunAsNonRoot: ptr.To(true), - AllowPrivilegeEscalation: ptr.To(false), - }, - VolumeMounts: volumeMounts, - } -} - -// buildAuthBridgeEnvResolved returns env vars for the combined container from resolved config. -func (b *ContainerBuilder) buildAuthBridgeEnvResolved(clientName string, spireEnabled, clientRegistrationEnabled bool) []corev1.EnvVar { - secretName := b.resolved.AdminCredentialsSecretName - if secretName == "" { - secretName = KeycloakAdminSecretName - } - - env := []corev1.EnvVar{ - // Control flags for the entrypoint - {Name: "SPIRE_ENABLED", Value: fmt.Sprintf("%t", spireEnabled)}, - {Name: "CLIENT_REGISTRATION_ENABLED", Value: fmt.Sprintf("%t", clientRegistrationEnabled)}, - // Envoy/go-processor env vars - {Name: "KEYCLOAK_URL", Value: b.resolved.KeycloakURL}, - {Name: "KEYCLOAK_REALM", Value: b.resolved.KeycloakRealm}, - {Name: "TOKEN_URL", Value: b.resolved.TokenURL}, - {Name: "ISSUER", Value: b.resolved.Issuer}, - {Name: "EXPECTED_AUDIENCE", Value: b.resolved.ExpectedAudience}, - {Name: "TARGET_AUDIENCE", Value: b.resolved.TargetAudience}, - {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: "DEFAULT_OUTBOUND_POLICY", Value: b.resolved.DefaultOutboundPolicy}, - // Client-registration env vars (sensitive values stay as SecretKeyRef) - { - Name: "KEYCLOAK_ADMIN_USERNAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, - Key: "KEYCLOAK_ADMIN_USERNAME", - }, - }, - }, - { - Name: "KEYCLOAK_ADMIN_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, - Key: "KEYCLOAK_ADMIN_PASSWORD", - }, - }, - }, - {Name: "CLIENT_NAME", Value: clientName}, - {Name: "SECRET_FILE_PATH", Value: "/shared/client-secret.txt"}, - {Name: "PLATFORM_CLIENT_IDS", Value: b.resolved.PlatformClientIDs}, - } - - return env -} - -// buildAuthBridgeEnvLegacy returns ValueFrom-based env vars for the combined container. -func (b *ContainerBuilder) buildAuthBridgeEnvLegacy(clientName string, spireEnabled, clientRegistrationEnabled bool) []corev1.EnvVar { - return []corev1.EnvVar{ - // Control flags for the entrypoint - {Name: "SPIRE_ENABLED", Value: fmt.Sprintf("%t", spireEnabled)}, - {Name: "CLIENT_REGISTRATION_ENABLED", Value: fmt.Sprintf("%t", clientRegistrationEnabled)}, - // Envoy/go-processor env vars (from ConfigMap) - { - Name: "KEYCLOAK_URL", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "KEYCLOAK_URL", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "KEYCLOAK_REALM", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "KEYCLOAK_REALM", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "TOKEN_URL", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "TOKEN_URL", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "ISSUER", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "ISSUER", - Optional: ptr.To(false), - }, - }, - }, - { - Name: "EXPECTED_AUDIENCE", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "EXPECTED_AUDIENCE", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "TARGET_AUDIENCE", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "TARGET_AUDIENCE", - Optional: ptr.To(true), - }, - }, - }, - { - Name: "TARGET_SCOPES", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "TARGET_SCOPES", - Optional: ptr.To(true), - }, - }, - }, - {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: "DEFAULT_OUTBOUND_POLICY", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "authbridge-config"}, - Key: "DEFAULT_OUTBOUND_POLICY", - Optional: ptr.To(true), - }, - }, - }, - // Client-registration env vars - { - Name: "KEYCLOAK_ADMIN_USERNAME", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "keycloak-admin-secret"}, - Key: "KEYCLOAK_ADMIN_USERNAME", - }, - }, - }, - { - Name: "KEYCLOAK_ADMIN_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "keycloak-admin-secret"}, - Key: "KEYCLOAK_ADMIN_PASSWORD", - }, - }, - }, - {Name: "CLIENT_NAME", Value: clientName}, - {Name: "SECRET_FILE_PATH", Value: "/shared/client-secret.txt"}, - { - Name: "PLATFORM_CLIENT_IDS", - ValueFrom: &corev1.EnvVarSource{ - ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: AuthBridgeConfigMapName}, - Key: "PLATFORM_CLIENT_IDS", - Optional: ptr.To(true), - }, - }, - }, - } -} - // BuildProxyInitContainer creates the init container that sets up iptables // to redirect outbound traffic to the Envoy proxy. // diff --git a/kagenti-operator/internal/webhook/injector/container_builder_test.go b/kagenti-operator/internal/webhook/injector/container_builder_test.go index 1749d79c..ecbfb133 100644 --- a/kagenti-operator/internal/webhook/injector/container_builder_test.go +++ b/kagenti-operator/internal/webhook/injector/container_builder_test.go @@ -109,140 +109,6 @@ func TestBuildEnvoyProxyContainer_Name(t *testing.T) { } } -func TestBuildClientRegistrationContainer_HasPlatformClientIDsEnv(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildClientRegistrationContainerWithSpireOption("test-agent", "team1", true) - - found := false - for _, env := range container.Env { - if env.Name == "PLATFORM_CLIENT_IDS" { - found = true - if env.ValueFrom == nil || env.ValueFrom.ConfigMapKeyRef == nil { - t.Error("PLATFORM_CLIENT_IDS should reference a ConfigMap key") - break - } - if env.ValueFrom.ConfigMapKeyRef.Name != "authbridge-config" { - t.Errorf("PLATFORM_CLIENT_IDS ConfigMapKeyRef.Name = %q, want %q", - env.ValueFrom.ConfigMapKeyRef.Name, "authbridge-config") - } - if env.ValueFrom.ConfigMapKeyRef.Key != "PLATFORM_CLIENT_IDS" { - t.Errorf("PLATFORM_CLIENT_IDS key = %q, want PLATFORM_CLIENT_IDS", - env.ValueFrom.ConfigMapKeyRef.Key) - } - if env.ValueFrom.ConfigMapKeyRef.Optional == nil || !*env.ValueFrom.ConfigMapKeyRef.Optional { - t.Error("PLATFORM_CLIENT_IDS should be optional") - } - break - } - } - if !found { - t.Error("client-registration container missing PLATFORM_CLIENT_IDS env var") - } -} - -func TestBuildClientRegistrationContainer_AdminCredentialsFromSecret(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildClientRegistrationContainerWithSpireOption("my-app", "my-ns", true) - - sensitiveKeys := []string{"KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD"} - for _, key := range sensitiveKeys { - found := false - for _, env := range container.Env { - if env.Name != key { - continue - } - found = true - if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { - t.Errorf("env %q must use SecretKeyRef, got ConfigMapKeyRef or literal", key) - continue - } - if env.ValueFrom.SecretKeyRef.Name != "keycloak-admin-secret" { - t.Errorf("env %q SecretKeyRef.Name = %q, want %q", key, env.ValueFrom.SecretKeyRef.Name, "keycloak-admin-secret") - } - } - if !found { - t.Errorf("client-registration container missing env var %q", key) - } - } -} - -func TestBuildClientRegistrationContainer_ResolvedPath_AdminCredentialsFromSecret(t *testing.T) { - resolved := &ResolvedConfig{ - Platform: config.CompiledDefaults(), - KeycloakURL: "https://keycloak.example.com", - } - builder := NewResolvedContainerBuilder(resolved) - container := builder.BuildClientRegistrationContainerWithSpireOption("my-app", "my-ns", true) - - sensitiveKeys := []string{"KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD"} - for _, key := range sensitiveKeys { - found := false - for _, env := range container.Env { - if env.Name != key { - continue - } - found = true - if env.Value != "" { - t.Errorf("env %q must NOT have a literal Value in resolved path (security: keeps credentials out of Pod spec)", key) - } - if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { - t.Errorf("env %q must use SecretKeyRef, got literal or ConfigMapKeyRef", key) - continue - } - if env.ValueFrom.SecretKeyRef.Name != "keycloak-admin-secret" { - t.Errorf("env %q SecretKeyRef.Name = %q, want %q", key, env.ValueFrom.SecretKeyRef.Name, "keycloak-admin-secret") - } - } - if !found { - t.Errorf("client-registration container missing env var %q", key) - } - } -} - -func TestBuildClientRegistrationContainer_NonSensitiveKeysFromConfigMap(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildClientRegistrationContainerWithSpireOption("my-app", "my-ns", true) - - nonSensitiveKeys := []string{"KEYCLOAK_URL", "KEYCLOAK_REALM"} - for _, key := range nonSensitiveKeys { - found := false - for _, env := range container.Env { - if env.Name != key { - continue - } - found = true - if env.ValueFrom == nil || env.ValueFrom.ConfigMapKeyRef == nil { - t.Errorf("env %q must use ConfigMapKeyRef", key) - continue - } - if env.ValueFrom.ConfigMapKeyRef.Name != "authbridge-config" { - t.Errorf("env %q ConfigMapKeyRef.Name = %q, want %q", key, env.ValueFrom.ConfigMapKeyRef.Name, "authbridge-config") - } - } - if !found { - t.Errorf("client-registration container missing env var %q", key) - } - } -} - -func TestBuildClientRegistrationContainer_HasSecretFilePath(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildClientRegistrationContainerWithSpireOption("my-app", "my-ns", true) - - found := false - for _, env := range container.Env { - if env.Name == "SECRET_FILE_PATH" { - found = true - if env.Value != "/shared/client-secret.txt" { - t.Errorf("SECRET_FILE_PATH should be /shared/client-secret.txt, got %s", env.Value) - } - } - } - if !found { - t.Error("client-registration container should have SECRET_FILE_PATH env var for backwards compatibility") - } -} - func TestBuildEnvoyProxyContainer_HasKeycloakURLAndRealm(t *testing.T) { builder := NewContainerBuilder(config.CompiledDefaults()) container := builder.BuildEnvoyProxyContainerWithSpireOption(true) @@ -440,242 +306,6 @@ func TestBuildPortExcludeValue(t *testing.T) { // AuthBridge combined container tests // ======================================== -func TestBuildAuthBridgeContainer_Name(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - if container.Name != AuthBridgeContainerName { - t.Errorf("container name = %q, want %q", container.Name, AuthBridgeContainerName) - } -} - -func TestBuildAuthBridgeContainer_UID1337(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - if container.SecurityContext == nil { - t.Fatal("SecurityContext is nil") - } - if container.SecurityContext.RunAsUser == nil || *container.SecurityContext.RunAsUser != 1337 { - t.Errorf("RunAsUser = %v, want 1337", container.SecurityContext.RunAsUser) - } - if container.SecurityContext.RunAsGroup == nil || *container.SecurityContext.RunAsGroup != 1337 { - t.Errorf("RunAsGroup = %v, want 1337", container.SecurityContext.RunAsGroup) - } -} - -func TestBuildAuthBridgeContainer_Ports(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - wantPorts := map[string]int32{ - "envoy-outbound": 15123, - "envoy-inbound": 15124, - "envoy-admin": 9901, - "ext-proc": 9090, - } - - portsByName := make(map[string]int32) - for _, p := range container.Ports { - portsByName[p.Name] = p.ContainerPort - } - - for name, wantPort := range wantPorts { - got, ok := portsByName[name] - if !ok { - t.Errorf("missing port %q", name) - continue - } - if got != wantPort { - t.Errorf("port %q = %d, want %d", name, got, wantPort) - } - } -} - -func TestBuildAuthBridgeContainer_SpireEnabled_AllMounts(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - wantMounts := map[string]string{ - "envoy-config": "/etc/envoy", - "authproxy-routes": "/etc/authproxy", - "shared-data": "/shared", - "svid-output": "/opt", - "spiffe-helper-config": "/etc/spiffe-helper", - "spire-agent-socket": "/spiffe-workload-api", - } - - mountsByName := make(map[string]string) - for _, vm := range container.VolumeMounts { - mountsByName[vm.Name] = vm.MountPath - } - - for name, wantPath := range wantMounts { - gotPath, ok := mountsByName[name] - if !ok { - t.Errorf("missing volume mount %q", name) - continue - } - if gotPath != wantPath { - t.Errorf("volume mount %q path = %q, want %q", name, gotPath, wantPath) - } - } - - // shared-data and svid-output must be read-write - for _, vm := range container.VolumeMounts { - if vm.Name == "shared-data" || vm.Name == "svid-output" { - if vm.ReadOnly { - t.Errorf("volume mount %q should be read-write, got ReadOnly=true", vm.Name) - } - } - } -} - -func TestBuildAuthBridgeContainer_SpireDisabled_NoSpireMounts(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", false, true) - - spireMounts := []string{"svid-output", "spiffe-helper-config", "spire-agent-socket"} - for _, vm := range container.VolumeMounts { - for _, spireMount := range spireMounts { - if vm.Name == spireMount { - t.Errorf("unexpected SPIRE volume mount %q when SPIRE is disabled", vm.Name) - } - } - } - - // Still has non-SPIRE mounts - found := false - for _, vm := range container.VolumeMounts { - if vm.Name == "envoy-config" { - found = true - } - } - if !found { - t.Error("missing envoy-config volume mount") - } -} - -func TestBuildAuthBridgeContainer_ControlFlags(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, false) - - envByName := make(map[string]string) - for _, env := range container.Env { - if env.Value != "" { - envByName[env.Name] = env.Value - } - } - - if envByName["SPIRE_ENABLED"] != "true" { - t.Errorf("SPIRE_ENABLED = %q, want %q", envByName["SPIRE_ENABLED"], "true") - } - if envByName["CLIENT_REGISTRATION_ENABLED"] != "false" { - t.Errorf("CLIENT_REGISTRATION_ENABLED = %q, want %q", envByName["CLIENT_REGISTRATION_ENABLED"], "false") - } -} - -func TestBuildAuthBridgeContainer_AllEnvVars(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - // Verify union of envoy-proxy + client-registration env vars exist - requiredEnvNames := []string{ - "SPIRE_ENABLED", "CLIENT_REGISTRATION_ENABLED", - "KEYCLOAK_URL", "KEYCLOAK_REALM", "TOKEN_URL", "ISSUER", - "EXPECTED_AUDIENCE", "TARGET_AUDIENCE", "TARGET_SCOPES", - "CLIENT_ID_FILE", "CLIENT_SECRET_FILE", "ROUTES_CONFIG_PATH", - "DEFAULT_OUTBOUND_POLICY", - "KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD", - "CLIENT_NAME", "SECRET_FILE_PATH", "PLATFORM_CLIENT_IDS", - } - - envNames := make(map[string]bool) - for _, env := range container.Env { - envNames[env.Name] = true - } - - for _, name := range requiredEnvNames { - if !envNames[name] { - t.Errorf("missing env var %q", name) - } - } -} - -func TestBuildAuthBridgeContainer_AdminCredentialsFromSecret(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - for _, key := range []string{"KEYCLOAK_ADMIN_USERNAME", "KEYCLOAK_ADMIN_PASSWORD"} { - found := false - for _, env := range container.Env { - if env.Name != key { - continue - } - found = true - if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { - t.Errorf("env %q must use SecretKeyRef", key) - } - } - if !found { - t.Errorf("missing env var %q", key) - } - } -} - -func TestBuildAuthBridgeContainer_ResolvedMode(t *testing.T) { - resolved := &ResolvedConfig{ - Platform: config.CompiledDefaults(), - KeycloakURL: "https://keycloak.example.com", - KeycloakRealm: "test-realm", - TokenURL: "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/token", - DefaultOutboundPolicy: "passthrough", - } - builder := NewResolvedContainerBuilder(resolved) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - envByName := make(map[string]string) - for _, env := range container.Env { - if env.Value != "" { - envByName[env.Name] = env.Value - } - } - - if envByName["KEYCLOAK_URL"] != "https://keycloak.example.com" { - t.Errorf("KEYCLOAK_URL = %q, want %q", envByName["KEYCLOAK_URL"], "https://keycloak.example.com") - } - if envByName["KEYCLOAK_REALM"] != "test-realm" { - t.Errorf("KEYCLOAK_REALM = %q, want %q", envByName["KEYCLOAK_REALM"], "test-realm") - } - - // Sensitive values should still use SecretKeyRef - for _, env := range container.Env { - if env.Name == "KEYCLOAK_ADMIN_USERNAME" || env.Name == "KEYCLOAK_ADMIN_PASSWORD" { - if env.Value != "" { - t.Errorf("env %q must NOT have a literal Value in resolved path", env.Name) - } - if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { - t.Errorf("env %q must use SecretKeyRef", env.Name) - } - } - } -} - -func TestBuildAuthBridgeContainer_ClientName(t *testing.T) { - builder := NewContainerBuilder(config.CompiledDefaults()) - container := builder.BuildAuthBridgeContainer("my-agent", "test-ns", true, true) - - for _, env := range container.Env { - if env.Name == "CLIENT_NAME" { - if env.Value != "test-ns/my-agent" { - t.Errorf("CLIENT_NAME = %q, want %q", env.Value, "test-ns/my-agent") - } - return - } - } - t.Error("missing CLIENT_NAME env var") -} - func TestBuildEnvoyProxyContainer_HasExpectedAudienceFromConfigMap(t *testing.T) { builder := NewContainerBuilder(config.CompiledDefaults()) container := builder.BuildEnvoyProxyContainerWithSpireOption(true) @@ -710,8 +340,8 @@ func TestBuildProxySidecarContainer_SpireDisabled(t *testing.T) { if container.Name != AuthBridgeProxyContainerName { t.Errorf("container name = %q, want %q", container.Name, AuthBridgeProxyContainerName) } - if container.Image != config.CompiledDefaults().Images.AuthBridgeLight { - t.Errorf("image = %q, want %q", container.Image, config.CompiledDefaults().Images.AuthBridgeLight) + if container.Image != config.CompiledDefaults().Images.AuthBridge { + t.Errorf("image = %q, want %q", container.Image, config.CompiledDefaults().Images.AuthBridge) } // Should have --config args (mode comes from per-agent ConfigMap, not CLI) diff --git a/kagenti-operator/internal/webhook/injector/injection_decision.go b/kagenti-operator/internal/webhook/injector/injection_decision.go index 78a21fc5..839b8fd2 100644 --- a/kagenti-operator/internal/webhook/injector/injection_decision.go +++ b/kagenti-operator/internal/webhook/injector/injection_decision.go @@ -8,14 +8,28 @@ type SidecarDecision struct { } // InjectionDecision holds the per-sidecar injection decisions for a workload. +// +// SpiffeHelper here is a per-workload SPIRE-enabled flag, not a separate +// container — spiffe-helper is bundled inside the combined authbridge +// images and starts conditionally on SPIRE_ENABLED. The flag still +// controls SPIRE volume mounts, ServiceAccount provisioning, and the +// SPIRE_ENABLED env var on the combined container. +// +// Client registration is operator-managed (no in-pod sidecar) and is +// no longer represented in the decision struct. +// +// TODO: rename SpiffeHelper to SpireEnabled (and the +// kagenti.io/spiffe-helper-inject label to kagenti.io/spire-enabled) so +// the names match what the field actually controls now that the +// standalone helper sidecar is gone. Left for a follow-up PR to keep +// this one focused. type InjectionDecision struct { - EnvoyProxy SidecarDecision - ProxyInit SidecarDecision // follows EnvoyProxy - SpiffeHelper SidecarDecision - ClientRegistration SidecarDecision + EnvoyProxy SidecarDecision + ProxyInit SidecarDecision // follows EnvoyProxy + SpiffeHelper SidecarDecision } // AnyInjected returns true if at least one sidecar will be injected. func (d *InjectionDecision) AnyInjected() bool { - return d.EnvoyProxy.Inject || d.SpiffeHelper.Inject || d.ClientRegistration.Inject + return d.EnvoyProxy.Inject || d.SpiffeHelper.Inject } diff --git a/kagenti-operator/internal/webhook/injector/namespace_config.go b/kagenti-operator/internal/webhook/injector/namespace_config.go index 29b677d0..f93e8687 100644 --- a/kagenti-operator/internal/webhook/injector/namespace_config.go +++ b/kagenti-operator/internal/webhook/injector/namespace_config.go @@ -22,6 +22,7 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" ) var nsConfigLog = logf.Log.WithName("namespace-config") @@ -133,3 +134,30 @@ func getConfigMap(ctx context.Context, c client.Reader, namespace, name string) } return cm, nil } + +// ExtractMode parses an authbridge-runtime-config config.yaml string and +// returns the value of its top-level `mode:` key. Returns "" if the YAML +// is empty, malformed, or has no `mode` field — in any of those cases the +// caller should fall back to the cluster default. +// +// Used by pod_mutator's mode-resolution chain. Stays a small surgical +// parse rather than a full YAML decode so it tolerates older or +// hand-edited ConfigMaps that may have other unknown top-level keys. +func ExtractMode(authbridgeYAML string) string { + if authbridgeYAML == "" { + return "" + } + var top struct { + Mode string `json:"mode"` + } + if err := yaml.Unmarshal([]byte(authbridgeYAML), &top); err != nil { + // Fail-safe: empty string lets the resolution chain fall through + // to the next layer. Log a warning so operators can spot a + // malformed authbridge-runtime-config — silent failure here was + // flagged in PR #361 review. + nsConfigLog.Info("WARN: failed to parse authbridge-runtime-config config.yaml; falling back to next resolution layer", + "error", err.Error()) + return "" + } + return top.Mode +} diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator.go b/kagenti-operator/internal/webhook/injector/pod_mutator.go index e631bdec..80b5d33c 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator.go @@ -19,7 +19,6 @@ package injector import ( "context" "fmt" - "strings" "github.com/kagenti/operator/internal/webhook/config" appsv1 "k8s.io/api/apps/v1" @@ -36,10 +35,6 @@ import ( var mutatorLog = logf.Log.WithName("pod-mutator") const ( - // Container names - SpiffeHelperContainerName = "spiffe-helper" - ClientRegistrationContainerName = "kagenti-client-registration" - // Label selector for authbridge injection opt-out. // Injection uses opt-out semantics for agents: sidecars are injected by // default. Setting AuthBridgeInjectLabel=AuthBridgeDisabledValue on a @@ -78,9 +73,8 @@ const ( ) type PodMutator struct { - Client client.Client - APIReader client.Reader // uncached reader for cross-namespace ConfigMap reads - EnableClientRegistration bool + Client client.Client + APIReader client.Reader // uncached reader for cross-namespace ConfigMap reads // Getter functions for hot-reloadable config (used by precedence evaluator) GetPlatformConfig func() *config.PlatformConfig GetFeatureGates func() *config.FeatureGates @@ -89,16 +83,14 @@ type PodMutator struct { func NewPodMutator( c client.Client, apiReader client.Reader, - enableClientRegistration bool, getPlatformConfig func() *config.PlatformConfig, getFeatureGates func() *config.FeatureGates, ) *PodMutator { return &PodMutator{ - Client: c, - APIReader: apiReader, - EnableClientRegistration: enableClientRegistration, - GetPlatformConfig: getPlatformConfig, - GetFeatureGates: getFeatureGates, + Client: c, + APIReader: apiReader, + GetPlatformConfig: getPlatformConfig, + GetFeatureGates: getFeatureGates, } } @@ -156,7 +148,6 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp {"envoy-proxy", decision.EnvoyProxy}, {"proxy-init", decision.ProxyInit}, {"spiffe-helper", decision.SpiffeHelper}, - {"client-registration", decision.ClientRegistration}, } { mutatorLog.Info("injection decision", "sidecar", d.name, @@ -263,14 +254,60 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // Mode-aware injection // ======================================== // - // The authbridge-mode annotation selects the deployment pattern: - // envoy-sidecar (default) — iptables + Envoy + ext_proc (authbridge-envoy image) - // proxy-sidecar — HTTP_PROXY env + lightweight authbridge (authbridge-light image) + // Three deployment shapes: + // proxy-sidecar (default) — HTTP_PROXY env + authbridge-proxy container (authbridge image) + // envoy-sidecar — iptables + Envoy + ext_proc (authbridge-envoy image) // waypoint — standalone deployment, not injected as sidecar - authBridgeMode := annotations[AnnotationAuthBridgeMode] + // + // Resolution chain (first non-empty wins): + // 1. AgentRuntime CR `Spec.AuthBridgeMode` (per-workload override) + // 2. namespace authbridge-runtime-config `mode:` field (namespace default) + // 3. kagenti.io/authbridge-mode annotation (deprecated) + // 4. ModeProxySidecar (cluster-wide fallback) + authBridgeMode := "" + modeSource := "" + if arOverrides != nil && arOverrides.AuthBridgeMode != nil { + authBridgeMode = *arOverrides.AuthBridgeMode + modeSource = "agentruntime-cr" + } if authBridgeMode == "" { - authBridgeMode = ModeEnvoySidecar + if m := ExtractMode(nsConfig.AuthBridgeRuntimeYAML); m != "" { + authBridgeMode = m + modeSource = "namespace-configmap" + } } + if authBridgeMode == "" { + if m := annotations[AnnotationAuthBridgeMode]; m != "" { + authBridgeMode = m + modeSource = "annotation-deprecated" + mutatorLog.Info("DEPRECATED: kagenti.io/authbridge-mode annotation used; set AgentRuntime.Spec.AuthBridgeMode instead", + "namespace", namespace, "crName", crName, "mode", authBridgeMode) + } + } + if authBridgeMode == "" { + authBridgeMode = ModeProxySidecar + modeSource = "cluster-default" + } + // Validate the resolved value. The CRD path is enum-checked by the + // API server, but the namespace ConfigMap and the deprecated + // annotation accept arbitrary strings — a typo (e.g. + // "proxy-sidecart") would otherwise flow through to the + // envoy-sidecar branch silently. Fall back to the cluster default + // and log a warning so operators can spot the typo. Per PR #361 + // review feedback. + switch authBridgeMode { + case ModeProxySidecar, ModeEnvoySidecar, ModeLite, ModeWaypoint: + // recognized, keep as-is + default: + mutatorLog.Info("WARN: unrecognized authBridgeMode; defaulting to proxy-sidecar", + "namespace", namespace, "crName", crName, + "unrecognized", authBridgeMode, "source", modeSource) + authBridgeMode = ModeProxySidecar + modeSource = "cluster-default-invalid-fallback" + } + mutatorLog.Info("resolved authbridge mode", + "namespace", namespace, "crName", crName, + "mode", authBridgeMode, "source", modeSource) if authBridgeMode == ModeWaypoint { mutatorLog.Info("waypoint mode — skipping sidecar injection (waypoint is a standalone deployment)", @@ -278,15 +315,29 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp return false, nil } - if authBridgeMode == ModeProxySidecar { - // Proxy-sidecar mode: inject lightweight authbridge container + HTTP_PROXY env vars. + if authBridgeMode == ModeProxySidecar || authBridgeMode == ModeLite { + // Proxy-sidecar / lite mode: inject the authbridge container + HTTP_PROXY env vars. // No iptables, no proxy-init, no Envoy. // + // proxy-sidecar uses Images.AuthBridge (full plugin set including parsers). + // lite uses Images.AuthBridgeLite (auth-only — parsers dropped). + // Listener layout, ports, ConfigMap shape, and SPIRE wiring are identical; + // only the image differs. + // // Port-stealing: the reverse proxy takes over the agent's original port so // the Service doesn't need patching. The agent is moved to a free port. // Service → :8000 → reverse proxy (validates JWT) → :8002 → agent // Agent outbound → HTTP_PROXY=127.0.0.1:8081 → forward proxy + // Pick the image based on mode. The lite binary still accepts + // mode=proxy-sidecar in its YAML config (lite is a build + // variant, not a runtime mode), so the per-agent ConfigMap's + // `mode:` field stays "proxy-sidecar" regardless. + proxyImage := builder.cfg.Images.AuthBridge + if authBridgeMode == ModeLite { + proxyImage = builder.cfg.Images.AuthBridgeLite + } + // Collect all ports in use across all containers in the pod. usedPorts := map[int32]bool{} for _, c := range podSpec.Containers { @@ -300,9 +351,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp var agentContainer *corev1.Container for i := range podSpec.Containers { c := &podSpec.Containers[i] - if c.Name == AuthBridgeProxyContainerName || - c.Name == SpiffeHelperContainerName || - c.Name == ClientRegistrationContainerName { + if c.Name == AuthBridgeProxyContainerName { continue } if len(c.Ports) > 0 { @@ -378,6 +427,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp podSpec.Containers = append(podSpec.Containers, builder.BuildProxySidecarContainerWithPorts( spireEnabled, + proxyImage, originalAgentPort, // reverse proxy listens here newAgentPort, // forwards to agent here forwardProxyPort, // forward proxy listens here @@ -387,21 +437,14 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // Inject HTTP_PROXY env vars into all existing app containers for i := range podSpec.Containers { c := &podSpec.Containers[i] - if c.Name == AuthBridgeProxyContainerName || - c.Name == SpiffeHelperContainerName || - c.Name == ClientRegistrationContainerName { + if c.Name == AuthBridgeProxyContainerName { continue } injectHTTPProxyEnv(c, forwardProxyPort) } - // spiffe-helper and client-registration are still injected - if decision.SpiffeHelper.Inject && !containerExists(podSpec.Containers, SpiffeHelperContainerName) { - podSpec.Containers = append(podSpec.Containers, builder.BuildSpiffeHelperContainer()) - } - if decision.ClientRegistration.Inject && !containerExists(podSpec.Containers, ClientRegistrationContainerName) { - podSpec.Containers = append(podSpec.Containers, builder.BuildClientRegistrationContainerWithSpireOption(crName, namespace, spireEnabled)) - } + // spiffe-helper is bundled in the authbridge combined image and + // gated by SPIRE_ENABLED; client-registration is operator-managed. // Inject volumes — use per-agent ConfigMap name for authbridge config. // requiredVolumes is always set above (resolved or legacy path) before @@ -424,7 +467,8 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp mutatorLog.Info("proxy-sidecar mode injection complete", "namespace", namespace, "crName", crName, - "image", builder.cfg.Images.AuthBridgeLight, + "resolvedMode", authBridgeMode, + "image", proxyImage, "perAgentConfigMap", perAgentCMName, "reverseProxyPort", originalAgentPort, "agentPort", newAgentPort, @@ -437,59 +481,28 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp } // ======================================== - // Envoy-sidecar mode (default) + // Envoy-sidecar mode // ======================================== + // + // Single combined container (authbridge-envoy image): Envoy + ext_proc + // authbridge + bundled spiffe-helper. proxy-init is a separate + // init container. spiffe-helper starts conditionally on SPIRE_ENABLED. - // Create per-agent ConfigMap for envoy-sidecar mode (no listener overrides). - // Skip when combinedSidecar is enabled — that container uses env vars directly - // and does not mount authbridge-runtime-config. - if !currentGates.CombinedSidecar { - perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, - ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil) - if err != nil { - return false, fmt.Errorf("envoy-sidecar per-agent ConfigMap: %w", err) - } - requiredVolumes = overrideAuthBridgeConfigMapInVolumes(requiredVolumes, perAgentCMName) + perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName, + ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil) + if err != nil { + return false, fmt.Errorf("envoy-sidecar per-agent ConfigMap: %w", err) } + requiredVolumes = overrideAuthBridgeConfigMapInVolumes(requiredVolumes, perAgentCMName) - // Conditionally inject sidecars based on precedence decisions. - // Two modes controlled by the combinedSidecar feature gate: - // true → combined mode: single "authbridge" container replaces envoy-proxy + - // spiffe-helper + client-registration. proxy-init is still separate. - // false → legacy mode: separate sidecar containers (unchanged behavior). - if currentGates.CombinedSidecar { - // Combined mode: inject single authbridge container (only when envoy-proxy is enabled) - if decision.EnvoyProxy.Inject && !containerExists(podSpec.Containers, AuthBridgeContainerName) { - podSpec.Containers = append(podSpec.Containers, - builder.BuildAuthBridgeContainer(crName, namespace, - decision.SpiffeHelper.Inject, - decision.ClientRegistration.Inject)) - } - // proxy-init is still injected separately - if decision.ProxyInit.Inject && !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - outboundExclude := annotations[OutboundPortsExcludeAnnotation] - inboundExclude := annotations[InboundPortsExcludeAnnotation] - podSpec.InitContainers = append(podSpec.InitContainers, builder.BuildProxyInitContainer(outboundExclude, inboundExclude)) - } - } else { - // Legacy mode: separate sidecar containers - if decision.EnvoyProxy.Inject && !containerExists(podSpec.Containers, EnvoyProxyContainerName) { - podSpec.Containers = append(podSpec.Containers, builder.BuildEnvoyProxyContainerWithSpireOption(spireEnabled)) - } - - if decision.ProxyInit.Inject && !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - outboundExclude := annotations[OutboundPortsExcludeAnnotation] - inboundExclude := annotations[InboundPortsExcludeAnnotation] - podSpec.InitContainers = append(podSpec.InitContainers, builder.BuildProxyInitContainer(outboundExclude, inboundExclude)) - } - - if decision.SpiffeHelper.Inject && !containerExists(podSpec.Containers, SpiffeHelperContainerName) { - podSpec.Containers = append(podSpec.Containers, builder.BuildSpiffeHelperContainer()) - } + if decision.EnvoyProxy.Inject && !containerExists(podSpec.Containers, EnvoyProxyContainerName) { + podSpec.Containers = append(podSpec.Containers, builder.BuildEnvoyProxyContainerWithSpireOption(spireEnabled)) + } - if decision.ClientRegistration.Inject && !containerExists(podSpec.Containers, ClientRegistrationContainerName) { - podSpec.Containers = append(podSpec.Containers, builder.BuildClientRegistrationContainerWithSpireOption(crName, namespace, spireEnabled)) - } + if decision.ProxyInit.Inject && !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + outboundExclude := annotations[OutboundPortsExcludeAnnotation] + inboundExclude := annotations[InboundPortsExcludeAnnotation] + podSpec.InitContainers = append(podSpec.InitContainers, builder.BuildProxyInitContainer(outboundExclude, inboundExclude)) } // Inject volumes @@ -502,9 +515,6 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp // Mount operator-managed Keycloak client credentials if annotation is present ApplyKeycloakClientCredentialsSecretVolumes(podSpec, annotations) - // Log how credentials are delivered for this pod - logClientRegistrationPaths(namespace, crName, labels, currentGates.CombinedSidecar, decision, annotations) - // Set fsGroup for shared volume access when SPIRE is enabled if spireEnabled { ensureFSGroup(podSpec) @@ -799,39 +809,6 @@ func volumeExists(volumes []corev1.Volume, name string) bool { return false } -// logClientRegistrationPaths logs how Keycloak credentials are delivered to this pod. -func logClientRegistrationPaths(namespace, crName string, labels map[string]string, combinedSidecar bool, decision InjectionDecision, annotations map[string]string) { - keycloakClientCredentialsSecret := strings.TrimSpace(annotations[AnnotationKeycloakClientSecretName]) - - var paths []string - if keycloakClientCredentialsSecret != "" { - paths = append(paths, "operator-secret") - } - - if combinedSidecar { - if decision.EnvoyProxy.Inject && decision.ClientRegistration.Inject { - paths = append(paths, "combined-authbridge") - } - } else if decision.ClientRegistration.Inject { - paths = append(paths, "sidecar") - } - - if len(paths) == 0 { - paths = append(paths, "skip") - } - - mutatorLog.Info("AuthBridge client registration: how credentials are supplied for this Pod", - "namespace", namespace, - "workloadKey", crName, - "kagentiType", labels[KagentiTypeLabel], - "deliveryPaths", strings.Join(paths, ","), - "keycloakClientCredentialsSecretName", keycloakClientCredentialsSecret, - "combinedSidecarMode", combinedSidecar, - "injectClientRegistrationSidecar", decision.ClientRegistration.Inject, - "injectEnvoyOrAuthbridge", decision.EnvoyProxy.Inject, - ) -} - // ensureFSGroup sets fsGroup in the pod security context to enable shared volume access. // This allows containers with different UIDs (spiffe-helper, client-registration, envoy-proxy) // to read/write files in shared volumes like svid-output. diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go index e817df76..ea090100 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go @@ -50,6 +50,16 @@ func newAgentRuntime(namespace, targetName string) *agentv1alpha1.AgentRuntime { } } +// newAgentRuntimeWithMode is the same as newAgentRuntime but pins the +// per-workload AuthBridgeMode (proxy-sidecar / envoy-sidecar / waypoint). +// Used by tests that exercise mode-specific code paths now that mode +// selection comes from the CR rather than a pod annotation. +func newAgentRuntimeWithMode(namespace, targetName, mode string) *agentv1alpha1.AgentRuntime { + rt := newAgentRuntime(namespace, targetName) + rt.Spec.AuthBridgeMode = mode + return rt +} + func newTestMutator(objs ...client.Object) *PodMutator { scheme := runtime.NewScheme() _ = corev1.AddToScheme(scheme) @@ -57,11 +67,10 @@ func newTestMutator(objs ...client.Object) *PodMutator { _ = agentv1alpha1.AddToScheme(scheme) fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() return &PodMutator{ - Client: fakeClient, - APIReader: fakeClient, - EnableClientRegistration: true, - GetPlatformConfig: config.CompiledDefaults, - GetFeatureGates: config.DefaultFeatureGates, + Client: fakeClient, + APIReader: fakeClient, + GetPlatformConfig: config.CompiledDefaults, + GetFeatureGates: config.DefaultFeatureGates, } } @@ -141,6 +150,7 @@ func TestEnsureServiceAccount_AlreadyExistsNoLabels(t *testing.T) { func TestInjectAuthBridge_NoAgentRuntime_InjectsWithDefaults(t *testing.T) { // Agent pod with correct labels but no AgentRuntime CR → inject with // defaults-only config (platform + namespace defaults, no CR overrides). + // Default mode is proxy-sidecar so the authbridge-proxy container is injected. m := newTestMutator() ctx := context.Background() @@ -157,15 +167,16 @@ func TestInjectAuthBridge_NoAgentRuntime_InjectsWithDefaults(t *testing.T) { t.Fatal("expected InjectAuthBridge to return true with defaults-only config") } - // Verify specific sidecar containers are present - if !containerExists(podSpec.Containers, EnvoyProxyContainerName) { - t.Errorf("expected %s container to be injected", EnvoyProxyContainerName) + // Default mode is proxy-sidecar — expect authbridge-proxy container, + // no envoy-proxy / proxy-init / standalone spiffe-helper. + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container to be injected", AuthBridgeProxyContainerName) } - if !containerExists(podSpec.Containers, SpiffeHelperContainerName) { - t.Errorf("expected %s container to be injected", SpiffeHelperContainerName) + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Errorf("unexpected %s container in proxy-sidecar mode", EnvoyProxyContainerName) } - if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - t.Errorf("expected %s init container to be injected", ProxyInitContainerName) + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Errorf("unexpected %s init container in proxy-sidecar mode", ProxyInitContainerName) } } @@ -346,7 +357,8 @@ func TestInjectAuthBridge_DefaultSAOverridden(t *testing.T) { } func TestInjectAuthBridge_OutboundPortsExcludeAnnotation(t *testing.T) { - m := newTestMutator(newAgentRuntime("test-ns", "my-agent")) + // proxy-init is only injected in envoy-sidecar mode. + m := newTestMutator(newAgentRuntimeWithMode("test-ns", "my-agent", ModeEnvoySidecar)) ctx := context.Background() podSpec := &corev1.PodSpec{} @@ -383,7 +395,8 @@ func TestInjectAuthBridge_OutboundPortsExcludeAnnotation(t *testing.T) { } func TestInjectAuthBridge_InboundPortsExcludeAnnotation(t *testing.T) { - m := newTestMutator(newAgentRuntime("test-ns", "my-agent")) + // proxy-init is only injected in envoy-sidecar mode. + m := newTestMutator(newAgentRuntimeWithMode("test-ns", "my-agent", ModeEnvoySidecar)) ctx := context.Background() podSpec := &corev1.PodSpec{} @@ -433,29 +446,9 @@ func TestInjectAuthBridge_InboundPortsExcludeAnnotation(t *testing.T) { t.Fatal("proxy-init container not found in initContainers") } -// ======================================== -// Combined sidecar mode tests -// ======================================== - -func newTestMutatorWithCombinedSidecar(objs ...client.Object) *PodMutator { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = agentv1alpha1.AddToScheme(scheme) - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() - return &PodMutator{ - Client: fakeClient, - EnableClientRegistration: true, - GetPlatformConfig: config.CompiledDefaults, - GetFeatureGates: func() *config.FeatureGates { - fg := config.DefaultFeatureGates() - fg.CombinedSidecar = true - return fg - }, - } -} - -func TestInjectAuthBridge_CombinedMode_SingleContainer(t *testing.T) { - m := newTestMutatorWithCombinedSidecar(newAgentRuntime("test-ns", "my-agent")) +func TestInjectAuthBridge_NilAnnotations(t *testing.T) { + // proxy-init is only injected in envoy-sidecar mode. + m := newTestMutator(newAgentRuntimeWithMode("test-ns", "my-agent", ModeEnvoySidecar)) ctx := context.Background() podSpec := &corev1.PodSpec{} @@ -471,173 +464,300 @@ func TestInjectAuthBridge_CombinedMode_SingleContainer(t *testing.T) { t.Fatal("expected InjectAuthBridge to return true") } - // Should have exactly 1 sidecar container (authbridge) — NOT envoy-proxy, spiffe-helper, or client-registration - if !containerExists(podSpec.Containers, AuthBridgeContainerName) { - t.Error("expected authbridge container to be injected") + for _, ic := range podSpec.InitContainers { + if ic.Name != ProxyInitContainerName { + continue + } + for _, env := range ic.Env { + if env.Name == "OUTBOUND_PORTS_EXCLUDE" { + if env.Value != "8080" { + t.Errorf("OUTBOUND_PORTS_EXCLUDE = %q, want %q (nil annotations should default to 8080 only)", env.Value, "8080") + } + return + } + } + t.Fatal("proxy-init container missing OUTBOUND_PORTS_EXCLUDE env var") + } + t.Fatal("proxy-init container not found in initContainers") +} + +// ======================================== +// Mode-aware injection tests +// ======================================== + +// authbridgeRuntimeConfigMap returns a fake authbridge-runtime-config +// ConfigMap pinning the given mode. Used by mode-resolution tests that +// exercise the namespace-config layer of the chain. +func authbridgeRuntimeConfigMap(namespace, mode string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: AuthBridgeRuntimeConfigMapName, + Namespace: namespace, + }, + Data: map[string]string{ + "config.yaml": "mode: " + mode + "\n", + }, } - if containerExists(podSpec.Containers, EnvoyProxyContainerName) { - t.Error("unexpected envoy-proxy container in combined mode") +} + +// Mode resolution chain (first non-empty wins): +// 1. AgentRuntime CR Spec.AuthBridgeMode +// 2. namespace authbridge-runtime-config mode field +// 3. kagenti.io/authbridge-mode annotation (deprecated) +// 4. ModeProxySidecar (cluster default) +// +// Layer 1 is exercised by the existing WaypointMode / ProxySidecarMode +// tests via newAgentRuntimeWithMode. The tests below cover layers 2-4. + +func TestInjectAuthBridge_ModeResolution_NamespaceConfigMap(t *testing.T) { + // AgentRuntime CR has no mode set; namespace ConfigMap pins envoy-sidecar. + m := newTestMutator( + newAgentRuntime("team1", "my-agent"), + authbridgeRuntimeConfigMap("team1", ModeEnvoySidecar), + ) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, } - if containerExists(podSpec.Containers, SpiffeHelperContainerName) { - t.Error("unexpected spiffe-helper container in combined mode") + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if containerExists(podSpec.Containers, ClientRegistrationContainerName) { - t.Error("unexpected client-registration container in combined mode") + if !mutated { + t.Fatal("expected mutation") } - // Should still have proxy-init + // envoy-sidecar shape: envoy-proxy + proxy-init, no authbridge-proxy + if !containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Errorf("expected %s container (namespace ConfigMap selected envoy-sidecar)", EnvoyProxyContainerName) + } if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - t.Error("expected proxy-init init container to be injected") + t.Errorf("expected %s init container", ProxyInitContainerName) + } + if containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Error("unexpected authbridge-proxy container in envoy-sidecar mode") } } -func TestInjectAuthBridge_CombinedMode_EnvoyDisabled_NoInjection(t *testing.T) { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = agentv1alpha1.AddToScheme(scheme) - ar := newAgentRuntime("test-ns", "my-agent") - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ar).Build() - m := &PodMutator{ - Client: fakeClient, - EnableClientRegistration: true, - GetPlatformConfig: config.CompiledDefaults, - GetFeatureGates: func() *config.FeatureGates { - fg := config.DefaultFeatureGates() - fg.CombinedSidecar = true - fg.EnvoyProxy = false - return fg - }, - } +func TestInjectAuthBridge_ModeResolution_CRBeatsNamespaceConfigMap(t *testing.T) { + // CR pins proxy-sidecar; namespace ConfigMap says envoy-sidecar. CR wins. + m := newTestMutator( + newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar), + authbridgeRuntimeConfigMap("team1", ModeEnvoySidecar), + ) ctx := context.Background() - podSpec := &corev1.PodSpec{} - labels := map[string]string{ - KagentiTypeLabel: KagentiTypeAgent, + podSpec := &corev1.PodSpec{ + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} - injected, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { - t.Fatalf("InjectAuthBridge() returned error: %v", err) + t.Fatalf("unexpected error: %v", err) + } + if !mutated { + t.Fatal("expected mutation") + } + + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container (CR field beats namespace ConfigMap)", AuthBridgeProxyContainerName) } - // With envoy-proxy disabled, the combined container should NOT be present - if containerExists(podSpec.Containers, AuthBridgeContainerName) { - t.Error("authbridge container should not be injected when envoy-proxy is disabled") + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Error("unexpected envoy-proxy container — CR pin should override namespace ConfigMap") } - _ = injected } -func TestInjectAuthBridge_CombinedMode_SpiffeDisabled_FlagPassed(t *testing.T) { - m := newTestMutatorWithCombinedSidecar(newAgentRuntime("test-ns", "my-agent")) +func TestInjectAuthBridge_ModeResolution_DeprecatedAnnotation(t *testing.T) { + // Neither CR nor namespace ConfigMap set; deprecated annotation pins envoy-sidecar. + m := newTestMutator(newAgentRuntime("team1", "my-agent")) ctx := context.Background() - podSpec := &corev1.PodSpec{} - labels := map[string]string{ - KagentiTypeLabel: KagentiTypeAgent, - LabelSpiffeHelperInject: "false", + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + annotations := map[string]string{AnnotationAuthBridgeMode: ModeEnvoySidecar} - injected, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) if err != nil { - t.Fatalf("InjectAuthBridge() returned error: %v", err) + t.Fatalf("unexpected error: %v", err) } - if !injected { - t.Fatal("expected InjectAuthBridge to return true") + if !mutated { + t.Fatal("expected mutation") } - // authbridge container should be present with SPIRE_ENABLED=false - if !containerExists(podSpec.Containers, AuthBridgeContainerName) { - t.Fatal("expected authbridge container to be injected") + if !containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Errorf("expected %s container (annotation fallback selected envoy-sidecar)", EnvoyProxyContainerName) } +} - for _, c := range podSpec.Containers { - if c.Name != AuthBridgeContainerName { - continue - } - for _, env := range c.Env { - if env.Name == "SPIRE_ENABLED" { - if env.Value != "false" { - t.Errorf("SPIRE_ENABLED = %q, want %q", env.Value, "false") - } - return - } - } - t.Fatal("missing SPIRE_ENABLED env var on authbridge container") +func TestInjectAuthBridge_ModeResolution_CRBeatsAnnotation(t *testing.T) { + // CR pins proxy-sidecar; annotation says envoy-sidecar. CR wins. + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + annotations := map[string]string{AnnotationAuthBridgeMode: ModeEnvoySidecar} + + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mutated { + t.Fatal("expected mutation") + } + + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container (CR field beats annotation)", AuthBridgeProxyContainerName) + } + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Error("unexpected envoy-proxy container — CR pin should override annotation") } } -func TestInjectAuthBridge_CombinedMode_Idempotency(t *testing.T) { - m := newTestMutatorWithCombinedSidecar(newAgentRuntime("test-ns", "my-agent")) +func TestInjectAuthBridge_ModeResolution_ClusterDefault(t *testing.T) { + // No CR, no namespace ConfigMap, no annotation — expect proxy-sidecar default. + m := newTestMutator(newAgentRuntime("team1", "my-agent")) ctx := context.Background() podSpec := &corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: AuthBridgeContainerName, Image: "authbridge:test"}, - }, + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, } - labels := map[string]string{ - KagentiTypeLabel: KagentiTypeAgent, + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mutated { + t.Fatal("expected mutation") } - injected, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container (cluster default is proxy-sidecar)", AuthBridgeProxyContainerName) + } + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Error("unexpected envoy-proxy container under default fallback") + } +} + +func TestInjectAuthBridge_LiteMode_UsesAuthBridgeLiteImage(t *testing.T) { + // Lite mode is structurally proxy-sidecar but uses Images.AuthBridgeLite. + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeLite)) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { - t.Fatalf("InjectAuthBridge() returned error: %v", err) + t.Fatalf("unexpected error: %v", err) } - if !injected { - t.Fatal("expected InjectAuthBridge to return true") + if !mutated { + t.Fatal("expected mutation") } - // Should still be exactly 1 authbridge container - count := 0 + // Same shape as proxy-sidecar: authbridge-proxy container, no envoy-proxy. + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container (lite mode uses proxy-sidecar shape)", AuthBridgeProxyContainerName) + } + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Error("unexpected envoy-proxy container in lite mode") + } + + // But the image must be AuthBridgeLite, not AuthBridge. + wantImage := config.CompiledDefaults().Images.AuthBridgeLite + gotImage := "" for _, c := range podSpec.Containers { - if c.Name == AuthBridgeContainerName { - count++ + if c.Name == AuthBridgeProxyContainerName { + gotImage = c.Image + break } } - if count != 1 { - t.Errorf("expected exactly 1 authbridge container, got %d", count) + if gotImage != wantImage { + t.Errorf("authbridge-proxy image = %q, want %q (Images.AuthBridgeLite)", gotImage, wantImage) } } -func TestInjectAuthBridge_NilAnnotations(t *testing.T) { - m := newTestMutator(newAgentRuntime("test-ns", "my-agent")) +func TestInjectAuthBridge_LiteMode_FromNamespaceConfigMap(t *testing.T) { + // Namespace ConfigMap pins lite; CR has no override. + m := newTestMutator( + newAgentRuntime("team1", "my-agent"), + authbridgeRuntimeConfigMap("team1", ModeLite), + ) ctx := context.Background() - podSpec := &corev1.PodSpec{} - labels := map[string]string{ - KagentiTypeLabel: KagentiTypeAgent, + podSpec := &corev1.PodSpec{ + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} - injected, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { - t.Fatalf("InjectAuthBridge() returned error: %v", err) + t.Fatalf("unexpected error: %v", err) } - if !injected { - t.Fatal("expected InjectAuthBridge to return true") + if !mutated { + t.Fatal("expected mutation") } - for _, ic := range podSpec.InitContainers { - if ic.Name != ProxyInitContainerName { - continue - } - for _, env := range ic.Env { - if env.Name == "OUTBOUND_PORTS_EXCLUDE" { - if env.Value != "8080" { - t.Errorf("OUTBOUND_PORTS_EXCLUDE = %q, want %q (nil annotations should default to 8080 only)", env.Value, "8080") - } - return - } + wantImage := config.CompiledDefaults().Images.AuthBridgeLite + for _, c := range podSpec.Containers { + if c.Name == AuthBridgeProxyContainerName && c.Image != wantImage { + t.Errorf("namespace ConfigMap selected lite but image = %q, want %q", c.Image, wantImage) } - t.Fatal("proxy-init container missing OUTBOUND_PORTS_EXCLUDE env var") } - t.Fatal("proxy-init container not found in initContainers") } -// ======================================== -// Mode-aware injection tests -// ======================================== +func TestInjectAuthBridge_ModeResolution_UnrecognizedFallsBackToProxySidecar(t *testing.T) { + // A typo in the namespace ConfigMap (e.g. "proxy-sidecart") should + // not silently flow through to the envoy-sidecar branch. The + // resolution chain validates the resolved value and falls back to + // proxy-sidecar with a WARN log. + m := newTestMutator( + newAgentRuntime("team1", "my-agent"), + authbridgeRuntimeConfigMap("team1", "proxy-sidecart"), + ) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + ServiceAccountName: "my-agent", + Containers: []corev1.Container{{Name: "agent", Image: "my-agent:latest"}}, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mutated { + t.Fatal("expected mutation despite unrecognized mode") + } + + // Should land on proxy-sidecar (the safe fallback), not envoy-sidecar. + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Errorf("expected %s container (typo should fall back to proxy-sidecar)", AuthBridgeProxyContainerName) + } + if containerExists(podSpec.Containers, EnvoyProxyContainerName) { + t.Error("unexpected envoy-proxy container — typo should not silently route to envoy-sidecar") + } +} func TestInjectAuthBridge_WaypointMode_SkipsInjection(t *testing.T) { - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeWaypoint)) ctx := context.Background() podSpec := &corev1.PodSpec{ @@ -648,11 +768,8 @@ func TestInjectAuthBridge_WaypointMode_SkipsInjection(t *testing.T) { labels := map[string]string{ KagentiTypeLabel: KagentiTypeAgent, } - annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeWaypoint, - } - mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -665,7 +782,7 @@ func TestInjectAuthBridge_WaypointMode_SkipsInjection(t *testing.T) { } func TestInjectAuthBridge_ProxySidecarMode_InjectsCorrectly(t *testing.T) { - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) ctx := context.Background() podSpec := &corev1.PodSpec{ @@ -677,11 +794,8 @@ func TestInjectAuthBridge_ProxySidecarMode_InjectsCorrectly(t *testing.T) { labels := map[string]string{ KagentiTypeLabel: KagentiTypeAgent, } - annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeProxySidecar, - } - mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -694,8 +808,8 @@ func TestInjectAuthBridge_ProxySidecarMode_InjectsCorrectly(t *testing.T) { for _, c := range podSpec.Containers { if c.Name == AuthBridgeProxyContainerName { proxyFound = true - if c.Image != config.CompiledDefaults().Images.AuthBridgeLight { - t.Errorf("proxy container image = %q, want authbridge-light", c.Image) + if c.Image != config.CompiledDefaults().Images.AuthBridge { + t.Errorf("proxy container image = %q, want %q", c.Image, config.CompiledDefaults().Images.AuthBridge) } } } @@ -750,7 +864,7 @@ func TestInjectAuthBridge_ProxySidecarMode_MountsKeycloakCredentials(t *testing. // Regression: the proxy-sidecar branch used to return before reaching // ApplyKeycloakClientCredentialsSecretVolumes. That left authbridge-proxy polling // /shared/client-id.txt forever and returning 503 "identity not yet configured". - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) ctx := context.Background() podSpec := &corev1.PodSpec{ @@ -763,7 +877,6 @@ func TestInjectAuthBridge_ProxySidecarMode_MountsKeycloakCredentials(t *testing. KagentiTypeLabel: KagentiTypeAgent, } annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeProxySidecar, AnnotationKeycloakClientSecretName: "kagenti-keycloak-client-credentials-abc12345", } @@ -854,7 +967,7 @@ func TestInjectHTTPProxyEnv_DoesNotDuplicate(t *testing.T) { } func TestInjectAuthBridge_ProxySidecarMode_PortCollision(t *testing.T) { - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) ctx := context.Background() // Agent uses ports 8000 and 8001 — agent should move to 8002, not 8001 @@ -874,11 +987,8 @@ func TestInjectAuthBridge_ProxySidecarMode_PortCollision(t *testing.T) { labels := map[string]string{ KagentiTypeLabel: KagentiTypeAgent, } - annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeProxySidecar, - } - mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -915,7 +1025,7 @@ func TestInjectAuthBridge_ProxySidecarMode_PortCollision(t *testing.T) { } func TestInjectAuthBridge_ProxySidecarMode_ForwardProxyCollision(t *testing.T) { - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) ctx := context.Background() // Agent uses port 8081 — forward proxy should use 8082 instead of default 8081 @@ -935,11 +1045,8 @@ func TestInjectAuthBridge_ProxySidecarMode_ForwardProxyCollision(t *testing.T) { labels := map[string]string{ KagentiTypeLabel: KagentiTypeAgent, } - annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeProxySidecar, - } - mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1036,7 +1143,7 @@ func TestSetOrAddEnv_AddsNew(t *testing.T) { } func TestInjectAuthBridge_ProxySidecarMode_NoPorts_UsesDefault(t *testing.T) { - m := newTestMutator() + m := newTestMutator(newAgentRuntimeWithMode("team1", "my-agent", ModeProxySidecar)) ctx := context.Background() // Agent container with no ports — should use default 8000 @@ -1049,11 +1156,8 @@ func TestInjectAuthBridge_ProxySidecarMode_NoPorts_UsesDefault(t *testing.T) { labels := map[string]string{ KagentiTypeLabel: KagentiTypeAgent, } - annotations := map[string]string{ - AnnotationAuthBridgeMode: ModeProxySidecar, - } - mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, annotations) + mutated, err := m.InjectAuthBridge(ctx, podSpec, "team1", "my-agent", labels, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/kagenti-operator/internal/webhook/injector/precedence.go b/kagenti-operator/internal/webhook/injector/precedence.go index e0f904f2..666a7061 100644 --- a/kagenti-operator/internal/webhook/injector/precedence.go +++ b/kagenti-operator/internal/webhook/injector/precedence.go @@ -46,14 +46,7 @@ func (e *PrecedenceEvaluator) Evaluate( e.featureGates.EnvoyProxy, workloadLabels[LabelEnvoyProxyInject], ), - SpiffeHelper: e.evaluateSidecar( - "spiffe-helper", - e.featureGates.SpiffeHelper, - workloadLabels[LabelSpiffeHelperInject], - ), - ClientRegistration: e.evaluateClientRegistration( - workloadLabels[LabelClientRegistrationInject], - ), + SpiffeHelper: evaluateSpiffeHelper(workloadLabels[LabelSpiffeHelperInject]), } // proxy-init always follows envoy-proxy @@ -66,28 +59,26 @@ func (e *PrecedenceEvaluator) Evaluate( return decision } -// evaluateClientRegistration applies feature gate then opt-in label semantics: the legacy -// client-registration sidecar (or combined authbridge registration slice) injects only when -// kagenti.io/client-registration-inject is exactly "true". Otherwise kagenti-operator is -// expected to register the client and supply credentials via pod template annotation. -func (e *PrecedenceEvaluator) evaluateClientRegistration(workloadLabelValue string) SidecarDecision { - if !e.featureGates.ClientRegistration { +// evaluateSpiffeHelper resolves the per-workload SPIRE-enabled flag from +// the kagenti.io/spiffe-helper-inject label. Spiffe-helper is bundled +// inside the combined authbridge images and gated by the SPIRE_ENABLED +// env var rather than by feature-gate, so this skips the feature-gate +// layer that evaluateSidecar applies. +// +// TODO: rename SpiffeHelper -> SpireEnabled (decision field + label) +// once the in-pod helper truly retires; left as-is here to keep this +// PR's blast radius contained. +func evaluateSpiffeHelper(workloadLabelValue string) SidecarDecision { + if workloadLabelValue == labelValueFalse { return SidecarDecision{ Inject: false, - Reason: "client-registration feature gate disabled", - Layer: "feature-gate", - } - } - if workloadLabelValue == labelValueTrue { - return SidecarDecision{ - Inject: true, - Reason: "workload opted in to legacy client-registration (kagenti.io/client-registration-inject=true)", + Reason: "workload label disabled spiffe-helper", Layer: "workload-label", } } return SidecarDecision{ - Inject: false, - Reason: "operator-managed client registration is default; set kagenti.io/client-registration-inject=true for legacy sidecar", + Inject: true, + Reason: "all gates passed", Layer: "default", } } diff --git a/kagenti-operator/internal/webhook/injector/precedence_test.go b/kagenti-operator/internal/webhook/injector/precedence_test.go index 2bdbe602..ddcce29a 100644 --- a/kagenti-operator/internal/webhook/injector/precedence_test.go +++ b/kagenti-operator/internal/webhook/injector/precedence_test.go @@ -22,217 +22,75 @@ func TestPrecedenceEvaluator(t *testing.T) { expectEnvoy bool expectProxyInit bool expectSpiffe bool - expectClientReg bool expectEnvoyLayer string }{ - // === Per-sidecar feature gate tests === + // === Per-sidecar feature gate === { - name: "per-sidecar gate off - envoy skipped", + name: "envoy gate off - envoy and proxy-init skipped", featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: false, - SpiffeHelper: true, - ClientRegistration: true, + GlobalEnabled: true, + EnvoyProxy: false, }, - workloadLabels: noLabels(), expectEnvoy: false, expectProxyInit: false, // follows envoy - expectSpiffe: true, - expectClientReg: false, // opt-in: no label → not injected + expectSpiffe: true, // spiffe gate is implicit-true expectEnvoyLayer: "feature-gate", }, - { - name: "per-sidecar gate off - spiffe skipped", - featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: true, - SpiffeHelper: false, - ClientRegistration: true, - }, - - workloadLabels: noLabels(), - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: false, - expectClientReg: false, // opt-in: no label → not injected - }, - { - name: "per-sidecar gate off - client-registration skipped", - featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: true, - SpiffeHelper: true, - ClientRegistration: false, - }, - - workloadLabels: noLabels(), - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, - }, - // === Workload label tests === + // === Workload label === { - name: "workload label disables envoy - envoy and proxy-init skipped", - featureGates: allEnabledGates(), - + name: "envoy label false - envoy+proxy-init skipped", + featureGates: allEnabledGates(), workloadLabels: map[string]string{LabelEnvoyProxyInject: "false"}, expectEnvoy: false, expectProxyInit: false, expectSpiffe: true, - expectClientReg: false, // opt-in: no label → not injected expectEnvoyLayer: "workload-label", }, { - name: "workload label disables spiffe only", - featureGates: allEnabledGates(), - + name: "spiffe label false - spiffe skipped, envoy unaffected", + featureGates: allEnabledGates(), workloadLabels: map[string]string{LabelSpiffeHelperInject: "false"}, expectEnvoy: true, expectProxyInit: true, expectSpiffe: false, - expectClientReg: false, // opt-in: no label → not injected }, { - name: "workload label disables client-registration explicitly", - featureGates: allEnabledGates(), - - workloadLabels: map[string]string{LabelClientRegistrationInject: "false"}, - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, - }, - { - name: "client-registration label true - opt-in sidecar injected", - featureGates: allEnabledGates(), - - workloadLabels: map[string]string{LabelClientRegistrationInject: "true"}, - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: true, - }, - { - name: "workload label true value on envoy - no effect on others", - featureGates: allEnabledGates(), - - workloadLabels: map[string]string{LabelEnvoyProxyInject: "true"}, - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, // opt-in: no client-reg label → not injected - }, - { - name: "workload labels absent - envoy+spiffe injected, client-reg not (opt-in)", - featureGates: allEnabledGates(), - - workloadLabels: noLabels(), - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, // opt-in: default is operator-managed + name: "no labels - envoy+spiffe injected", + featureGates: allEnabledGates(), + workloadLabels: noLabels(), + expectEnvoy: true, + expectProxyInit: true, + expectSpiffe: true, + expectEnvoyLayer: "default", }, { - name: "all workload opt-out labels set - all skipped", + name: "all opt-out labels set - all skipped", featureGates: allEnabledGates(), - workloadLabels: map[string]string{ - LabelEnvoyProxyInject: "false", - LabelSpiffeHelperInject: "false", - LabelClientRegistrationInject: "false", + LabelEnvoyProxyInject: "false", + LabelSpiffeHelperInject: "false", }, expectEnvoy: false, expectProxyInit: false, expectSpiffe: false, - expectClientReg: false, expectEnvoyLayer: "workload-label", }, - { - name: "all labels opt-in - everything injected", - featureGates: allEnabledGates(), - workloadLabels: map[string]string{ - LabelClientRegistrationInject: "true", - }, - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: true, - }, - - // === Precedence ordering: feature gate beats workload label === + // === Precedence ordering: gate beats workload label === { - name: "feature gate off + workload label absent - skipped (gate wins)", + name: "envoy gate off + label true - gate wins", featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: false, - SpiffeHelper: true, - ClientRegistration: true, + GlobalEnabled: true, + EnvoyProxy: false, }, - workloadLabels: map[string]string{LabelEnvoyProxyInject: "true"}, expectEnvoy: false, expectProxyInit: false, expectSpiffe: true, - expectClientReg: false, // opt-in: no label → not injected expectEnvoyLayer: "feature-gate", }, - { - name: "client-reg feature gate off beats opt-in label", - featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: true, - SpiffeHelper: true, - ClientRegistration: false, - }, - - workloadLabels: map[string]string{LabelClientRegistrationInject: "true"}, - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, // gate off overrides opt-in label - }, - { - name: "all gates pass, no client-reg label - envoy+spiffe injected", - featureGates: allEnabledGates(), - - workloadLabels: noLabels(), - expectEnvoy: true, - expectProxyInit: true, - expectSpiffe: true, - expectClientReg: false, // opt-in: default is not injected - expectEnvoyLayer: "default", - }, - - // === proxy-init coupling tests === - { - name: "envoy skipped via feature gate - proxy-init also skipped", - featureGates: &config.FeatureGates{ - GlobalEnabled: true, - EnvoyProxy: false, - SpiffeHelper: true, - ClientRegistration: true, - }, - - workloadLabels: noLabels(), - expectEnvoy: false, - expectProxyInit: false, - expectSpiffe: true, - expectClientReg: false, // opt-in: no label - }, - { - name: "envoy skipped via workload label - proxy-init also skipped", - featureGates: allEnabledGates(), - - workloadLabels: map[string]string{LabelEnvoyProxyInject: "false"}, - expectEnvoy: false, - expectProxyInit: false, - expectSpiffe: true, - expectClientReg: false, // opt-in: no label - }, } for _, tt := range tests { @@ -255,11 +113,6 @@ func TestPrecedenceEvaluator(t *testing.T) { decision.SpiffeHelper.Inject, tt.expectSpiffe, decision.SpiffeHelper.Reason, decision.SpiffeHelper.Layer) } - if decision.ClientRegistration.Inject != tt.expectClientReg { - t.Errorf("ClientRegistration.Inject = %v, want %v (reason: %s, layer: %s)", - decision.ClientRegistration.Inject, tt.expectClientReg, - decision.ClientRegistration.Reason, decision.ClientRegistration.Layer) - } if tt.expectEnvoyLayer != "" && decision.EnvoyProxy.Layer != tt.expectEnvoyLayer { t.Errorf("EnvoyProxy.Layer = %q, want %q", decision.EnvoyProxy.Layer, tt.expectEnvoyLayer) } @@ -274,29 +127,26 @@ func TestAnyInjected(t *testing.T) { want bool }{ { - name: "all injected", + name: "envoy + spiffe injected", decision: InjectionDecision{ - EnvoyProxy: SidecarDecision{Inject: true}, - SpiffeHelper: SidecarDecision{Inject: true}, - ClientRegistration: SidecarDecision{Inject: true}, + EnvoyProxy: SidecarDecision{Inject: true}, + SpiffeHelper: SidecarDecision{Inject: true}, }, want: true, }, { name: "only envoy injected", decision: InjectionDecision{ - EnvoyProxy: SidecarDecision{Inject: true}, - SpiffeHelper: SidecarDecision{Inject: false}, - ClientRegistration: SidecarDecision{Inject: false}, + EnvoyProxy: SidecarDecision{Inject: true}, + SpiffeHelper: SidecarDecision{Inject: false}, }, want: true, }, { name: "none injected", decision: InjectionDecision{ - EnvoyProxy: SidecarDecision{Inject: false}, - SpiffeHelper: SidecarDecision{Inject: false}, - ClientRegistration: SidecarDecision{Inject: false}, + EnvoyProxy: SidecarDecision{Inject: false}, + SpiffeHelper: SidecarDecision{Inject: false}, }, want: false, }, diff --git a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go index 9cc75d44..b92a8662 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go +++ b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook.go @@ -195,10 +195,7 @@ func (w *AuthBridgeWebhook) isAlreadyInjected(podSpec *corev1.PodSpec) bool { // containerExists/volumeExists checks for idempotency). for i := range podSpec.Containers { if podSpec.Containers[i].Name == injector.EnvoyProxyContainerName || - podSpec.Containers[i].Name == injector.AuthBridgeProxyContainerName || - podSpec.Containers[i].Name == injector.SpiffeHelperContainerName || - podSpec.Containers[i].Name == injector.ClientRegistrationContainerName || - podSpec.Containers[i].Name == injector.AuthBridgeContainerName { + podSpec.Containers[i].Name == injector.AuthBridgeProxyContainerName { return true } } diff --git a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook_test.go b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook_test.go index 27bca659..12dd10f9 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook_test.go +++ b/kagenti-operator/internal/webhook/v1alpha1/authbridge_webhook_test.go @@ -34,6 +34,13 @@ var testNsCounter int // createAgentRuntime creates an AgentRuntime CR in the given namespace targeting // the given workload name. The webhook requires a matching AgentRuntime to exist. func createAgentRuntime(namespace, targetName string) { + createAgentRuntimeWithMode(namespace, targetName, "") +} + +// createAgentRuntimeWithMode is the same as createAgentRuntime but pins +// the per-workload AuthBridgeMode field. Pass an empty string to leave +// mode resolution to the namespace ConfigMap / cluster default. +func createAgentRuntimeWithMode(namespace, targetName, mode string) { ar := &agentv1alpha1.AgentRuntime{ ObjectMeta: metav1.ObjectMeta{ Name: targetName + "-runtime", @@ -46,6 +53,7 @@ func createAgentRuntime(namespace, targetName string) { Kind: "Deployment", Name: targetName, }, + AuthBridgeMode: mode, }, } err := k8sClient.Create(ctx, ar) @@ -92,8 +100,9 @@ var _ = Describe("AuthBridge Pod Webhook", func() { Context("when a Pod has kagenti.io/type=agent and kagenti.io/inject=enabled", func() { It("should inject sidecars", func() { - // AgentRuntime CR must exist for injection to proceed - createAgentRuntime(testNamespace, "agent-pod") + // AgentRuntime CR pins envoy-sidecar mode so this test continues to + // exercise the envoy-proxy + proxy-init injection path. + createAgentRuntimeWithMode(testNamespace, "agent-pod", injector.ModeEnvoySidecar) pod := newTestPod("agent-pod", map[string]string{ "kagenti.io/type": "agent", @@ -179,8 +188,11 @@ var _ = Describe("AuthBridge Pod Webhook", func() { err = k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), pod) Expect(err).NotTo(HaveOccurred()) - Expect(containerNames(pod.Spec.Containers)).To(ContainElement(injector.EnvoyProxyContainerName)) - Expect(initContainerNames(pod.Spec.InitContainers)).To(ContainElement(injector.ProxyInitContainerName)) + // Default mode is proxy-sidecar — expect authbridge-proxy, no + // envoy-proxy or proxy-init. + Expect(containerNames(pod.Spec.Containers)).To(ContainElement(injector.AuthBridgeProxyContainerName)) + Expect(containerNames(pod.Spec.Containers)).NotTo(ContainElement(injector.EnvoyProxyContainerName)) + Expect(initContainerNames(pod.Spec.InitContainers)).NotTo(ContainElement(injector.ProxyInitContainerName)) }) }) @@ -216,30 +228,6 @@ var _ = Describe("AuthBridge Pod Webhook", func() { }) }) - Context("when a Pod already has the combined authbridge container (idempotency)", func() { - It("should not double-inject", func() { - pod := newTestPod("already-combined-pod", map[string]string{ - "kagenti.io/type": "agent", - "kagenti.io/inject": "enabled", - }) - // Pre-add the authbridge container to simulate prior combined injection - pod.Spec.Containers = append(pod.Spec.Containers, corev1.Container{ - Name: injector.AuthBridgeContainerName, - Image: "authbridge:test", - }) - - err := k8sClient.Create(ctx, pod) - Expect(err).NotTo(HaveOccurred()) - - err = k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), pod) - Expect(err).NotTo(HaveOccurred()) - - // Should not have added any additional sidecar containers - Expect(containerNames(pod.Spec.Containers)).NotTo(ContainElement(injector.EnvoyProxyContainerName)) - Expect(containerNames(pod.Spec.Containers)).To(ContainElement(injector.AuthBridgeContainerName)) - }) - }) - // Pre-population of the Keycloak client-credentials annotation ensures that the first pod // created for an agent workload mounts the operator-produced Secret without having to wait // for the ClientRegistration controller to patch the workload's pod template and trigger a diff --git a/kagenti-operator/internal/webhook/v1alpha1/webhook_suite_test.go b/kagenti-operator/internal/webhook/v1alpha1/webhook_suite_test.go index ef51e0bf..930e3eeb 100644 --- a/kagenti-operator/internal/webhook/v1alpha1/webhook_suite_test.go +++ b/kagenti-operator/internal/webhook/v1alpha1/webhook_suite_test.go @@ -124,7 +124,6 @@ var _ = BeforeSuite(func() { podMutator := injector.NewPodMutator( k8sClient, k8sClient, - true, func() *config.PlatformConfig { return config.CompiledDefaults() }, func() *config.FeatureGates { return config.DefaultFeatureGates() }, ) diff --git a/kagenti-operator/test/e2e/e2e_suite_test.go b/kagenti-operator/test/e2e/e2e_suite_test.go index e3d9cf4d..7cc5022c 100644 --- a/kagenti-operator/test/e2e/e2e_suite_test.go +++ b/kagenti-operator/test/e2e/e2e_suite_test.go @@ -53,12 +53,16 @@ var ( // signerImage is the agentcard-signer init-container image signerImage = "ghcr.io/kagenti/kagenti-operator/agentcard-signer:e2e-test" - // sidecarImages are the AuthBridge sidecar images to pull and load into Kind + // sidecarImages are the AuthBridge sidecar images to pull and load into Kind. + // kagenti-extensions ships two combined images plus proxy-init: + // * authbridge-envoy: envoy-sidecar mode (Envoy + ext_proc + bundled spiffe-helper) + // * authbridge: proxy-sidecar mode (authbridge-proxy + bundled spiffe-helper) + // * proxy-init: iptables init container, envoy-sidecar mode only + // Spiffe-helper and client-registration are no longer separate images. sidecarImages = []string{ "ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest", - "ghcr.io/kagenti/kagenti-extensions/authbridge-light:latest", + "ghcr.io/kagenti/kagenti-extensions/authbridge:latest", "ghcr.io/kagenti/kagenti-extensions/proxy-init:latest", - "ghcr.io/kagenti/kagenti-extensions/spiffe-helper:latest", } ) diff --git a/kagenti-operator/test/e2e/e2e_test.go b/kagenti-operator/test/e2e/e2e_test.go index 6e17bc7d..7833a924 100644 --- a/kagenti-operator/test/e2e/e2e_test.go +++ b/kagenti-operator/test/e2e/e2e_test.go @@ -416,7 +416,7 @@ var _ = Describe("AuthBridge Injection E2E", Ordered, func() { SetDefaultEventuallyPollingInterval(time.Second) Context("Sidecar injection", Ordered, func() { - It("should inject envoy-proxy, proxy-init, and spiffe-helper", func() { + It("should inject envoy-proxy + proxy-init with bundled spiffe-helper", func() { By("deploying authbridge-agent") _, err := utils.KubectlApplyStdin(authBridgeAgentFixture(), authBridgeTestNamespace) Expect(err).NotTo(HaveOccurred()) @@ -424,6 +424,12 @@ var _ = Describe("AuthBridge Injection E2E", Ordered, func() { By("waiting for deployment to be ready") Expect(utils.WaitForDeploymentReady("authbridge-agent", authBridgeTestNamespace, 3*time.Minute)).To(Succeed()) + // Spiffe-helper is bundled inside the authbridge-envoy combined + // image and gated by SPIRE_ENABLED — there is no separate + // "spiffe-helper" container anymore. Same for client-registration + // (operator-managed Secret). Bundling is verified below via the + // SPIRE_ENABLED env var + spiffe-helper-config volume mount on + // the envoy-proxy container. By("verifying injected sidecar containers") Eventually(func(g Gomega) { containers, err := utils.KubectlGetJsonpath("pod", "", @@ -431,10 +437,22 @@ var _ = Describe("AuthBridge Injection E2E", Ordered, func() { "{.items[?(@.metadata.labels.app\\.kubernetes\\.io/name=='authbridge-agent')].spec.containers[*].name}") g.Expect(err).NotTo(HaveOccurred()) g.Expect(containers).To(ContainSubstring("envoy-proxy")) - g.Expect(containers).To(ContainSubstring("spiffe-helper")) + g.Expect(containers).NotTo(ContainSubstring("spiffe-helper"), + "spiffe-helper is bundled inside envoy-proxy, not a separate container") g.Expect(containers).NotTo(ContainSubstring("kagenti-client-registration")) }).Should(Succeed()) + By("verifying spiffe-helper is wired into envoy-proxy via SPIRE_ENABLED env") + Eventually(func(g Gomega) { + labelSel := "@.metadata.labels.app\\.kubernetes\\.io/name=='authbridge-agent'" + jp := "{.items[?(" + labelSel + ")]" + + ".spec.containers[?(@.name=='envoy-proxy')]" + + ".env[?(@.name=='SPIRE_ENABLED')].value}" + spireEnv, err := utils.KubectlGetJsonpath("pod", "", authBridgeTestNamespace, jp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(spireEnv).To(Equal("true")) + }).Should(Succeed()) + By("verifying injected init containers") Eventually(func(g Gomega) { initContainers, err := utils.KubectlGetJsonpath("pod", "", @@ -496,7 +514,7 @@ var _ = Describe("AuthBridge Injection E2E", Ordered, func() { g.Expect(phase).To(Equal("Running")) }, 3*time.Minute, 2*time.Second).Should(Succeed()) - By("verifying exactly 1 envoy-proxy, 1 spiffe-helper, 1 proxy-init") + By("verifying exactly 1 envoy-proxy and 1 proxy-init (no separate spiffe-helper)") cmd = exec.Command("kubectl", "get", "pods", "-l", "app.kubernetes.io/name=authbridge-agent", "-n", authBridgeTestNamespace, @@ -504,7 +522,8 @@ var _ = Describe("AuthBridge Injection E2E", Ordered, func() { containers, err := utils.Run(cmd) Expect(err).NotTo(HaveOccurred()) Expect(strings.Count(containers, "envoy-proxy")).To(Equal(1), "expected exactly 1 envoy-proxy") - Expect(strings.Count(containers, "spiffe-helper")).To(Equal(1), "expected exactly 1 spiffe-helper") + Expect(strings.Count(containers, "spiffe-helper")).To(Equal(0), + "spiffe-helper is bundled inside envoy-proxy, should not appear as a separate container") cmd = exec.Command("kubectl", "get", "pods", "-l", "app.kubernetes.io/name=authbridge-agent", @@ -1731,6 +1750,9 @@ rules: }) It("should inject Auth Bridge sidecars into workload pods", func() { + // Spiffe-helper is bundled inside the envoy-proxy combined image + // and gated by SPIRE_ENABLED — verified below via env var, not + // by presence of a separate container. By("verifying injected sidecar containers") Eventually(func(g Gomega) { containers, err := utils.KubectlGetJsonpath("pod", "", @@ -1738,7 +1760,19 @@ rules: "{.items[?(@.metadata.labels.app\\.kubernetes\\.io/name=='combined-agent')].spec.containers[*].name}") g.Expect(err).NotTo(HaveOccurred()) g.Expect(containers).To(ContainSubstring("envoy-proxy")) - g.Expect(containers).To(ContainSubstring("spiffe-helper")) + g.Expect(containers).NotTo(ContainSubstring("spiffe-helper"), + "spiffe-helper is bundled inside envoy-proxy, not a separate container") + }).Should(Succeed()) + + By("verifying spiffe-helper is wired into envoy-proxy via SPIRE_ENABLED env") + Eventually(func(g Gomega) { + labelSel := "@.metadata.labels.app\\.kubernetes\\.io/name=='combined-agent'" + jp := "{.items[?(" + labelSel + ")]" + + ".spec.containers[?(@.name=='envoy-proxy')]" + + ".env[?(@.name=='SPIRE_ENABLED')].value}" + spireEnv, err := utils.KubectlGetJsonpath("pod", "", combinedTestNamespace, jp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(spireEnv).To(Equal("true")) }).Should(Succeed()) By("verifying injected init containers") @@ -1885,14 +1919,15 @@ rules: g.Expect(phase).To(Equal("Running")) }, 3*time.Minute, 2*time.Second).Should(Succeed()) - By("verifying replacement pod has sidecars") + By("verifying replacement pod has sidecars (spiffe-helper bundled in envoy-proxy)") Eventually(func(g Gomega) { containers, err := utils.KubectlGetJsonpath("pod", "", combinedTestNamespace, "{.items[?(@.metadata.labels.app\\.kubernetes\\.io/name=='combined-agent')].spec.containers[*].name}") g.Expect(err).NotTo(HaveOccurred()) g.Expect(containers).To(ContainSubstring("envoy-proxy")) - g.Expect(containers).To(ContainSubstring("spiffe-helper")) + g.Expect(containers).NotTo(ContainSubstring("spiffe-helper"), + "spiffe-helper is bundled inside envoy-proxy, not a separate container") }).Should(Succeed()) By("verifying replacement pod has proxy-init")