From 5d58fcaf9e3477b2fd44200839eb217e5d6dc709 Mon Sep 17 00:00:00 2001 From: zanarelli Date: Fri, 31 Jul 2026 23:47:16 -0300 Subject: [PATCH 1/3] fix: reconcile ListenerSet when referenced TLS Secret changes Signed-off-by: zanarelli --- .../provider/kubernetes/controller_offline.go | 1 + .../kubernetes/controller_offline_test.go | 2 + internal/provider/kubernetes/indexers.go | 27 +++++++ internal/provider/kubernetes/predicates.go | 18 +++++ .../provider/kubernetes/predicates_test.go | 70 +++++++++++++++++-- .../9614-listenerset-secret-reconcile.md | 4 ++ 6 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 release-notes/current/bug_fixes/9614-listenerset-secret-reconcile.md diff --git a/internal/provider/kubernetes/controller_offline.go b/internal/provider/kubernetes/controller_offline.go index 4868fa726d..3b1c4249d6 100644 --- a/internal/provider/kubernetes/controller_offline.go +++ b/internal/provider/kubernetes/controller_offline.go @@ -163,6 +163,7 @@ func newOfflineGatewayAPIClient(extensionPolicies []schema.GroupVersionKind, ena WithIndex(&gwapiv1.Gateway{}, classGatewayIndex, gatewayIndexFunc). WithIndex(&gwapiv1.Gateway{}, secretGatewayIndex, secretGatewayIndexFunc). WithIndex(&gwapiv1.ListenerSet{}, gatewayListenerSetIndex, gatewayListenerSetIndexFunc). + WithIndex(&gwapiv1.ListenerSet{}, secretListenerSetIndex, secretListenerSetIndexFunc). WithIndex(&gwapiv1.HTTPRoute{}, gatewayHTTPRouteIndex, gatewayHTTPRouteIndexFunc). WithIndex(&gwapiv1.HTTPRoute{}, backendHTTPRouteIndex, backendHTTPRouteIndexFunc). WithIndex(&gwapiv1.HTTPRoute{}, listenerSetHTTPRouteIndex, listenerSetHTTPRouteIndexFunc). diff --git a/internal/provider/kubernetes/controller_offline_test.go b/internal/provider/kubernetes/controller_offline_test.go index f0bd82c1ba..1b2c25e761 100644 --- a/internal/provider/kubernetes/controller_offline_test.go +++ b/internal/provider/kubernetes/controller_offline_test.go @@ -144,6 +144,8 @@ func TestNewOfflineGatewayAPIControllerIndexRegistration(t *testing.T) { t.Run("ListenerSet index", func(t *testing.T) { err := cli.List(context.Background(), &gwapiv1.ListenerSetList{}, client.MatchingFields{gatewayListenerSetIndex: "any"}) require.NoError(t, err) + err = cli.List(context.Background(), &gwapiv1.ListenerSetList{}, client.MatchingFields{secretListenerSetIndex: "any"}) + require.NoError(t, err) }) t.Run("HTTPRoute indices", func(t *testing.T) { diff --git a/internal/provider/kubernetes/indexers.go b/internal/provider/kubernetes/indexers.go index 652b898c94..ca4341267c 100644 --- a/internal/provider/kubernetes/indexers.go +++ b/internal/provider/kubernetes/indexers.go @@ -36,6 +36,7 @@ const ( gatewayTCPRouteIndex = "gatewayTCPRouteIndex" gatewayUDPRouteIndex = "gatewayUDPRouteIndex" secretGatewayIndex = "secretGatewayIndex" + secretListenerSetIndex = "secretListenerSetIndex" targetRefGrantRouteIndex = "targetRefGrantRouteIndex" backendHTTPRouteIndex = "backendHTTPRouteIndex" backendGRPCRouteIndex = "backendGRPCRouteIndex" @@ -139,6 +140,9 @@ func addListenerSetIndexers(ctx context.Context, mgr manager.Manager) error { if err := mgr.GetFieldIndexer().IndexField(ctx, &gwapiv1.ListenerSet{}, gatewayListenerSetIndex, gatewayListenerSetIndexFunc); err != nil { return err } + if err := mgr.GetFieldIndexer().IndexField(ctx, &gwapiv1.ListenerSet{}, secretListenerSetIndex, secretListenerSetIndexFunc); err != nil { + return err + } return nil } @@ -746,6 +750,29 @@ func secretGatewayIndexFunc(rawObj client.Object) []string { return secretReferences } +// secretListenerSetIndexFunc indexes ListenerSet objects by the Secrets they +// reference in listeners[].tls.certificateRefs, mirroring secretGatewayIndexFunc. +func secretListenerSetIndexFunc(rawObj client.Object) []string { + listenerSet := rawObj.(*gwapiv1.ListenerSet) + var secretReferences []string + for _, listener := range listenerSet.Spec.Listeners { + if listener.TLS == nil || *listener.TLS.Mode != gwapiv1.TLSModeTerminate { + continue + } + for _, cert := range listener.TLS.CertificateRefs { + if *cert.Kind == resource.KindSecret { + secretReferences = append(secretReferences, + types.NamespacedName{ + Namespace: gatewayapi.NamespaceDerefOr(cert.Namespace, listenerSet.Namespace), + Name: string(cert.Name), + }.String(), + ) + } + } + } + return secretReferences +} + func gatewayIndexFunc(rawObj client.Object) []string { gateway := rawObj.(*gwapiv1.Gateway) return []string{string(gateway.Spec.GatewayClassName)} diff --git a/internal/provider/kubernetes/predicates.go b/internal/provider/kubernetes/predicates.go index d05fe9d7be..f69ab45a5d 100644 --- a/internal/provider/kubernetes/predicates.go +++ b/internal/provider/kubernetes/predicates.go @@ -161,6 +161,12 @@ func (r *gatewayAPIReconciler) validateSecretForReconcile(secret *corev1.Secret) return true } + if r.listenerSetCRDExists { + if r.isListenerSetReferencingSecret(&nsName) { + return true + } + } + if r.spCRDExists { if r.isSecurityPolicyReferencingSecret(&nsName) { return true @@ -390,6 +396,18 @@ func (r *gatewayAPIReconciler) isGatewayReferencingSecret(nsName *types.Namespac return true } +func (r *gatewayAPIReconciler) isListenerSetReferencingSecret(nsName *types.NamespacedName) bool { + lsList := &gwapiv1.ListenerSetList{} + if err := r.client.List(context.Background(), lsList, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(secretListenerSetIndex, nsName.String()), + }); err != nil { + r.log.Error(err, "unable to find associated ListenerSets") + return false + } + + return len(lsList.Items) > 0 +} + func (r *gatewayAPIReconciler) isSecurityPolicyReferencingSecret(nsName *types.NamespacedName) bool { spList := &egv1a1.SecurityPolicyList{} if err := r.client.List(context.Background(), spList, &client.ListOptions{ diff --git a/internal/provider/kubernetes/predicates_test.go b/internal/provider/kubernetes/predicates_test.go index 73e441fd59..8e4aeaa679 100644 --- a/internal/provider/kubernetes/predicates_test.go +++ b/internal/provider/kubernetes/predicates_test.go @@ -910,19 +910,74 @@ func TestValidateSecretForReconcile(t *testing.T) { secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "unrelated-secret"}), expect: false, }, + { + name: "references ListenerSet TLS certificate", + configs: []client.Object{ + test.GetGatewayClass("test-gc", egv1a1.GatewayControllerName, nil), + test.GetGateway(types.NamespacedName{Namespace: "default", Name: "parent-gw"}, "test-gc", 8080), + func() *gwapiv1.ListenerSet { + ls := test.GetListenerSet( + types.NamespacedName{Namespace: "default", Name: "tls-ls"}, + types.NamespacedName{Namespace: "default", Name: "parent-gw"}, + 443, + ) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + ls.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + ls.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "ls-tls-secret", + }}, + } + return ls + }(), + }, + secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "ls-tls-secret"}), + expect: true, + }, + { + name: "ListenerSet exists but secret is unrelated", + configs: []client.Object{ + test.GetGatewayClass("test-gc", egv1a1.GatewayControllerName, nil), + test.GetGateway(types.NamespacedName{Namespace: "default", Name: "parent-gw"}, "test-gc", 8080), + func() *gwapiv1.ListenerSet { + ls := test.GetListenerSet( + types.NamespacedName{Namespace: "default", Name: "tls-ls"}, + types.NamespacedName{Namespace: "default", Name: "parent-gw"}, + 443, + ) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + ls.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + ls.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "ls-tls-secret", + }}, + } + return ls + }(), + }, + secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "unrelated-secret"}), + expect: false, + }, } // Create the reconciler. logger := logging.DefaultLogger(os.Stdout, egv1a1.LogLevelInfo) r := gatewayAPIReconciler{ - classController: egv1a1.GatewayControllerName, - log: logger, - backendCRDExists: true, - spCRDExists: true, - epCRDExists: true, - eepCRDExists: true, - hrfCRDExists: true, + classController: egv1a1.GatewayControllerName, + log: logger, + backendCRDExists: true, + spCRDExists: true, + epCRDExists: true, + eepCRDExists: true, + hrfCRDExists: true, + listenerSetCRDExists: true, envoyGateway: &egv1a1.EnvoyGateway{ EnvoyGatewaySpec: egv1a1.EnvoyGatewaySpec{ ExtensionAPIs: &egv1a1.ExtensionAPISettings{ @@ -937,6 +992,7 @@ func TestValidateSecretForReconcile(t *testing.T) { WithScheme(envoygateway.GetScheme()). WithObjects(tc.configs...). WithIndex(&gwapiv1.Gateway{}, secretGatewayIndex, secretGatewayIndexFunc). + WithIndex(&gwapiv1.ListenerSet{}, secretListenerSetIndex, secretListenerSetIndexFunc). WithIndex(&egv1a1.SecurityPolicy{}, secretSecurityPolicyIndex, secretSecurityPolicyIndexFunc). WithIndex(&egv1a1.EnvoyProxy{}, secretEnvoyProxyIndex, secretEnvoyProxyIndexFunc). WithIndex(&egv1a1.EnvoyExtensionPolicy{}, secretEnvoyExtensionPolicyIndex, secretEnvoyExtensionPolicyIndexFunc). diff --git a/release-notes/current/bug_fixes/9614-listenerset-secret-reconcile.md b/release-notes/current/bug_fixes/9614-listenerset-secret-reconcile.md new file mode 100644 index 0000000000..79ddca9a09 --- /dev/null +++ b/release-notes/current/bug_fixes/9614-listenerset-secret-reconcile.md @@ -0,0 +1,4 @@ +Fixed ListenerSet not being reconciled when a referenced TLS Secret is +created or updated after the ListenerSet. Secret watches previously only +indexed Gateway certificateRefs, so cert-manager style late Secret creation +left the ListenerSet stuck with Programmed=False until an unrelated reconcile. From 5d9382364a4da905680e88bc3f6fac5e362e6b9a Mon Sep 17 00:00:00 2001 From: zanarelli Date: Sat, 1 Aug 2026 20:15:52 -0300 Subject: [PATCH 2/3] fix: validate parent Gateway for ListenerSet secret watches Mirror isGatewayReferencingSecret by checking that every ListenerSet indexed by a TLS Secret belongs to a Gateway managed by this controller. Signed-off-by: zanarelli --- internal/provider/kubernetes/predicates.go | 23 +++++++++++++++- .../provider/kubernetes/predicates_test.go | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/provider/kubernetes/predicates.go b/internal/provider/kubernetes/predicates.go index f69ab45a5d..2efeeb3880 100644 --- a/internal/provider/kubernetes/predicates.go +++ b/internal/provider/kubernetes/predicates.go @@ -405,7 +405,28 @@ func (r *gatewayAPIReconciler) isListenerSetReferencingSecret(nsName *types.Name return false } - return len(lsList.Items) > 0 + if len(lsList.Items) == 0 { + return false + } + + for i := range lsList.Items { + ls := &lsList.Items[i] + parent := ls.Spec.ParentRef + gw := &gwapiv1.Gateway{} + key := types.NamespacedName{ + Namespace: gatewayapi.NamespaceDerefOr(parent.Namespace, ls.Namespace), + Name: string(parent.Name), + } + if err := r.client.Get(context.Background(), key, gw); err != nil { + r.log.Error(err, "failed to get parent Gateway for ListenerSet", + "namespace", ls.Namespace, "name", ls.Name) + return false + } + if !r.validateGatewayForReconcile(gw) { + return false + } + } + return true } func (r *gatewayAPIReconciler) isSecurityPolicyReferencingSecret(nsName *types.NamespacedName) bool { diff --git a/internal/provider/kubernetes/predicates_test.go b/internal/provider/kubernetes/predicates_test.go index 8e4aeaa679..a13d8fcb98 100644 --- a/internal/provider/kubernetes/predicates_test.go +++ b/internal/provider/kubernetes/predicates_test.go @@ -964,6 +964,33 @@ func TestValidateSecretForReconcile(t *testing.T) { secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "unrelated-secret"}), expect: false, }, + { + name: "ListenerSet references secret but parent gateway has invalid controller", + configs: []client.Object{ + test.GetGatewayClass("test-gc", "not.configured/controller", nil), + test.GetGateway(types.NamespacedName{Namespace: "default", Name: "parent-gw"}, "test-gc", 8080), + func() *gwapiv1.ListenerSet { + ls := test.GetListenerSet( + types.NamespacedName{Namespace: "default", Name: "tls-ls"}, + types.NamespacedName{Namespace: "default", Name: "parent-gw"}, + 443, + ) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + ls.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + ls.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "ls-tls-secret", + }}, + } + return ls + }(), + }, + secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "ls-tls-secret"}), + expect: false, + }, } // Create the reconciler. From 6569e5ac6e6a177fba2e2837f140622694e843e3 Mon Sep 17 00:00:00 2001 From: zanarelli Date: Mon, 3 Aug 2026 00:20:05 -0300 Subject: [PATCH 3/3] fix: do not abort secret reconcile on one bad parentRef isGatewayReferencingSecret and isListenerSetReferencingSecret returned false as soon as any unmanaged or missing parent appeared, so a valid Gateway/ListenerSet sharing the same TLS Secret never reconciled. Return true on the first managed parent; continue past Get errors and unmanaged gateways. Cover mixed valid/invalid parents in unit tests. Signed-off-by: zanarelli --- internal/provider/kubernetes/predicates.go | 14 +-- .../provider/kubernetes/predicates_test.go | 85 +++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/internal/provider/kubernetes/predicates.go b/internal/provider/kubernetes/predicates.go index 2efeeb3880..f3895c9454 100644 --- a/internal/provider/kubernetes/predicates.go +++ b/internal/provider/kubernetes/predicates.go @@ -389,11 +389,11 @@ func (r *gatewayAPIReconciler) isGatewayReferencingSecret(nsName *types.Namespac for i := range gwList.Items { gw := &gwList.Items[i] - if !r.validateGatewayForReconcile(gw) { - return false + if r.validateGatewayForReconcile(gw) { + return true } } - return true + return false } func (r *gatewayAPIReconciler) isListenerSetReferencingSecret(nsName *types.NamespacedName) bool { @@ -420,13 +420,13 @@ func (r *gatewayAPIReconciler) isListenerSetReferencingSecret(nsName *types.Name if err := r.client.Get(context.Background(), key, gw); err != nil { r.log.Error(err, "failed to get parent Gateway for ListenerSet", "namespace", ls.Namespace, "name", ls.Name) - return false + continue } - if !r.validateGatewayForReconcile(gw) { - return false + if r.validateGatewayForReconcile(gw) { + return true } } - return true + return false } func (r *gatewayAPIReconciler) isSecurityPolicyReferencingSecret(nsName *types.NamespacedName) bool { diff --git a/internal/provider/kubernetes/predicates_test.go b/internal/provider/kubernetes/predicates_test.go index a13d8fcb98..d0a4d5dc8f 100644 --- a/internal/provider/kubernetes/predicates_test.go +++ b/internal/provider/kubernetes/predicates_test.go @@ -991,6 +991,91 @@ func TestValidateSecretForReconcile(t *testing.T) { secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "ls-tls-secret"}), expect: false, }, + { + // One unmanaged parent must not hide another ListenerSet whose parent is managed. + name: "mixed ListenerSet parents: one invalid controller, one managed", + configs: []client.Object{ + test.GetGatewayClass("test-gc", egv1a1.GatewayControllerName, nil), + test.GetGatewayClass("other-gc", "not.configured/controller", nil), + test.GetGateway(types.NamespacedName{Namespace: "default", Name: "good-gw"}, "test-gc", 8080), + test.GetGateway(types.NamespacedName{Namespace: "default", Name: "bad-gw"}, "other-gc", 8080), + func() *gwapiv1.ListenerSet { + ls := test.GetListenerSet( + types.NamespacedName{Namespace: "default", Name: "bad-ls"}, + types.NamespacedName{Namespace: "default", Name: "bad-gw"}, + 443, + ) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + ls.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + ls.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "shared-ls-tls", + }}, + } + return ls + }(), + func() *gwapiv1.ListenerSet { + ls := test.GetListenerSet( + types.NamespacedName{Namespace: "default", Name: "good-ls"}, + types.NamespacedName{Namespace: "default", Name: "good-gw"}, + 443, + ) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + ls.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + ls.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "shared-ls-tls", + }}, + } + return ls + }(), + }, + secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "shared-ls-tls"}), + expect: true, + }, + { + name: "mixed Gateways referencing secret: one invalid controller, one managed", + configs: []client.Object{ + test.GetGatewayClass("test-gc", egv1a1.GatewayControllerName, nil), + test.GetGatewayClass("other-gc", "not.configured/controller", nil), + func() *gwapiv1.Gateway { + gw := test.GetGateway(types.NamespacedName{Namespace: "default", Name: "bad-gw"}, "other-gc", 443) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + gw.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + gw.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "shared-gw-tls", + }}, + } + return gw + }(), + func() *gwapiv1.Gateway { + gw := test.GetGateway(types.NamespacedName{Namespace: "default", Name: "good-gw"}, "test-gc", 443) + secretKind := gwapiv1.Kind(resource.KindSecret) + mode := gwapiv1.TLSModeTerminate + gw.Spec.Listeners[0].Protocol = gwapiv1.HTTPSProtocolType + gw.Spec.Listeners[0].TLS = &gwapiv1.ListenerTLSConfig{ + Mode: &mode, + CertificateRefs: []gwapiv1.SecretObjectReference{{ + Kind: &secretKind, + Name: "shared-gw-tls", + }}, + } + return gw + }(), + }, + secret: test.GetSecret(types.NamespacedName{Namespace: "default", Name: "shared-gw-tls"}), + expect: true, + }, } // Create the reconciler.