From 555554a539d7323f91c5bce7efce711f88f988fc Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 16 Jun 2026 10:54:22 +0100 Subject: [PATCH 1/8] feat: add HelperConfig field to SpiffeConfig schema Add HelperConfig string field to SpiffeConfig struct so PlatformConfig can carry raw spiffe-helper helper.conf content. Wire default content into CompiledDefaults() and log truncated snippet on startup. RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../internal/webhook/config/defaults.go | 20 +++++++- .../internal/webhook/config/loader.go | 5 ++ .../internal/webhook/config/types.go | 5 +- .../internal/webhook/config/types_test.go | 51 ++++++++++++++++++- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/kagenti-operator/internal/webhook/config/defaults.go b/kagenti-operator/internal/webhook/config/defaults.go index 58fb5abf..ac60d1be 100644 --- a/kagenti-operator/internal/webhook/config/defaults.go +++ b/kagenti-operator/internal/webhook/config/defaults.go @@ -5,6 +5,21 @@ import ( "k8s.io/apimachinery/pkg/api/resource" ) +// DefaultSpiffeHelperConfig is the default helper.conf content for spiffe-helper. +// Keep in sync with charts/kagenti-operator/values.yaml defaults.spiffe.helperConfig. +const DefaultSpiffeHelperConfig = `agent_address = "/spiffe-workload-api/spire-agent.sock" +cmd = "" +cmd_args = "" +svid_file_name = "/opt/svid.pem" +svid_key_file_name = "/opt/svid_key.pem" +svid_bundle_file_name = "/opt/svid_bundle.pem" +cert_file_mode = 0644 +key_file_mode = 0640 +jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] +jwt_svid_file_mode = 0644 +include_federated_domains = true +` + // CompiledDefaults returns hardcoded defaults used when no config is provided func CompiledDefaults() *PlatformConfig { return &PlatformConfig{ @@ -79,8 +94,9 @@ func CompiledDefaults() *PlatformConfig { DefaultScopes: []string{"openid"}, }, Spiffe: SpiffeConfig{ - TrustDomain: "cluster.local", - SocketPath: "unix:///spiffe-workload-api/spire-agent.sock", + TrustDomain: "cluster.local", + SocketPath: "unix:///spiffe-workload-api/spire-agent.sock", + HelperConfig: DefaultSpiffeHelperConfig, }, Observability: ObservabilityConfig{ LogLevel: "info", diff --git a/kagenti-operator/internal/webhook/config/loader.go b/kagenti-operator/internal/webhook/config/loader.go index 10030168..ea740a47 100644 --- a/kagenti-operator/internal/webhook/config/loader.go +++ b/kagenti-operator/internal/webhook/config/loader.go @@ -227,9 +227,14 @@ func logConfig(cfg *PlatformConfig, source string) { "defaultAudience", cfg.TokenExchange.DefaultAudience, "defaultScopes", cfg.TokenExchange.DefaultScopes, ) + helperSnippet := cfg.Spiffe.HelperConfig + if len(helperSnippet) > 80 { + helperSnippet = helperSnippet[:80] + "..." + } log.Info("[config] spiffe", "trustDomain", cfg.Spiffe.TrustDomain, "socketPath", cfg.Spiffe.SocketPath, + "helperConfig", helperSnippet, ) log.Info("=============================================") } diff --git a/kagenti-operator/internal/webhook/config/types.go b/kagenti-operator/internal/webhook/config/types.go index be035c0c..9735b06b 100644 --- a/kagenti-operator/internal/webhook/config/types.go +++ b/kagenti-operator/internal/webhook/config/types.go @@ -86,8 +86,9 @@ type TokenExchangeDefaults struct { } type SpiffeConfig struct { - TrustDomain string `json:"trustDomain" yaml:"trustDomain"` - SocketPath string `json:"socketPath" yaml:"socketPath"` + TrustDomain string `json:"trustDomain" yaml:"trustDomain"` + SocketPath string `json:"socketPath" yaml:"socketPath"` + HelperConfig string `json:"helperConfig" yaml:"helperConfig"` } type ObservabilityConfig struct { diff --git a/kagenti-operator/internal/webhook/config/types_test.go b/kagenti-operator/internal/webhook/config/types_test.go index 2bb86431..8f7398a2 100644 --- a/kagenti-operator/internal/webhook/config/types_test.go +++ b/kagenti-operator/internal/webhook/config/types_test.go @@ -1,6 +1,11 @@ package config -import "testing" +import ( + "strings" + "testing" + + "sigs.k8s.io/yaml" +) // TransparentPort is the REDIRECT target for the enforce-redirect guard; it must // be a valid, non-privileged port (the proxy binds it as a non-root user). @@ -60,3 +65,47 @@ func TestValidate_IptablesCmd(t *testing.T) { }) } } + +func TestCompiledDefaults_SpiffeHelperConfig(t *testing.T) { + cfg := CompiledDefaults() + if cfg.Spiffe.HelperConfig == "" { + t.Fatal("CompiledDefaults().Spiffe.HelperConfig must not be empty") + } + if !strings.Contains(cfg.Spiffe.HelperConfig, "agent_address") { + t.Error("HelperConfig must contain agent_address") + } + if !strings.Contains(cfg.Spiffe.HelperConfig, "svid_file_name") { + t.Error("HelperConfig must contain svid_file_name") + } +} + +func TestSpiffeHelperConfig_YAMLRoundTrip(t *testing.T) { + input := ` +spiffe: + trustDomain: example.org + socketPath: "unix:///custom/socket.sock" + helperConfig: | + agent_address = "/custom/socket.sock" + cmd = "" +` + var cfg PlatformConfig + if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { + t.Fatalf("YAML unmarshal failed: %v", err) + } + if cfg.Spiffe.TrustDomain != "example.org" { + t.Errorf("TrustDomain = %q, want example.org", cfg.Spiffe.TrustDomain) + } + if !strings.Contains(cfg.Spiffe.HelperConfig, "agent_address") { + t.Error("HelperConfig not loaded from YAML") + } +} + +func TestDeepCopy_PreservesHelperConfig(t *testing.T) { + cfg := CompiledDefaults() + cfg.Spiffe.HelperConfig = "custom_helper_config" + + copied := cfg.DeepCopy() + if copied.Spiffe.HelperConfig != "custom_helper_config" { + t.Errorf("DeepCopy lost HelperConfig: got %q", copied.Spiffe.HelperConfig) + } +} From 0bd664dd1162a8a93a469ce1e0d4e433a3b242aa Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 16 Jun 2026 10:54:30 +0100 Subject: [PATCH 2/8] feat: derive spiffe-helper-config CM from PlatformConfig Add GetPlatformConfig to AgentRuntimeReconciler and ensureSpiffeHelperConfigMap() which creates/overwrites the spiffe-helper-config CM per namespace from PlatformConfig. Remove spiffe-helper-config from templateConfigMapNames (no longer template-copied). Include helperConfig in config hash so changes trigger rolling updates. RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/cmd/main.go | 1 + .../controller/agentruntime_config.go | 24 ++++- .../controller/agentruntime_config_test.go | 26 +++++ .../controller/agentruntime_controller.go | 74 ++++++++++++++ .../agentruntime_controller_test.go | 98 ++++++++++++++++++- 5 files changed, 219 insertions(+), 4 deletions(-) diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 4c3e1f82..31145181 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -631,6 +631,7 @@ func main() { EnableCardDiscovery: enableCardDiscovery, SpireTrustDomain: spireTrustDomain, GetFeatureGates: featureGateLoader.Get, + GetPlatformConfig: configLoader.Get, } if enableCardDiscovery { artReconciler.AgentFetcher = agentFetcher diff --git a/kagenti-operator/internal/controller/agentruntime_config.go b/kagenti-operator/internal/controller/agentruntime_config.go index 66cd2e0c..ea38385b 100644 --- a/kagenti-operator/internal/controller/agentruntime_config.go +++ b/kagenti-operator/internal/controller/agentruntime_config.go @@ -48,6 +48,11 @@ const ( // ConfigMap are watched by AgentRuntimeReconciler so the resolved-config // hash picks them up and rolls affected workloads. AuthBridgeRuntimeConfigMapName = "authbridge-runtime-config" + + // SpiffeHelperConfigMapName is the namespace-scoped ConfigMap holding + // the spiffe-helper helper.conf. Derived from PlatformConfig by the + // controller; included in the config hash for rolling updates. + SpiffeHelperConfigMapName = "spiffe-helper-config" ) // ClusterDefaultsNamespace is the namespace where cluster-level ConfigMaps @@ -84,6 +89,10 @@ type resolvedConfig struct { // we want any byte change to roll the workload. Empty string when // the ConfigMap doesn't exist in the namespace. AuthBridgeRuntime string `json:"authBridgeRuntime,omitempty"` + + // SpiffeHelperConfig captures the spiffe-helper-config CM content so + // changes to PlatformConfig's spiffe.helperConfig trigger rolling updates. + SpiffeHelperConfig string `json:"spiffeHelperConfig,omitempty"` } // ConfigResult holds the computed hash and any warnings from the config resolution. @@ -130,10 +139,19 @@ func resolveConfig(ctx context.Context, c client.Reader, namespace string) (reso abRuntime = data["config.yaml"] } + // Layer 2c: spiffe-helper-config (helper.conf). + // Derived from PlatformConfig by the controller; included in hash + // so changes to spiffe.helperConfig trigger rolling updates. + spiffeHelper := "" + if data := readConfigMapData(ctx, c, namespace, SpiffeHelperConfigMapName); len(data) > 0 { + spiffeHelper = data["helper.conf"] + } + return resolvedConfig{ - FeatureGates: featureGates, - Defaults: merged, - AuthBridgeRuntime: abRuntime, + FeatureGates: featureGates, + Defaults: merged, + AuthBridgeRuntime: abRuntime, + SpiffeHelperConfig: spiffeHelper, }, warnings } diff --git a/kagenti-operator/internal/controller/agentruntime_config_test.go b/kagenti-operator/internal/controller/agentruntime_config_test.go index b7d77b67..76d4d225 100644 --- a/kagenti-operator/internal/controller/agentruntime_config_test.go +++ b/kagenti-operator/internal/controller/agentruntime_config_test.go @@ -220,6 +220,32 @@ var _ = Describe("AgentRuntime Config", func() { r2, _ := ComputeConfigHash(ctx, k8sClient, namespace) Expect(r1.Hash).NotTo(Equal(r2.Hash)) }) + + It("should change when spiffe-helper-config content changes", func() { + const shHashNS = "sh-hash-ns" + shNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: shHashNS}} + _ = k8sClient.Create(ctx, shNS) + + shCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spiffe-helper-config", + Namespace: shHashNS, + }, + Data: map[string]string{ + "helper.conf": "agent_address = \"/old/socket\"", + }, + } + Expect(k8sClient.Create(ctx, shCM)).To(Succeed()) + defer func() { _ = k8sClient.Delete(ctx, shCM) }() + + r1, _ := ComputeConfigHash(ctx, k8sClient, shHashNS) + + shCM.Data["helper.conf"] = "agent_address = \"/new/socket\"" + Expect(k8sClient.Update(ctx, shCM)).To(Succeed()) + + r2, _ := ComputeConfigHash(ctx, k8sClient, shHashNS) + Expect(r1.Hash).NotTo(Equal(r2.Hash)) + }) }) Context("resolveConfig two-layer merge", func() { diff --git a/kagenti-operator/internal/controller/agentruntime_controller.go b/kagenti-operator/internal/controller/agentruntime_controller.go index 7c0e3dd0..02dd967e 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller.go +++ b/kagenti-operator/internal/controller/agentruntime_controller.go @@ -113,6 +113,7 @@ type AgentRuntimeReconciler struct { EnableCardDiscovery bool SpireTrustDomain string GetFeatureGates func() *webhookconfig.FeatureGates + GetPlatformConfig func() *webhookconfig.PlatformConfig } func (r *AgentRuntimeReconciler) getFeatureGates() *webhookconfig.FeatureGates { @@ -122,6 +123,15 @@ func (r *AgentRuntimeReconciler) getFeatureGates() *webhookconfig.FeatureGates { return webhookconfig.DefaultFeatureGates() } +func (r *AgentRuntimeReconciler) getPlatformConfig() *webhookconfig.PlatformConfig { + if r.GetPlatformConfig != nil { + if cfg := r.GetPlatformConfig(); cfg != nil { + return cfg + } + } + return webhookconfig.CompiledDefaults() +} + // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentruntimes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentruntimes/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agent.kagenti.dev,resources=agentruntimes/finalizers,verbs=update @@ -194,6 +204,17 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request } } + // 4.5b. Ensure spiffe-helper-config CM is derived from PlatformConfig. + // Unlike template CMs above, this always overwrites to keep PlatformConfig + // as the single source of truth. + if err := r.ensureSpiffeHelperConfigMap(ctx, rt.Namespace); err != nil { + logger.Error(err, "Failed to ensure spiffe-helper-config") + if r.Recorder != nil { + r.Recorder.Eventf(rt, nil, corev1.EventTypeWarning, "ConfigMapEnsureError", + "EnsureSpiffeHelperConfig", err.Error()) + } + } + // 4.6. Ensure namespace has Istio ambient mesh labels for ztunnel mTLS. istioLabeled, istioErr := r.ensureIstioMeshLabels(ctx, rt.Namespace) switch { @@ -1097,6 +1118,59 @@ func (r *AgentRuntimeReconciler) ensureNamespaceConfigMaps(ctx context.Context, return nil } +// ensureSpiffeHelperConfigMap creates or updates the spiffe-helper-config ConfigMap +// in the target namespace using content from PlatformConfig. Unlike template CMs +// which are create-if-not-exists, this always overwrites because PlatformConfig is +// the single source of truth. +func (r *AgentRuntimeReconciler) ensureSpiffeHelperConfigMap(ctx context.Context, namespace string) error { + logger := log.FromContext(ctx) + cfg := r.getPlatformConfig() + + desired := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: SpiffeHelperConfigMapName, + Namespace: namespace, + Labels: map[string]string{ + LabelManagedBy: LabelManagedByValue, + }, + }, + Data: map[string]string{ + "helper.conf": cfg.Spiffe.HelperConfig, + }, + } + + existing := &corev1.ConfigMap{} + err := r.uncachedReader().Get(ctx, client.ObjectKey{Namespace: namespace, Name: SpiffeHelperConfigMapName}, existing) + if apierrors.IsNotFound(err) { + if err := r.Create(ctx, desired); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create spiffe-helper-config in %s: %w", namespace, err) + } + logger.Info("Created spiffe-helper-config from PlatformConfig", "namespace", namespace) + return nil + } + if err != nil { + return fmt.Errorf("failed to check spiffe-helper-config in %s: %w", namespace, err) + } + + if existing.Data["helper.conf"] == cfg.Spiffe.HelperConfig { + return nil + } + + existing.Data = desired.Data + if existing.Labels == nil { + existing.Labels = make(map[string]string) + } + existing.Labels[LabelManagedBy] = LabelManagedByValue + if err := r.Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update spiffe-helper-config in %s: %w", namespace, err) + } + logger.Info("Updated spiffe-helper-config from PlatformConfig", "namespace", namespace) + return nil +} + const ( sccClusterRoleName = "system:openshift:scc:kagenti-authbridge" sccRoleBindingName = "agent-authbridge-scc" diff --git a/kagenti-operator/internal/controller/agentruntime_controller_test.go b/kagenti-operator/internal/controller/agentruntime_controller_test.go index 8a9fb112..a43ac4a2 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller_test.go +++ b/kagenti-operator/internal/controller/agentruntime_controller_test.go @@ -704,7 +704,7 @@ var _ = Describe("AgentRuntime Controller", func() { }) AfterEach(func() { - for _, name := range templateConfigMapNames { + for _, name := range []string{"authbridge-config", "authbridge-runtime-config", "envoy-config", "spiffe-helper-config"} { cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ClusterDefaultsNamespace}} _ = k8sClient.Delete(ctx, cm) cm = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: cmTestNS}} @@ -819,6 +819,102 @@ var _ = Describe("AgentRuntime Controller", func() { }) }) + Context("When ensuring spiffe-helper-config from PlatformConfig", func() { + const shTestNS = "sh-test-ns" + + BeforeEach(func() { + testNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: shTestNS}} + _ = k8sClient.Create(ctx, testNS) + }) + + AfterEach(func() { + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "spiffe-helper-config", Namespace: shTestNS}} + _ = k8sClient.Delete(ctx, cm) + }) + + It("should create spiffe-helper-config when missing", func() { + r := newReconciler() + r.GetPlatformConfig = func() *webhookconfig.PlatformConfig { + cfg := webhookconfig.CompiledDefaults() + cfg.Spiffe.HelperConfig = "test_agent_address = \"/test/socket\"" + return cfg + } + + Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) + + created := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "spiffe-helper-config", Namespace: shTestNS}, created)).To(Succeed()) + Expect(created.Data["helper.conf"]).To(Equal("test_agent_address = \"/test/socket\"")) + Expect(created.Labels[LabelManagedBy]).To(Equal(LabelManagedByValue)) + }) + + It("should overwrite spiffe-helper-config when content differs", func() { + existing := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spiffe-helper-config", + Namespace: shTestNS, + }, + Data: map[string]string{"helper.conf": "old_content"}, + } + Expect(k8sClient.Create(ctx, existing)).To(Succeed()) + + r := newReconciler() + r.GetPlatformConfig = func() *webhookconfig.PlatformConfig { + cfg := webhookconfig.CompiledDefaults() + cfg.Spiffe.HelperConfig = "new_content" + return cfg + } + + Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) + + updated := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "spiffe-helper-config", Namespace: shTestNS}, updated)).To(Succeed()) + Expect(updated.Data["helper.conf"]).To(Equal("new_content")) + }) + + It("should be idempotent when content matches", func() { + existing := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spiffe-helper-config", + Namespace: shTestNS, + Labels: map[string]string{LabelManagedBy: LabelManagedByValue}, + }, + Data: map[string]string{"helper.conf": "same_content"}, + } + Expect(k8sClient.Create(ctx, existing)).To(Succeed()) + + r := newReconciler() + r.GetPlatformConfig = func() *webhookconfig.PlatformConfig { + cfg := webhookconfig.CompiledDefaults() + cfg.Spiffe.HelperConfig = "same_content" + return cfg + } + + Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) + + result := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "spiffe-helper-config", Namespace: shTestNS}, result)).To(Succeed()) + Expect(result.Data["helper.conf"]).To(Equal("same_content")) + }) + + It("should use CompiledDefaults when GetPlatformConfig is nil", func() { + r := newReconciler() + Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) + + created := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "spiffe-helper-config", Namespace: shTestNS}, created)).To(Succeed()) + Expect(created.Data["helper.conf"]).To(Equal(webhookconfig.DefaultSpiffeHelperConfig)) + }) + }) + + Context("When templateConfigMapNames excludes spiffe-helper-config", func() { + It("should not contain spiffe-helper-config", func() { + for _, name := range templateConfigMapNames { + Expect(name).NotTo(Equal("spiffe-helper-config")) + } + }) + }) + Context("Service resolution for card discovery", func() { It("should resolve service by name match", func() { dep := newDeployment("svc-name-deploy", namespace) From 6826e954114e391109d20e565ff816c4f969adc3 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 16 Jun 2026 10:54:36 +0100 Subject: [PATCH 3/8] refactor: remove SpiffeHelperConf from webhook namespace/resolved config Webhook no longer reads spiffe-helper-config CM at admission time. Remove SpiffeHelperConf field from NamespaceConfig and ResolvedConfig structs. The controller now derives the CM from PlatformConfig; the volume mount still references the CM by name (unchanged). RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../internal/webhook/injector/namespace_config.go | 12 +++--------- .../webhook/injector/namespace_config_test.go | 9 +-------- .../internal/webhook/injector/resolved_config.go | 2 -- .../webhook/injector/resolved_config_test.go | 4 ---- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/kagenti-operator/internal/webhook/injector/namespace_config.go b/kagenti-operator/internal/webhook/injector/namespace_config.go index d893a174..3d263044 100644 --- a/kagenti-operator/internal/webhook/injector/namespace_config.go +++ b/kagenti-operator/internal/webhook/injector/namespace_config.go @@ -53,9 +53,6 @@ type NamespaceConfig struct { SpiffeIdpAlias string // Keycloak SPIFFE Identity Provider alias (e.g., "spire-spiffe") JWTAudience string // Audience for SPIFFE JWT-SVIDs (token-exchange identity.jwt_audience) - // From "spiffe-helper-config" ConfigMap - SpiffeHelperConf string // raw helper.conf content - // From "authproxy-routes" ConfigMap AuthproxyRoutesYAML string // raw routes.yaml content @@ -91,12 +88,9 @@ func ReadNamespaceConfig(ctx context.Context, c client.Reader, namespace string) // uses SecretKeyRef to reference the secret by name, keeping credentials out of // the NamespaceConfig struct and the webhook's memory. - // Read "spiffe-helper-config" ConfigMap - if cm, err := getConfigMap(ctx, c, namespace, SpiffeHelperConfigMapName); err != nil { - nsConfigLog.V(1).Info("ConfigMap not found", "name", SpiffeHelperConfigMapName, "namespace", namespace, "error", err) - } else { - cfg.SpiffeHelperConf = cm.Data["helper.conf"] - } + // Note: spiffe-helper-config is NOT read here. The controller derives it + // from PlatformConfig via ensureSpiffeHelperConfigMap(). The volume mount + // still references SpiffeHelperConfigMapName directly. // Read "authproxy-routes" ConfigMap if cm, err := getConfigMap(ctx, c, namespace, AuthproxyRoutesConfigMapName); err != nil { diff --git a/kagenti-operator/internal/webhook/injector/namespace_config_test.go b/kagenti-operator/internal/webhook/injector/namespace_config_test.go index b8884421..c0f8126e 100644 --- a/kagenti-operator/internal/webhook/injector/namespace_config_test.go +++ b/kagenti-operator/internal/webhook/injector/namespace_config_test.go @@ -48,16 +48,12 @@ func TestReadNamespaceConfig_AllPresent(t *testing.T) { "DEFAULT_OUTBOUND_POLICY": "passthrough", }, } - spiffeCM := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{Name: SpiffeHelperConfigMapName, Namespace: "ns1"}, - Data: map[string]string{"helper.conf": "agent_address = \"/spiffe-workload-api/spire-agent.sock\""}, - } routesCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{Name: AuthproxyRoutesConfigMapName, Namespace: "ns1"}, Data: map[string]string{"routes.yaml": "routes: []"}, } - reader := newFakeReader(abCM, spiffeCM, routesCM) + reader := newFakeReader(abCM, routesCM) cfg, err := ReadNamespaceConfig(context.Background(), reader, "ns1") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -75,9 +71,6 @@ func TestReadNamespaceConfig_AllPresent(t *testing.T) { if cfg.Issuer != "http://keycloak:8080/realms/demo" { t.Errorf("Issuer = %q", cfg.Issuer) } - if cfg.SpiffeHelperConf == "" { - t.Error("SpiffeHelperConf is empty") - } if cfg.TargetAudience != "auth-target" { t.Errorf("TargetAudience = %q", cfg.TargetAudience) } diff --git a/kagenti-operator/internal/webhook/injector/resolved_config.go b/kagenti-operator/internal/webhook/injector/resolved_config.go index eb856570..053d9016 100644 --- a/kagenti-operator/internal/webhook/injector/resolved_config.go +++ b/kagenti-operator/internal/webhook/injector/resolved_config.go @@ -45,7 +45,6 @@ type ResolvedConfig struct { SpiffeIdpAlias string // Keycloak SPIFFE Identity Provider alias // Sidecar configs — from namespace CMs - SpiffeHelperConf string AuthproxyRoutesYAML string // AuthBridge runtime config — from namespace "authbridge-runtime-config" ConfigMap @@ -86,7 +85,6 @@ func ResolveConfig(platform *config.PlatformConfig, ns *NamespaceConfig) *Resolv DefaultOutboundPolicy: ns.DefaultOutboundPolicy, ClientAuthType: ns.ClientAuthType, SpiffeIdpAlias: ns.SpiffeIdpAlias, - SpiffeHelperConf: ns.SpiffeHelperConf, AuthproxyRoutesYAML: ns.AuthproxyRoutesYAML, AuthBridgeRuntimeYAML: ns.AuthBridgeRuntimeYAML, } diff --git a/kagenti-operator/internal/webhook/injector/resolved_config_test.go b/kagenti-operator/internal/webhook/injector/resolved_config_test.go index 4eddb2f3..e6be76fa 100644 --- a/kagenti-operator/internal/webhook/injector/resolved_config_test.go +++ b/kagenti-operator/internal/webhook/injector/resolved_config_test.go @@ -65,14 +65,10 @@ func TestResolveConfig_SpiffeTrustDomain_FromPlatform(t *testing.T) { func TestResolveConfig_SidecarConfigs_FromNamespace(t *testing.T) { ns := &NamespaceConfig{ - SpiffeHelperConf: "helper.conf content", AuthproxyRoutesYAML: "routes.yaml content", } resolved := ResolveConfig(config.CompiledDefaults(), ns) - if resolved.SpiffeHelperConf != "helper.conf content" { - t.Errorf("SpiffeHelperConf should come from namespace") - } if resolved.AuthproxyRoutesYAML != "routes.yaml content" { t.Errorf("AuthproxyRoutesYAML should come from namespace") } From 5dd5f31086635c26befe7e0b21b602761f4dd43a Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 16 Jun 2026 10:54:43 +0100 Subject: [PATCH 4/8] chore: add spiffe defaults to Helm values, remove static templates Add spiffe section with helperConfig to Helm values.yaml under defaults. Delete static spiffe-helper-config.yaml template and OpenShift spiffe-helper-keycloak.yaml kustomize patch. Update kustomization references and e2e fixture comments. RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- charts/kagenti-operator/values.yaml | 18 ++++++++++++++ .../authbridge/spiffe-helper-config.yaml | 24 ------------------- .../config/openshift/kustomization.yaml | 1 - .../patches/spiffe-helper-keycloak.yaml | 20 ---------------- 4 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 kagenti-operator/config/authbridge/spiffe-helper-config.yaml delete mode 100644 kagenti-operator/config/openshift/patches/spiffe-helper-keycloak.yaml diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index dc961436..8ff77cb9 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -275,3 +275,21 @@ defaults: limits: cpu: 300m memory: 384Mi + + # SPIFFE / spiffe-helper settings. + # Keep helperConfig in sync with DefaultSpiffeHelperConfig in internal/webhook/config/defaults.go. + spiffe: + trustDomain: cluster.local + socketPath: "unix:///spiffe-workload-api/spire-agent.sock" + helperConfig: | + agent_address = "/spiffe-workload-api/spire-agent.sock" + cmd = "" + cmd_args = "" + svid_file_name = "/opt/svid.pem" + svid_key_file_name = "/opt/svid_key.pem" + svid_bundle_file_name = "/opt/svid_bundle.pem" + cert_file_mode = 0644 + key_file_mode = 0640 + jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] + jwt_svid_file_mode = 0644 + include_federated_domains = true diff --git a/kagenti-operator/config/authbridge/spiffe-helper-config.yaml b/kagenti-operator/config/authbridge/spiffe-helper-config.yaml deleted file mode 100644 index ddde6583..00000000 --- a/kagenti-operator/config/authbridge/spiffe-helper-config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# SPIFFE helper configuration template. -# The operator copies this to agent namespaces via ensureNamespaceConfigMaps. -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: kagenti-operator-system - labels: - app.kubernetes.io/name: kagenti-operator - app.kubernetes.io/component: authbridge - app.kubernetes.io/managed-by: kustomize -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - svid_file_name = "/opt/svid.pem" - svid_key_file_name = "/opt/svid_key.pem" - svid_bundle_file_name = "/opt/svid_bundle.pem" - cert_file_mode = 0644 - key_file_mode = 0640 - jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] - jwt_svid_file_mode = 0644 - include_federated_domains = true diff --git a/kagenti-operator/config/openshift/kustomization.yaml b/kagenti-operator/config/openshift/kustomization.yaml index 386fa6f7..81e67fe7 100644 --- a/kagenti-operator/config/openshift/kustomization.yaml +++ b/kagenti-operator/config/openshift/kustomization.yaml @@ -12,4 +12,3 @@ resources: patches: - path: patches/authbridge-keycloak.yaml -- path: patches/spiffe-helper-keycloak.yaml diff --git a/kagenti-operator/config/openshift/patches/spiffe-helper-keycloak.yaml b/kagenti-operator/config/openshift/patches/spiffe-helper-keycloak.yaml deleted file mode 100644 index ca816bb2..00000000 --- a/kagenti-operator/config/openshift/patches/spiffe-helper-keycloak.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Strategic merge patch: override spiffe-helper-config with -# in-cluster Keycloak JWT audience for OpenShift deployments. -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: kagenti-operator-system -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - svid_file_name = "/opt/svid.pem" - svid_key_file_name = "/opt/svid_key.pem" - svid_bundle_file_name = "/opt/svid_bundle.pem" - cert_file_mode = 0644 - key_file_mode = 0640 - jwt_svids = [{jwt_audience="http://keycloak-service.keycloak.svc:8080/realms/kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] - jwt_svid_file_mode = 0644 - include_federated_domains = true From a90fd5806662eb8176a76f4890919c2ec96c46a8 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Wed, 17 Jun 2026 10:48:56 +0100 Subject: [PATCH 5/8] fix: reconcile ownership label before content-equality check Address PR review feedback: - Label reconciliation now runs before content comparison so leftover CMs from the old static-template path get their managed-by label adopted even when content already matches PlatformConfig. - Add prod/OpenShift override note to DefaultSpiffeHelperConfig. - Add test for label adoption on leftover CMs. RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../controller/agentruntime_controller.go | 16 ++++++++---- .../agentruntime_controller_test.go | 26 +++++++++++++++++++ .../internal/webhook/config/defaults.go | 3 +++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/kagenti-operator/internal/controller/agentruntime_controller.go b/kagenti-operator/internal/controller/agentruntime_controller.go index 02dd967e..0b2ac7cc 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller.go +++ b/kagenti-operator/internal/controller/agentruntime_controller.go @@ -1155,15 +1155,21 @@ func (r *AgentRuntimeReconciler) ensureSpiffeHelperConfigMap(ctx context.Context return fmt.Errorf("failed to check spiffe-helper-config in %s: %w", namespace, err) } - if existing.Data["helper.conf"] == cfg.Spiffe.HelperConfig { - return nil - } + needsUpdate := existing.Data["helper.conf"] != cfg.Spiffe.HelperConfig - existing.Data = desired.Data if existing.Labels == nil { existing.Labels = make(map[string]string) } - existing.Labels[LabelManagedBy] = LabelManagedByValue + if existing.Labels[LabelManagedBy] != LabelManagedByValue { + existing.Labels[LabelManagedBy] = LabelManagedByValue + needsUpdate = true + } + + if !needsUpdate { + return nil + } + + existing.Data = desired.Data if err := r.Update(ctx, existing); err != nil { return fmt.Errorf("failed to update spiffe-helper-config in %s: %w", namespace, err) } diff --git a/kagenti-operator/internal/controller/agentruntime_controller_test.go b/kagenti-operator/internal/controller/agentruntime_controller_test.go index a43ac4a2..39460a40 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller_test.go +++ b/kagenti-operator/internal/controller/agentruntime_controller_test.go @@ -897,6 +897,32 @@ var _ = Describe("AgentRuntime Controller", func() { Expect(result.Data["helper.conf"]).To(Equal("same_content")) }) + It("should adopt label on leftover CM with matching content", func() { + existing := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spiffe-helper-config", + Namespace: shTestNS, + Labels: map[string]string{"app.kubernetes.io/managed-by": "kustomize"}, + }, + Data: map[string]string{"helper.conf": "same_content"}, + } + Expect(k8sClient.Create(ctx, existing)).To(Succeed()) + + r := newReconciler() + r.GetPlatformConfig = func() *webhookconfig.PlatformConfig { + cfg := webhookconfig.CompiledDefaults() + cfg.Spiffe.HelperConfig = "same_content" + return cfg + } + + Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) + + result := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "spiffe-helper-config", Namespace: shTestNS}, result)).To(Succeed()) + Expect(result.Data["helper.conf"]).To(Equal("same_content")) + Expect(result.Labels[LabelManagedBy]).To(Equal(LabelManagedByValue)) + }) + It("should use CompiledDefaults when GetPlatformConfig is nil", func() { r := newReconciler() Expect(r.ensureSpiffeHelperConfigMap(ctx, shTestNS)).To(Succeed()) diff --git a/kagenti-operator/internal/webhook/config/defaults.go b/kagenti-operator/internal/webhook/config/defaults.go index ac60d1be..8a9c36d8 100644 --- a/kagenti-operator/internal/webhook/config/defaults.go +++ b/kagenti-operator/internal/webhook/config/defaults.go @@ -7,6 +7,9 @@ import ( // DefaultSpiffeHelperConfig is the default helper.conf content for spiffe-helper. // Keep in sync with charts/kagenti-operator/values.yaml defaults.spiffe.helperConfig. +// The jwt_audience below targets a local dev Keycloak. Production and OpenShift +// deployments MUST override this via Helm values (defaults.spiffe.helperConfig) +// to set the in-cluster Keycloak audience. const DefaultSpiffeHelperConfig = `agent_address = "/spiffe-workload-api/spire-agent.sock" cmd = "" cmd_args = "" From bb1c4aa56985f3b1e79dc348748260119fd75870 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Wed, 17 Jun 2026 16:55:42 +0100 Subject: [PATCH 6/8] fix: retry AgentRuntime CR creation for webhook readiness in e2e Wrap AgentRuntime CR apply in Eventually with 1m timeout and 5s poll, matching the retry pattern already used by authbridge and combined e2e tests. Fixes flaky webhook connection-refused race when the validation webhook TCP listener isn't accepting connections yet despite endpoint being registered. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/test/e2e/e2e_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/kagenti-operator/test/e2e/e2e_test.go b/kagenti-operator/test/e2e/e2e_test.go index 86beae1f..f9b9c6e3 100644 --- a/kagenti-operator/test/e2e/e2e_test.go +++ b/kagenti-operator/test/e2e/e2e_test.go @@ -1182,9 +1182,11 @@ rules: Expect(err).NotTo(HaveOccurred()) Expect(utils.WaitForDeploymentReady("runtime-agent-target", agentRuntimeTestNamespace, 2*time.Minute)).To(Succeed()) - By("creating AgentRuntime CR") - _, err = utils.KubectlApplyStdin(runtimeAgentCRFixture(), agentRuntimeTestNamespace) - Expect(err).NotTo(HaveOccurred()) + By("creating AgentRuntime CR (with retry for webhook readiness)") + Eventually(func() error { + _, err := utils.KubectlApplyStdin(runtimeAgentCRFixture(), agentRuntimeTestNamespace) + return err + }, 1*time.Minute, 5*time.Second).Should(Succeed()) By("verifying kagenti.io/type=agent on workload metadata") Eventually(func(g Gomega) { From 661dd7f3ecc79c192a710c7215536097e8081dde Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Mon, 22 Jun 2026 10:36:48 +0100 Subject: [PATCH 7/8] fix: requeue on spiffe-helper-config failure, remove from template list Remove spiffe-helper-config from templateConfigMapNames since it is now derived from PlatformConfig via ensureSpiffeHelperConfigMap. Make failure in ensureSpiffeHelperConfigMap set error status and requeue (30s), matching the SCC binding pattern, to prevent stamping workloads with a stale or missing spiffe-helper-config. Addresses review feedback from kevincogan on PR #437. Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../internal/controller/agentruntime_controller.go | 3 ++- .../internal/controller/agentruntime_controller_test.go | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/kagenti-operator/internal/controller/agentruntime_controller.go b/kagenti-operator/internal/controller/agentruntime_controller.go index 0b2ac7cc..e49cc439 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller.go +++ b/kagenti-operator/internal/controller/agentruntime_controller.go @@ -213,6 +213,8 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request r.Recorder.Eventf(rt, nil, corev1.EventTypeWarning, "ConfigMapEnsureError", "EnsureSpiffeHelperConfig", err.Error()) } + r.updateErrorStatus(ctx, req.NamespacedName, ConditionTypeReady, "SpiffeHelperConfigError", err.Error()) + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } // 4.6. Ensure namespace has Istio ambient mesh labels for ztunnel mTLS. @@ -1066,7 +1068,6 @@ func computeCardContentHash(cardData *agentv1alpha1.AgentCardData) string { var templateConfigMapNames = []string{ "authbridge-config", "authbridge-runtime-config", - "spiffe-helper-config", } // ensureNamespaceConfigMaps copies template ConfigMaps from kagenti-system to the diff --git a/kagenti-operator/internal/controller/agentruntime_controller_test.go b/kagenti-operator/internal/controller/agentruntime_controller_test.go index 39460a40..17c74494 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller_test.go +++ b/kagenti-operator/internal/controller/agentruntime_controller_test.go @@ -704,7 +704,7 @@ var _ = Describe("AgentRuntime Controller", func() { }) AfterEach(func() { - for _, name := range []string{"authbridge-config", "authbridge-runtime-config", "envoy-config", "spiffe-helper-config"} { + for _, name := range []string{"authbridge-config", "authbridge-runtime-config", "envoy-config"} { cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ClusterDefaultsNamespace}} _ = k8sClient.Delete(ctx, cm) cm = &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: cmTestNS}} @@ -809,8 +809,8 @@ var _ = Describe("AgentRuntime Controller", func() { Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "authbridge-config", Namespace: cmTestNS}, abCfg)).To(Succeed()) Expect(abCfg.Data["KEYCLOAK_URL"]).To(Equal("http://existing")) - // The other 3 should be created from templates - for _, name := range []string{"authbridge-runtime-config", "spiffe-helper-config"} { + // authbridge-runtime-config should be created from template + for _, name := range []string{"authbridge-runtime-config"} { cm := &corev1.ConfigMap{} Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: cmTestNS}, cm)).To(Succeed()) Expect(cm.Data["config.yaml"]).To(Equal("template-" + name)) From 67b8a5ebaa867f30d967135c329703a911773105 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Wed, 24 Jun 2026 11:21:15 +0100 Subject: [PATCH 8/8] fix: use uncached reader for ComputeConfigHash after spiffe-helper-config write ensureSpiffeHelperConfigMap() creates/updates the CM via the API server, but ComputeConfigHash() was reading it back through the controller-runtime cache, which may not yet reflect the write. Switch to uncachedReader() so the hash is always computed from the authoritative state. Addresses review feedback from kevincogan on PR #437. RHAIENG-4939 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/internal/controller/agentruntime_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kagenti-operator/internal/controller/agentruntime_controller.go b/kagenti-operator/internal/controller/agentruntime_controller.go index e49cc439..b21b3f62 100644 --- a/kagenti-operator/internal/controller/agentruntime_controller.go +++ b/kagenti-operator/internal/controller/agentruntime_controller.go @@ -258,7 +258,7 @@ func (r *AgentRuntimeReconciler) Reconcile(ctx context.Context, req ctrl.Request } // 5. Compute config hash from merged configuration (cluster → namespace) - configResult, err := ComputeConfigHash(ctx, r.Client, rt.Namespace) + configResult, err := ComputeConfigHash(ctx, r.uncachedReader(), rt.Namespace) if err != nil { logger.Error(err, "Failed to compute config hash") r.updateErrorStatus(ctx, req.NamespacedName, ConditionTypeReady, "ConfigHashError", err.Error())