From 226253c5d644246601290a170d368b6b49142f81 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Tue, 28 Apr 2026 09:33:50 +0100 Subject: [PATCH] Render tigera-gateway namespace before EnvoyProxyRef resolution When a GatewayAPI CR is created on a fresh install with envoyProxyRef set on a GatewayClass pointing at an EnvoyProxy in the operator-managed tigera-gateway namespace, reconcile would early-return on the missing EnvoyProxy before reaching the non-CRD render that creates the namespace. Users could not create the EnvoyProxy because the namespace did not exist, deadlocking the controller in Degraded state. Render the namespace alongside CRDs, before EnvoyProxyRef resolution, so the namespace is always present as part of the operator's contract for any GatewayAPI CR. The existing non-CRD pass continues to apply the namespace later (idempotent), keeping the GatewayAPI CR as owner so the namespace is still cleaned up on CR removal. --- .../gatewayapi/gatewayapi_controller.go | 13 ++++ .../gatewayapi/gatewayapi_controller_test.go | 62 +++++++++++++++++-- pkg/render/gatewayapi/gateway_api.go | 28 +++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/pkg/controller/gatewayapi/gatewayapi_controller.go b/pkg/controller/gatewayapi/gatewayapi_controller.go index d6744dc288..1aa4f8eaf3 100644 --- a/pkg/controller/gatewayapi/gatewayapi_controller.go +++ b/pkg/controller/gatewayapi/gatewayapi_controller.go @@ -262,6 +262,19 @@ func (r *ReconcileGatewayAPI) Reconcile(ctx context.Context, request reconcile.R reqLogger.Info("Could not render all optional GatewayAPI CRDs", "err", err) } + // Render the tigera-gateway Namespace early — before any of the steps below that may + // early-return (e.g. EnvoyProxyRef resolution). The namespace is part of the operator's + // contract for any GatewayAPI CR, so it must always exist; otherwise users referencing + // custom EnvoyProxy resources in tigera-gateway on a fresh install hit a deadlock where + // reconcile fails on the missing EnvoyProxy and never reaches the non-CRD render that + // would have created the namespace. + namespaceComponent := gatewayapi.GatewayAPINamespaceComponent(installationSpec) + err = r.newComponentHandler(log, r.client, r.scheme, gatewayAPI).CreateOrUpdateOrDelete(ctx, namespaceComponent, nil) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error rendering tigera-gateway namespace", err, log) + return reconcile.Result{}, err + } + pullSecrets, err := utils.GetInstallationPullSecrets(installationSpec, r.client) if err != nil { r.status.SetDegraded(operatorv1.ResourceReadError, "Error retrieving pull secrets", err, reqLogger) diff --git a/pkg/controller/gatewayapi/gatewayapi_controller_test.go b/pkg/controller/gatewayapi/gatewayapi_controller_test.go index 90437ac610..ca3ebc16bc 100644 --- a/pkg/controller/gatewayapi/gatewayapi_controller_test.go +++ b/pkg/controller/gatewayapi/gatewayapi_controller_test.go @@ -225,12 +225,13 @@ var _ = Describe("Gateway API controller tests", func() { Expect(err).ShouldNot(HaveOccurred()) By("checking the component handlers") - Expect(fakeComponentHandlers).To(HaveLen(2)) + Expect(fakeComponentHandlers).To(HaveLen(3)) Expect(fakeComponentHandlers[0].createOnly).To(BeTrue()) Expect(fakeComponentHandlers[1].createOnly).To(BeFalse()) + Expect(fakeComponentHandlers[2].createOnly).To(BeFalse()) By("checking that the custom EnvoyGateway was passed through") - gatewayAPIImplementationConfig := fakeComponentHandlers[1].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() + gatewayAPIImplementationConfig := fakeComponentHandlers[2].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() Expect(gatewayAPIImplementationConfig.CustomEnvoyGateway).NotTo(BeNil()) Expect(*gatewayAPIImplementationConfig.CustomEnvoyGateway).To(Equal(*envoyGateway)) }) @@ -300,7 +301,7 @@ var _ = Describe("Gateway API controller tests", func() { Expect(err).ShouldNot(HaveOccurred()) By("checking that the custom EnvoyGateway was passed through") - gatewayAPIImplementationConfig := fakeComponentHandlers[1].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() + gatewayAPIImplementationConfig := fakeComponentHandlers[2].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() Expect(gatewayAPIImplementationConfig.CustomEnvoyGateway).NotTo(BeNil()) Expect(*gatewayAPIImplementationConfig.CustomEnvoyGateway).To(Equal(*envoyGateway)) }) @@ -467,12 +468,13 @@ var _ = Describe("Gateway API controller tests", func() { Expect(err).ShouldNot(HaveOccurred()) By("checking the component handlers") - Expect(fakeComponentHandlers).To(HaveLen(2)) + Expect(fakeComponentHandlers).To(HaveLen(3)) Expect(fakeComponentHandlers[0].createOnly).To(BeTrue()) Expect(fakeComponentHandlers[1].createOnly).To(BeFalse()) + Expect(fakeComponentHandlers[2].createOnly).To(BeFalse()) By("checking that the custom EnvoyProxies were passed through") - gatewayAPIImplementationConfig := fakeComponentHandlers[1].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() + gatewayAPIImplementationConfig := fakeComponentHandlers[2].lastComponent.(gatewayapi.GatewayAPIImplementationConfigInterface).GetConfig() Expect(gatewayAPIImplementationConfig.CustomEnvoyProxies).NotTo(BeNil()) Expect(gatewayAPIImplementationConfig.CustomEnvoyProxies).To(HaveKeyWithValue("custom-class-1", envoyProxy1)) Expect(gatewayAPIImplementationConfig.CustomEnvoyProxies).To(HaveKeyWithValue("custom-class-2", envoyProxy2)) @@ -534,6 +536,56 @@ var _ = Describe("Gateway API controller tests", func() { ).Return() _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).Should(HaveOccurred()) + + By("checking the tigera-gateway namespace was still rendered before the EnvoyProxyRef failure") + // Regression: previously, an EnvoyProxyRef pointing at a not-yet-existent EnvoyProxy in + // tigera-gateway caused reconcile to early-return before the namespace was created, + // deadlocking the user (they couldn't create the EnvoyProxy because the namespace + // didn't exist). The namespace must now be rendered before EnvoyProxyRef resolution. + Expect(fakeComponentHandlers).To(HaveLen(2)) + Expect(fakeComponentHandlers[0].createOnly).To(BeTrue()) + Expect(fakeComponentHandlers[1].createOnly).To(BeFalse()) + nsObjs, _ := fakeComponentHandlers[1].lastComponent.Objects() + Expect(nsObjs).To(HaveLen(1)) + Expect(nsObjs[0]).To(BeAssignableToTypeOf(&corev1.Namespace{})) + Expect(nsObjs[0].GetName()).To(Equal("tigera-gateway")) + }) + + It("renders the tigera-gateway namespace when only an EnvoyProxyRef is configured and the EnvoyProxy is missing", func() { + Expect(c.Create(ctx, installation)).NotTo(HaveOccurred()) + + By("applying a GatewayAPI CR with an EnvoyProxyRef but no matching EnvoyProxy") + gwapi := &operatorv1.GatewayAPI{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-secure"}, + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{ + Name: "custom-class-1", + EnvoyProxyRef: &operatorv1.NamespacedName{ + Namespace: "tigera-gateway", + Name: "missing-proxy", + }, + }}, + }, + } + Expect(c.Create(ctx, gwapi)).NotTo(HaveOccurred()) + + By("triggering a reconcile") + mockStatus.On( + "SetDegraded", + operatorv1.ResourceReadError, + "Error reading EnvoyProxyRef", + "envoyproxies.gateway.envoyproxy.io \"missing-proxy\" not found", + mock.Anything, + ).Return() + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).Should(HaveOccurred()) + + By("verifying the namespace component still ran (CRDs + namespace, no nonCRD impl)") + Expect(fakeComponentHandlers).To(HaveLen(2)) + nsObjs, _ := fakeComponentHandlers[1].lastComponent.Objects() + Expect(nsObjs).To(HaveLen(1)) + Expect(nsObjs[0]).To(BeAssignableToTypeOf(&corev1.Namespace{})) + Expect(nsObjs[0].GetName()).To(Equal("tigera-gateway")) }) It("handles when both GatewayKind and an incompatible EnvoyProxy are specified", func() { diff --git a/pkg/render/gatewayapi/gateway_api.go b/pkg/render/gatewayapi/gateway_api.go index e64cbe6f95..f28bcafe4d 100644 --- a/pkg/render/gatewayapi/gateway_api.go +++ b/pkg/render/gatewayapi/gateway_api.go @@ -424,6 +424,34 @@ func GatewayAPIImplementationComponent(cfg *GatewayAPIImplementationConfig) rend } } +// GatewayAPINamespaceComponent emits only the tigera-gateway Namespace. The controller renders +// this early — alongside CRDs and before EnvoyProxyRef resolution — so that users can create +// EnvoyProxy resources in tigera-gateway on a fresh install without the controller deadlocking +// on a missing namespace. +func GatewayAPINamespaceComponent(installation *operatorv1.InstallationSpec) render.Component { + return &gatewayAPINamespaceComponent{installation: installation} +} + +type gatewayAPINamespaceComponent struct { + installation *operatorv1.InstallationSpec +} + +func (c *gatewayAPINamespaceComponent) ResolveImages(*operatorv1.ImageSet) error { return nil } +func (c *gatewayAPINamespaceComponent) SupportedOSType() rmeta.OSType { return rmeta.OSTypeLinux } +func (c *gatewayAPINamespaceComponent) Ready() bool { return true } + +func (c *gatewayAPINamespaceComponent) Objects() ([]client.Object, []client.Object) { + resources := GatewayAPIResources() + return []client.Object{ + render.CreateNamespace( + resources.namespace.Name, + c.installation.KubernetesProvider, + render.PSSPrivileged, // Needed for HostPath volume to write logs to + c.installation.Azure, + ), + }, nil +} + func (pr *gatewayAPIImplementationComponent) ResolveImages(is *operatorv1.ImageSet) error { reg := pr.cfg.Installation.Registry path := pr.cfg.Installation.ImagePath