From 87949082ef657eaa1ac9c8b3296e86f7b382fc16 Mon Sep 17 00:00:00 2001 From: vara Date: Thu, 30 Jul 2026 10:35:22 -0700 Subject: [PATCH] PMREQ-821: Manager access via Calico Ingress Gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add spec.gateway to the Manager CR. When set (and a GatewayAPI CR exists), the manager controller renders Gateway API resources to expose the Manager UI through CIG: an HTTPS Gateway with TLS termination and an operator-provisioned certificate, an HTTPRoute, an Envoy Gateway Backend that re-originates TLS to calico-manager:9443 trusting tigera-ca-bundle, and — for a custom gateway namespace — a ReferenceGrant. A shared pkg/render/gateway package carries the rendering so Whisker can reuse it. Namespace handling: the gateway namespace is user-configurable and created on demand (never deleted — it may hold user workloads). In calico-system the component also renders the proxy ServiceAccount, RoleBinding, and a NetworkPolicy covering the default-deny (ingress on the remapped port 10443, egress to the Gateway controller and Manager). In a custom namespace those are left to the GatewayAPI controller's per-namespace machinery, matching user-brought Gateways; the TLS secret is rendered after the Gateway because the operator only gains secret access there once the GatewayAPI controller sees the Gateway. Cleanup is label-driven: the Gateway carries an operator.tigera.io/gateway label, and each reconcile tears down any labeled Gateway's namespace that no longer matches the spec — covering namespace changes and spec.gateway removal without stored state. On a namespace move the Backend is kept (the new render still routes through it) and the ReferenceGrant is deleted only when moving into the backend namespace. Status follows the design: gateway validation failures (GatewayAPI CR missing, unresolvable class, hostname/managerDomain mismatch) and unhealthy Gateway/HTTPRoute conditions set Degraded and requeue every 30s until Envoy converges; deployed resources are never torn down. The Gateway/HTTPRoute watch uses a name-matching predicate that accepts status-only events, and the gateway TLS secret is watched across namespaces with its truth copy persisted via the CertificateManagement component so the certificate is minted once, not per reconcile. spec.gateway on a multi-tenant tenant Manager sets Degraded(InvalidConfigurationError): resource names and the cleanup label carry no tenant identity, so multi-tenant support needs its own design. Co-Authored-By: Claude Fable 5 --- api/v1/gateway_types.go | 45 ++ api/v1/manager_types.go | 7 + api/v1/zz_generated.deepcopy.go | 30 ++ pkg/controller/manager/gateway_status_test.go | 142 ++++++ pkg/controller/manager/manager_controller.go | 367 +++++++++++++- .../manager/manager_controller_test.go | 56 +++ .../operator/operator.tigera.io_managers.yaml | 28 ++ pkg/render/gateway/component.go | 436 ++++++++++++++++ pkg/render/gateway/component_test.go | 465 ++++++++++++++++++ pkg/render/gateway/suite_test.go | 29 ++ 10 files changed, 1597 insertions(+), 8 deletions(-) create mode 100644 api/v1/gateway_types.go create mode 100644 pkg/controller/manager/gateway_status_test.go create mode 100644 pkg/render/gateway/component.go create mode 100644 pkg/render/gateway/component_test.go create mode 100644 pkg/render/gateway/suite_test.go diff --git a/api/v1/gateway_types.go b/api/v1/gateway_types.go new file mode 100644 index 0000000000..6def4dd21e --- /dev/null +++ b/api/v1/gateway_types.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1 + +// GatewaySpec configures Calico Ingress Gateway access for a UI component. +// When set, the operator renders Gateway API resources (Gateway, HTTPRoute, +// Backend, ReferenceGrant, TLS Secret) to expose the component via CIG. +type GatewaySpec struct { + // Hostname for the Gateway listener. Must match the Authentication CR's + // managerDomain when OIDC is configured (Manager only). + // +kubebuilder:validation:MinLength=1 + Hostname string `json:"hostname"` + + // GatewayNamespace is the namespace where the Gateway and Envoy Proxy pods + // run. Defaults to "calico-system". + // +optional + GatewayNamespace *string `json:"gatewayNamespace,omitempty"` + + // GatewayClassName selects the GatewayClass for the Gateway resource. + // If not set and the GatewayAPI CR has exactly one class, that class is + // used. If not set and multiple classes exist, the controller sets a + // warning requiring the user to specify one. + // +optional + GatewayClassName *string `json:"gatewayClassName,omitempty"` +} + +// NamespaceOrDefault returns the configured gateway namespace or "calico-system". +func (g *GatewaySpec) NamespaceOrDefault() string { + if g.GatewayNamespace != nil && *g.GatewayNamespace != "" { + return *g.GatewayNamespace + } + return "calico-system" +} diff --git a/api/v1/manager_types.go b/api/v1/manager_types.go index f338ce0d99..9eab445822 100644 --- a/api/v1/manager_types.go +++ b/api/v1/manager_types.go @@ -30,6 +30,13 @@ type ManagerSpec struct { // RBACUI configures the RBAC management UI feature. // +optional RBACUI *RBACUI `json:"rbacUI,omitempty"` + + // Gateway configures Calico Ingress Gateway access to the Manager UI. + // When set, the operator renders Gateway API resources (Gateway, HTTPRoute, + // Backend, ReferenceGrant, TLS Secret) to expose Manager via CIG. + // Requires a GatewayAPI CR to be present. + // +optional + Gateway *GatewaySpec `json:"gateway,omitempty"` } // +kubebuilder:validation:Enum=Enabled;Disabled diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 30e6ba5f6c..8033b7c19b 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -5191,6 +5191,31 @@ func (in *GatewayServiceSpec) DeepCopy() *GatewayServiceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewaySpec) DeepCopyInto(out *GatewaySpec) { + *out = *in + if in.GatewayNamespace != nil { + in, out := &in.GatewayNamespace, &out.GatewayNamespace + *out = new(string) + **out = **in + } + if in.GatewayClassName != nil { + in, out := &in.GatewayClassName, &out.GatewayClassName + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewaySpec. +func (in *GatewaySpec) DeepCopy() *GatewaySpec { + if in == nil { + return nil + } + out := new(GatewaySpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Goldmane) DeepCopyInto(out *Goldmane) { *out = *in @@ -7932,6 +7957,11 @@ func (in *ManagerSpec) DeepCopyInto(out *ManagerSpec) { *out = new(RBACUI) (*in).DeepCopyInto(*out) } + if in.Gateway != nil { + in, out := &in.Gateway, &out.Gateway + *out = new(GatewaySpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagerSpec. diff --git a/pkg/controller/manager/gateway_status_test.go b/pkg/controller/manager/gateway_status_test.go new file mode 100644 index 0000000000..ca4fa99457 --- /dev/null +++ b/pkg/controller/manager/gateway_status_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manager + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + gapi "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/tigera/operator/pkg/apis" + ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" +) + +var _ = Describe("gatewayUnhealthyReason", func() { + const ns = "calico-system" + + var ( + ctx context.Context + r *ReconcileManager + ) + + cond := func(t string, status metav1.ConditionStatus, msg string) metav1.Condition { + return metav1.Condition{Type: t, Status: status, Message: msg, Reason: "Test", LastTransitionTime: metav1.Now()} + } + + newGateway := func(conds ...metav1.Condition) *gapi.Gateway { + return &gapi.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: ManagerGatewayResourcePrefix + "-gateway", Namespace: ns}, + Status: gapi.GatewayStatus{Conditions: conds}, + } + } + + newRoute := func(conds ...metav1.Condition) *gapi.HTTPRoute { + route := &gapi.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: ManagerGatewayResourcePrefix + "-route", Namespace: ns}, + } + if len(conds) > 0 { + route.Status.Parents = []gapi.RouteParentStatus{{Conditions: conds}} + } + return route + } + + healthyGateway := func() *gapi.Gateway { + return newGateway( + cond(string(gapi.GatewayConditionAccepted), metav1.ConditionTrue, ""), + cond(string(gapi.GatewayConditionProgrammed), metav1.ConditionTrue, ""), + ) + } + + build := func(objs ...client.Object) { + scheme := runtime.NewScheme() + Expect(apis.AddToScheme(scheme, false)).NotTo(HaveOccurred()) + cli := ctrlrfake.DefaultFakeClientBuilder(scheme).WithObjects(objs...).Build() + r = &ReconcileManager{client: cli} + } + + BeforeEach(func() { + ctx = context.Background() + }) + + It("reports a missing Gateway", func() { + build() + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(ContainSubstring("not found yet")) + }) + + It("reports Gateway not accepted", func() { + build(newGateway(cond(string(gapi.GatewayConditionAccepted), metav1.ConditionFalse, "invalid listener"))) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(Equal("Gateway not accepted: invalid listener")) + }) + + It("reports Gateway not programmed", func() { + build(newGateway( + cond(string(gapi.GatewayConditionAccepted), metav1.ConditionTrue, ""), + cond(string(gapi.GatewayConditionProgrammed), metav1.ConditionFalse, "no addresses assigned"), + )) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(Equal("Gateway not programmed: no addresses assigned")) + }) + + It("treats missing Gateway conditions as healthy and moves on to the HTTPRoute", func() { + build(newGateway(), newRoute()) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(BeEmpty()) + }) + + It("reports a missing HTTPRoute once the Gateway is healthy", func() { + build(healthyGateway()) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(ContainSubstring("HTTPRoute")) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(ContainSubstring("not found yet")) + }) + + It("reports HTTPRoute not accepted", func() { + build(healthyGateway(), newRoute(cond(string(gapi.RouteConditionAccepted), metav1.ConditionFalse, "no matching parent"))) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(Equal("HTTPRoute not accepted: no matching parent")) + }) + + It("reports HTTPRoute refs not resolved", func() { + build(healthyGateway(), newRoute( + cond(string(gapi.RouteConditionAccepted), metav1.ConditionTrue, ""), + cond(string(gapi.RouteConditionResolvedRefs), metav1.ConditionFalse, "backend not permitted"), + )) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(Equal("HTTPRoute refs not resolved: backend not permitted")) + }) + + It("returns empty when the Gateway and HTTPRoute are healthy", func() { + build(healthyGateway(), newRoute( + cond(string(gapi.RouteConditionAccepted), metav1.ConditionTrue, ""), + cond(string(gapi.RouteConditionResolvedRefs), metav1.ConditionTrue, ""), + )) + Expect(r.gatewayUnhealthyReason(ctx, ns)).To(BeEmpty()) + }) +}) + +var _ = DescribeTable("managerDomainHost", + func(input, expected string) { + Expect(managerDomainHost(input)).To(Equal(expected)) + }, + Entry("bare host", "manager.example.com", "manager.example.com"), + Entry("https scheme", "https://manager.example.com", "manager.example.com"), + Entry("http scheme", "http://manager.example.com", "manager.example.com"), + Entry("explicit default port", "https://manager.example.com:443", "manager.example.com"), + Entry("non-default port", "manager.example.com:9443", "manager.example.com"), + Entry("scheme and port", "https://manager.example.com:9443", "manager.example.com"), + Entry("bare IPv6 without port", "2001:db8::1", "2001:db8::1"), + Entry("bracketed IPv6 with port", "[2001:db8::1]:443", "2001:db8::1"), +) diff --git a/pkg/controller/manager/manager_controller.go b/pkg/controller/manager/manager_controller.go index 993e229821..9a431f3c20 100644 --- a/pkg/controller/manager/manager_controller.go +++ b/pkg/controller/manager/manager_controller.go @@ -16,10 +16,17 @@ package manager import ( "context" + stderrors "errors" "fmt" + "net" + "slices" + "strings" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -27,7 +34,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gapi "sigs.k8s.io/gateway-api/apis/v1" v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" @@ -35,6 +44,7 @@ import ( "github.com/tigera/operator/pkg/common" "github.com/tigera/operator/pkg/controller/certificatemanager" "github.com/tigera/operator/pkg/controller/compliance" + "github.com/tigera/operator/pkg/controller/gatewayapi" lscommon "github.com/tigera/operator/pkg/controller/logstorage/common" "github.com/tigera/operator/pkg/controller/options" "github.com/tigera/operator/pkg/controller/status" @@ -47,6 +57,7 @@ import ( tigerakvc "github.com/tigera/operator/pkg/render/common/authentication/tigera/key_validator_config" relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/networkpolicy" + rgateway "github.com/tigera/operator/pkg/render/gateway" "github.com/tigera/operator/pkg/render/logstorage/eck" rmanager "github.com/tigera/operator/pkg/render/manager" "github.com/tigera/operator/pkg/render/monitor" @@ -158,6 +169,9 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { if err = c.WatchObject(&operatorv1.LogStorage{}, eventHandler); err != nil { return fmt.Errorf("manager-controller failed to watch LogStorage resource: %w", err) } + if err = c.WatchObject(&operatorv1.GatewayAPI{}, eventHandler); err != nil { + return fmt.Errorf("manager-controller failed to watch GatewayAPI resource: %w", err) + } if opts.MultiTenant { if err = c.WatchObject(&operatorv1.Tenant{}, &handler.EnqueueRequestForObject{}); err != nil { return fmt.Errorf("manager-controller failed to watch Tenant resource: %w", err) @@ -184,6 +198,34 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } } + // The gateway TLS secret is watched across all namespaces: the truth copy + // lives in the operator namespace, the rendered copy in the + // user-configurable gateway namespace. + if err = utils.AddSecretsWatch(c, ManagerGatewayTLSSecretName, ""); err != nil { + return fmt.Errorf("manager-controller failed to watch the secret '%s': %w", ManagerGatewayTLSSecretName, err) + } + + // Gateway and HTTPRoute status transitions must re-run + // gatewayUnhealthyReason so the Degraded state tracks gateway health. + // The watch arms once the Gateway API CRDs exist. Gateway health lives + // in status, which does not bump the generation, so the default + // generation-based predicate would drop these events — match by name + // and accept every event instead. + gatewayWatchPredicate := predicate.NewPredicateFuncs(func(o client.Object) bool { + return o.GetName() == ManagerGatewayResourcePrefix+"-gateway" || + o.GetName() == ManagerGatewayResourcePrefix+"-route" + }) + go utils.WaitToAddResourceWatch(c, opts.K8sClientset, log, nil, []client.Object{ + &gapi.Gateway{ + TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: ManagerGatewayResourcePrefix + "-gateway"}, + }, + &gapi.HTTPRoute{ + TypeMeta: metav1.TypeMeta{Kind: "HTTPRoute", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: ManagerGatewayResourcePrefix + "-route"}, + }, + }, gatewayWatchPredicate) + if err = utils.AddConfigMapWatch(c, tigerakvc.StaticWellKnownJWKSConfigMapName, common.OperatorNamespace(), &handler.EnqueueRequestForObject{}); err != nil { return fmt.Errorf("manager-controller failed to watch ConfigMap resource %s: %w", tigerakvc.StaticWellKnownJWKSConfigMapName, err) } @@ -752,6 +794,87 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ return reconcile.Result{}, err } + // Resolve gateway components. Cleanup is label-driven: every Gateway + // carrying this component's label outside the desired namespace (or in + // any namespace, when spec.gateway is nil) marks leftover resources to + // tear down. No state is stored — each reconcile converges from what is + // observed on the cluster. + var gatewayComponents []render.Component + var gatewayTLSKeyPair certificatemanagement.KeyPairInterface + if r.opts.MultiTenant { + // Multi-tenant CIG is not supported: resource names and the cleanup + // label carry no tenant identity, so tenants would fight over the + // same Gateway and could delete each other's resources — including + // the GatewayAPI controller's per-namespace SA and RoleBinding. + // Nothing is ever created, so there is nothing to clean up either. + if instance.Spec.Gateway != nil { + r.status.SetDegraded(operatorv1.InvalidConfigurationError, "spec.gateway is not supported in multi-tenant clusters", nil, logc) + return reconcile.Result{}, nil + } + } else if instance.Spec.Gateway != nil { + gwComp, gwKeyPair, result, err := r.resolveGateway(ctx, instance, authenticationCR, certificateManager, helper, logc) + if err != nil { + return result, err + } + if gwComp == nil { + return result, nil + } + gatewayTLSKeyPair = gwKeyPair + + gwNS := instance.Spec.Gateway.NamespaceOrDefault() + strays, err := r.managerGatewayNamespaces(ctx) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Failed to list gateways for cleanup", err, logc) + return reconcile.Result{}, err + } + for _, ns := range strays { + if ns == gwNS { + continue + } + gatewayComponents = append(gatewayComponents, rgateway.DeletionComponent(&rgateway.DeletionConfiguration{ + ResourcePrefix: ManagerGatewayResourcePrefix, + GatewayNamespace: ns, + BackendNamespace: helper.InstallNamespace(), + TLSSecretName: ManagerGatewayTLSSecretName, + Enterprise: true, + MoveTargetNamespace: gwNS, + })) + } + gatewayComponents = append(gatewayComponents, gwComp) + } else { + // Tear down every labeled Gateway's namespace. The install namespace + // is always included: it holds the Backend and ReferenceGrant, and + // this covers partial renders that never produced a labeled Gateway. + namespaces, err := r.managerGatewayNamespaces(ctx) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceReadError, "Failed to list gateways for cleanup", err, logc) + return reconcile.Result{}, err + } + if !slices.Contains(namespaces, helper.InstallNamespace()) { + namespaces = append(namespaces, helper.InstallNamespace()) + } + for _, ns := range namespaces { + gatewayComponents = append(gatewayComponents, rgateway.DeletionComponent(&rgateway.DeletionConfiguration{ + ResourcePrefix: ManagerGatewayResourcePrefix, + GatewayNamespace: ns, + BackendNamespace: helper.InstallNamespace(), + TLSSecretName: ManagerGatewayTLSSecretName, + Enterprise: true, + })) + } + } + + keyPairOptions := []rcertificatemanagement.KeyPairOption{ + rcertificatemanagement.NewKeyPairOption(tlsSecret, true, true), + rcertificatemanagement.NewKeyPairOption(linseedVoltronServerCert, true, true), + rcertificatemanagement.NewKeyPairOption(internalTrafficSecret, true, true), + rcertificatemanagement.NewKeyPairOption(tunnelServerCert, false, true), + rcertificatemanagement.NewKeyPairOption(additionalTunnelServerCert, false, true), + } + if gatewayTLSKeyPair != nil { + keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(gatewayTLSKeyPair, true, false)) + } + components := []render.Component{ // Install manager components. component, @@ -761,14 +884,8 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ Namespace: helper.InstallNamespace(), TruthNamespace: helper.TruthNamespace(), ServiceAccounts: []string{render.ManagerServiceAccount}, - KeyPairOptions: []rcertificatemanagement.KeyPairOption{ - rcertificatemanagement.NewKeyPairOption(tlsSecret, true, true), - rcertificatemanagement.NewKeyPairOption(linseedVoltronServerCert, true, true), - rcertificatemanagement.NewKeyPairOption(internalTrafficSecret, true, true), - rcertificatemanagement.NewKeyPairOption(tunnelServerCert, false, true), - rcertificatemanagement.NewKeyPairOption(additionalTunnelServerCert, false, true), - }, - TrustedBundle: bundleMaker, + KeyPairOptions: keyPairOptions, + TrustedBundle: bundleMaker, }), } @@ -776,6 +893,8 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ components = append(components, tunnelSecretPassthrough) } + components = append(components, gatewayComponents...) + for _, component := range components { if err := defaultHandler.CreateOrUpdateOrDelete(ctx, component, r.status); err != nil { r.status.SetDegraded(operatorv1.ResourceUpdateError, "Error creating / updating resource", err, logc) @@ -783,6 +902,16 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ } } + if instance.Spec.Gateway != nil { + // An unhealthy gateway degrades the component without tearing down + // deployed resources. The requeue re-checks until Envoy converges; + // the degraded state then clears on the pass below. + if msg := r.gatewayUnhealthyReason(ctx, instance.Spec.Gateway.NamespaceOrDefault()); msg != "" { + r.status.SetDegraded(operatorv1.ResourceNotReady, msg, nil, logc) + return reconcile.Result{RequeueAfter: utils.StandardRetry}, nil + } + } + // Check BYO certificate expiry warnings. certificatemanagement.CheckKeyPairWarnings(map[string]certificatemanagement.KeyPairInterface{ render.ManagerTLSSecretName: tlsSecret, @@ -887,3 +1016,225 @@ func (r *ReconcileManager) resolveAdditionalTunnelCert( } return certificatemanagement.NewKeyPair(secret, nil, ""), nil } + +const ( + ManagerGatewayTLSSecretName = "calico-manager-gateway-tls" + ManagerGatewayResourcePrefix = "calico-manager" +) + +// managerGatewayNamespaces returns the sorted, de-duplicated namespaces of +// Gateways carrying this component's gateway label. A missing Gateway API CRD +// yields an empty list — there is nothing to clean up on clusters without +// CIG. In multi-tenant mode the list is skipped: the label value is shared +// across tenants, so one tenant's cleanup must not see another's Gateways. +func (r *ReconcileManager) managerGatewayNamespaces(ctx context.Context) ([]string, error) { + if r.opts.MultiTenant { + return nil, nil + } + gwList := &gapi.GatewayList{} + if err := r.client.List(ctx, gwList, client.MatchingLabels{rgateway.GatewayLabel: ManagerGatewayResourcePrefix}); err != nil { + var noMatch *apimeta.NoKindMatchError + if stderrors.As(err, &noMatch) { + return nil, nil + } + return nil, err + } + var namespaces []string + for _, gw := range gwList.Items { + if !slices.Contains(namespaces, gw.Namespace) { + namespaces = append(namespaces, gw.Namespace) + } + } + slices.Sort(namespaces) + return namespaces, nil +} + +// resolveGateway validates the Manager spec.gateway configuration, resolves the +// GatewayClass, provisions the TLS keypair, and returns a gateway render component. +func (r *ReconcileManager) resolveGateway( + ctx context.Context, + instance *operatorv1.Manager, + authenticationCR *operatorv1.Authentication, + certManager certificatemanager.CertificateManager, + helper utils.NamespaceHelper, + logc logr.Logger, +) (render.Component, certificatemanagement.KeyPairInterface, reconcile.Result, error) { + gw := instance.Spec.Gateway + + // Fetch GatewayAPI CR. + gatewayAPI, msg, err := gatewayapi.GetGatewayAPI(ctx, r.client) + if err != nil { + if errors.IsNotFound(err) { + r.status.SetDegraded(operatorv1.ResourceNotFound, "GatewayAPI CR not found; gateway resources will not be rendered", err, logc) + return nil, nil, reconcile.Result{}, err + } + r.status.SetDegraded(operatorv1.ResourceReadError, msg, err, logc) + return nil, nil, reconcile.Result{}, err + } + + // Resolve gatewayClassName. + gatewayClassName, err := resolveGatewayClassName(gw, gatewayAPI) + if err != nil { + r.status.SetDegraded(operatorv1.InvalidConfigurationError, "Failed to resolve gateway class", err, logc) + return nil, nil, reconcile.Result{}, err + } + + // OIDC hostname check. managerDomain is a base URL (https://host[:port]); + // only the host must match spec.gateway.hostname — scheme and port are + // ignored. + if authenticationCR != nil && authenticationCR.Spec.ManagerDomain != "" { + if managerDomainHost(authenticationCR.Spec.ManagerDomain) != gw.Hostname { + err := fmt.Errorf("Authentication.spec.managerDomain %q does not match spec.gateway.hostname %q — OIDC redirects will fail", + authenticationCR.Spec.ManagerDomain, gw.Hostname) + r.status.SetDegraded(operatorv1.InvalidConfigurationError, "Gateway hostname mismatch", err, logc) + return nil, nil, reconcile.Result{}, err + } + } + + // Create the gateway namespace if it does not exist. calico-system is + // skipped: the Installation controller owns it. + if gwNS := gw.NamespaceOrDefault(); gwNS != common.CalicoNamespace { + if err := r.ensureGatewayNamespace(ctx, gwNS); err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, fmt.Sprintf("Failed to create gateway namespace %q", gwNS), err, logc) + return nil, nil, reconcile.Result{}, err + } + } + + // Provision TLS keypair for the gateway listener. + gwTLSKeyPair, err := certManager.GetOrCreateKeyPair( + r.client, + ManagerGatewayTLSSecretName, + helper.TruthNamespace(), + []string{gw.Hostname}) + if err != nil { + r.status.SetDegraded(operatorv1.CertificateError, "Error getting or creating gateway TLS certificate", err, logc) + return nil, nil, reconcile.Result{}, err + } + + gwCfg := &rgateway.Configuration{ + Hostname: gw.Hostname, + GatewayNamespace: gw.NamespaceOrDefault(), + GatewayClassName: gatewayClassName, + BackendServiceName: render.ManagerServiceName, + BackendPort: render.ManagerPort, + BackendNamespace: helper.InstallNamespace(), + BackendCABundleConfigMapName: certificatemanagement.TrustedCertConfigMapName, + TLSKeyPair: gwTLSKeyPair, + ResourcePrefix: ManagerGatewayResourcePrefix, + Enterprise: true, + OpenShift: r.opts.DetectedProvider.IsOpenShift(), + } + + return rgateway.Component(gwCfg), gwTLSKeyPair, reconcile.Result{}, nil +} + +// ensureGatewayNamespace creates the gateway namespace if it does not exist. +// The namespace is created without an owner reference and is never deleted by +// the operator: a user-provided namespace may hold other workloads. +func (r *ReconcileManager) ensureGatewayNamespace(ctx context.Context, name string) error { + err := r.client.Get(ctx, types.NamespacedName{Name: name}, &corev1.Namespace{}) + if err == nil || !errors.IsNotFound(err) { + return err + } + ns := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{Kind: "Namespace", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"name": name}, + }, + } + if err := r.client.Create(ctx, ns); err != nil && !errors.IsAlreadyExists(err) { + return err + } + return nil +} + +// gatewayUnhealthyReason reads the Gateway and HTTPRoute status conditions +// and returns why the gateway is not ready, or "" when every condition is +// healthy. Per the design, an unhealthy gateway degrades the component — the +// caller sets Degraded and requeues; deployed resources are never torn down. +// NotFound is reported too: the requeue re-checks once the cache catches up +// with the resources this reconcile just applied. +func (r *ReconcileManager) gatewayUnhealthyReason(ctx context.Context, gatewayNS string) string { + gatewayName := ManagerGatewayResourcePrefix + "-gateway" + routeName := ManagerGatewayResourcePrefix + "-route" + + gw := &gapi.Gateway{} + if err := r.client.Get(ctx, client.ObjectKey{Name: gatewayName, Namespace: gatewayNS}, gw); err != nil { + if errors.IsNotFound(err) { + return fmt.Sprintf("Gateway %s/%s not found yet", gatewayNS, gatewayName) + } + return fmt.Sprintf("Failed to read Gateway %s/%s status: %v", gatewayNS, gatewayName, err) + } + + if msg := unhealthyCondition(gw.Status.Conditions, string(gapi.GatewayConditionAccepted), "Gateway not accepted"); msg != "" { + return msg + } + if msg := unhealthyCondition(gw.Status.Conditions, string(gapi.GatewayConditionProgrammed), "Gateway not programmed"); msg != "" { + return msg + } + + route := &gapi.HTTPRoute{} + if err := r.client.Get(ctx, client.ObjectKey{Name: routeName, Namespace: gatewayNS}, route); err != nil { + if errors.IsNotFound(err) { + return fmt.Sprintf("HTTPRoute %s/%s not found yet", gatewayNS, routeName) + } + return fmt.Sprintf("Failed to read HTTPRoute %s/%s status: %v", gatewayNS, routeName, err) + } + for _, ps := range route.Status.Parents { + if msg := unhealthyCondition(ps.Conditions, string(gapi.RouteConditionAccepted), "HTTPRoute not accepted"); msg != "" { + return msg + } + if msg := unhealthyCondition(ps.Conditions, string(gapi.RouteConditionResolvedRefs), "HTTPRoute refs not resolved"); msg != "" { + return msg + } + } + + return "" +} + +// unhealthyCondition returns a message when the named condition exists and is +// not True. A missing condition is healthy: the controller has not written +// its verdict yet, and Accepted/Programmed gate readiness once it does. +func unhealthyCondition(conditions []metav1.Condition, condType, msgPrefix string) string { + for _, cond := range conditions { + if cond.Type == condType && cond.Status != metav1.ConditionTrue { + return fmt.Sprintf("%s: %s", msgPrefix, cond.Message) + } + } + return "" +} + +// managerDomainHost extracts the host from a managerDomain-style value — +// scheme and port, when present, are dropped. +func managerDomainHost(s string) string { + s = strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") + if host, _, err := net.SplitHostPort(s); err == nil { + return host + } + return s +} + +// resolveGatewayClassName determines the GatewayClass name to use based on the +// user's spec.gateway.gatewayClassName or the GatewayAPI CR's configured classes. +func resolveGatewayClassName(gw *operatorv1.GatewaySpec, gatewayAPI *operatorv1.GatewayAPI) (string, error) { + if gw.GatewayClassName != nil && *gw.GatewayClassName != "" { + name := *gw.GatewayClassName + for _, c := range gatewayAPI.Spec.GatewayClasses { + if c.Name == name { + return name, nil + } + } + return "", fmt.Errorf("GatewayClass %q not found; verify GatewayAPI CR includes this class", name) + } + + classes := gatewayAPI.Spec.GatewayClasses + switch len(classes) { + case 0: + return "", fmt.Errorf("no GatewayClasses configured on GatewayAPI CR") + case 1: + return classes[0].Name, nil + default: + return "", fmt.Errorf("multiple GatewayClasses configured on GatewayAPI CR; set spec.gateway.gatewayClassName to select one") + } +} diff --git a/pkg/controller/manager/manager_controller_test.go b/pkg/controller/manager/manager_controller_test.go index b3960fa4c7..50939b8f09 100644 --- a/pkg/controller/manager/manager_controller_test.go +++ b/pkg/controller/manager/manager_controller_test.go @@ -37,6 +37,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayapiv1 "sigs.k8s.io/gateway-api/apis/v1" v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" operatorv1 "github.com/tigera/operator/api/v1" @@ -1243,6 +1244,28 @@ var _ = Describe("Manager controller tests", func() { Expect(err).NotTo(HaveOccurred()) }) + It("should degrade when spec.gateway is set on a tenant Manager", func() { + manager := &operatorv1.Manager{} + Expect(c.Get(ctx, types.NamespacedName{Name: "tigera-secure", Namespace: tenantANamespace}, manager)).NotTo(HaveOccurred()) + manager.Spec.Gateway = &operatorv1.GatewaySpec{Hostname: "manager.example.com"} + Expect(c.Update(ctx, manager)).NotTo(HaveOccurred()) + + mockStatus.On("SetDegraded", operatorv1.InvalidConfigurationError, "spec.gateway is not supported in multi-tenant clusters", mock.Anything, mock.Anything).Return() + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: tenantANamespace}}) + Expect(err).ShouldNot(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + mockStatus.AssertCalled(GinkgoT(), "SetDegraded", operatorv1.InvalidConfigurationError, "spec.gateway is not supported in multi-tenant clusters", mock.Anything, mock.Anything) + + // Nothing gateway-related is rendered for the tenant. + gw := &gatewayapiv1.Gateway{ + TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: ManagerGatewayResourcePrefix + "-gateway", Namespace: tenantANamespace}, + } + Expect(kerror.IsNotFound(test.GetResource(c, gw))).To(BeTrue()) + }) + It("should reconcile only if a namespace is provided", func() { _, err := r.Reconcile(ctx, reconcile.Request{}) Expect(err).ShouldNot(HaveOccurred()) @@ -1477,6 +1500,39 @@ var _ = Describe("Manager controller tests", func() { }) }) }) + + Context("ensureGatewayNamespace", func() { + var r ReconcileManager + + BeforeEach(func() { + r = ReconcileManager{client: c, scheme: scheme} + }) + + It("should create the namespace when it does not exist", func() { + Expect(r.ensureGatewayNamespace(ctx, "ns-a")).NotTo(HaveOccurred()) + + ns := &corev1.Namespace{} + Expect(c.Get(ctx, types.NamespacedName{Name: "ns-a"}, ns)).NotTo(HaveOccurred()) + Expect(ns.Labels).To(HaveKeyWithValue("name", "ns-a")) + Expect(ns.OwnerReferences).To(BeEmpty()) + }) + + It("should leave an existing namespace untouched", func() { + existing := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-a", + Labels: map[string]string{"team": "netsec"}, + }, + } + Expect(c.Create(ctx, existing)).NotTo(HaveOccurred()) + + Expect(r.ensureGatewayNamespace(ctx, "ns-a")).NotTo(HaveOccurred()) + + ns := &corev1.Namespace{} + Expect(c.Get(ctx, types.NamespacedName{Name: "ns-a"}, ns)).NotTo(HaveOccurred()) + Expect(ns.Labels).To(Equal(map[string]string{"team": "netsec"})) + }) + }) }) func assertSANs(secret *corev1.Secret, expectedSAN string) { diff --git a/pkg/imports/crds/operator/operator.tigera.io_managers.yaml b/pkg/imports/crds/operator/operator.tigera.io_managers.yaml index 66c1345bf4..e182203207 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_managers.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_managers.yaml @@ -42,6 +42,34 @@ spec: Specification of the desired state for the Calico Enterprise manager. properties: + gateway: + description: |- + Gateway configures Calico Ingress Gateway access to the Manager UI. + When set, the operator renders Gateway API resources (Gateway, HTTPRoute, + Backend, ReferenceGrant, TLS Secret) to expose Manager via CIG. + Requires a GatewayAPI CR to be present. + properties: + gatewayClassName: + description: |- + GatewayClassName selects the GatewayClass for the Gateway resource. + If not set and the GatewayAPI CR has exactly one class, that class is + used. If not set and multiple classes exist, the controller sets a + warning requiring the user to specify one. + type: string + gatewayNamespace: + description: |- + GatewayNamespace is the namespace where the Gateway and Envoy Proxy pods + run. Defaults to "calico-system". + type: string + hostname: + description: |- + Hostname for the Gateway listener. Must match the Authentication CR's + managerDomain when OIDC is configured (Manager only). + minLength: 1 + type: string + required: + - hostname + type: object managerDeployment: description: ManagerDeployment configures the Manager Deployment. properties: diff --git a/pkg/render/gateway/component.go b/pkg/render/gateway/component.go new file mode 100644 index 0000000000..2f5df2bad5 --- /dev/null +++ b/pkg/render/gateway/component.go @@ -0,0 +1,436 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gateway + +import ( + "fmt" + + envoyapi "github.com/envoyproxy/gateway/api/v1alpha1" + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gapi "sigs.k8s.io/gateway-api/apis/v1" + + operatorv1 "github.com/tigera/operator/api/v1" + rmeta "github.com/tigera/operator/pkg/render/common/meta" + "github.com/tigera/operator/pkg/render/common/networkpolicy" + rgatewayapi "github.com/tigera/operator/pkg/render/gatewayapi" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +const ( + EnvoyGatewayGroup = "gateway.envoyproxy.io" + BackendKind = "Backend" + + // GatewayLabel marks operator-managed UI Gateways; the value is the + // component's resource prefix. Cleanup lists Gateways by this label to + // find namespaces holding leftover gateway resources. + GatewayLabel = "operator.tigera.io/gateway" +) + +// Configuration holds everything the shared gateway component needs to render +// Gateway API resources for a UI component (Manager or Whisker). +type Configuration struct { + Hostname string + GatewayNamespace string + GatewayClassName string + + BackendServiceName string + BackendPort int32 + BackendNamespace string + BackendCABundleConfigMapName string + + TLSKeyPair certificatemanagement.KeyPairInterface + + // ResourcePrefix names all generated resources, e.g. "calico-manager" produces + // "calico-manager-gateway", "calico-manager-route", etc. + ResourcePrefix string + + // Enterprise controls whether the proxy SA, RoleBinding, and NetworkPolicy + // are rendered. They are only rendered when the Gateway is placed in the + // backend (install) namespace: the GatewayAPI controller skips + // calico-system (lifecycle guard), so this component fills that gap. For + // custom gateway namespaces the GatewayAPI controller creates the + // SA/RoleBinding itself and no NetworkPolicy is rendered, matching + // user-brought Gateways. + Enterprise bool + + OpenShift bool +} + +// Component renders Gateway API resources for CIG access to a UI component. +func Component(cfg *Configuration) *gatewayComponent { + return &gatewayComponent{cfg: cfg} +} + +type gatewayComponent struct { + cfg *Configuration +} + +func (c *gatewayComponent) ResolveImages(_ *operatorv1.ImageSet) error { + return nil +} + +func (c *gatewayComponent) SupportedOSType() rmeta.OSType { + return rmeta.OSTypeLinux +} + +func (c *gatewayComponent) Ready() bool { + return true +} + +func (c *gatewayComponent) Objects() (objsToCreate, objsToDelete []client.Object) { + var objs []client.Object + + if c.cfg.GatewayNamespace != c.cfg.BackendNamespace { + objs = append(objs, c.referenceGrant()) + } + + // The TLS secret is rendered after the Gateway. In a custom gateway + // namespace the operator gains secret access through the RoleBinding the + // GatewayAPI controller creates once it sees the Gateway there, so the + // secret create fails on the first reconcile and succeeds on the retry. + objs = append(objs, + c.gateway(), + c.backend(), + c.httpRoute(), + c.tlsSecret(), + ) + + if c.cfg.Enterprise && c.cfg.GatewayNamespace == c.cfg.BackendNamespace { + // calico-system has an operator-managed default-deny, and the + // GatewayAPI controller skips it (lifecycle guard), so the proxy SA, + // RoleBinding, and NetworkPolicy are rendered here. In a custom + // namespace the GatewayAPI controller creates the SA and RoleBinding, + // and no NetworkPolicy is rendered — the same treatment user-brought + // Gateways get. + objs = append(objs, + rgatewayapi.GatewayNamespaceServiceAccount(c.cfg.GatewayNamespace), + rgatewayapi.GatewayNamespaceRoleBinding(c.cfg.GatewayNamespace), + c.proxyNetworkPolicy(), + ) + } + + return objs, nil +} + +func (c *gatewayComponent) tlsSecret() *corev1.Secret { + s := c.cfg.TLSKeyPair.Secret(c.cfg.GatewayNamespace) + s.Type = corev1.SecretTypeTLS + return s +} + +func (c *gatewayComponent) gateway() *gapi.Gateway { + listenerName := gapi.SectionName(c.cfg.ResourcePrefix + "-https") + hostname := gapi.Hostname(c.cfg.Hostname) + tlsSecretName := c.cfg.TLSKeyPair.GetName() + + return &gapi.Gateway{ + TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.cfg.ResourcePrefix + "-gateway", + Namespace: c.cfg.GatewayNamespace, + Labels: map[string]string{ + GatewayLabel: c.cfg.ResourcePrefix, + }, + }, + Spec: gapi.GatewaySpec{ + GatewayClassName: gapi.ObjectName(c.cfg.GatewayClassName), + Listeners: []gapi.Listener{ + { + Name: listenerName, + Protocol: gapi.HTTPSProtocolType, + Port: gapi.PortNumber(443), + Hostname: &hostname, + TLS: &gapi.ListenerTLSConfig{ + Mode: ptr.To(gapi.TLSModeTerminate), + CertificateRefs: []gapi.SecretObjectReference{ + { + Name: gapi.ObjectName(tlsSecretName), + }, + }, + }, + AllowedRoutes: &gapi.AllowedRoutes{ + Namespaces: &gapi.RouteNamespaces{ + From: ptr.To(gapi.NamespacesFromSame), + }, + }, + }, + }, + }, + } +} + +func (c *gatewayComponent) httpRoute() *gapi.HTTPRoute { + gatewayName := gapi.ObjectName(c.cfg.ResourcePrefix + "-gateway") + sectionName := gapi.SectionName(c.cfg.ResourcePrefix + "-https") + backendName := gapi.ObjectName(c.cfg.ResourcePrefix + "-backend") + backendNS := gapi.Namespace(c.cfg.BackendNamespace) + group := gapi.Group(EnvoyGatewayGroup) + + return &gapi.HTTPRoute{ + TypeMeta: metav1.TypeMeta{Kind: "HTTPRoute", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.cfg.ResourcePrefix + "-route", + Namespace: c.cfg.GatewayNamespace, + }, + Spec: gapi.HTTPRouteSpec{ + CommonRouteSpec: gapi.CommonRouteSpec{ + ParentRefs: []gapi.ParentReference{ + { + Name: gatewayName, + SectionName: §ionName, + }, + }, + }, + Rules: []gapi.HTTPRouteRule{ + { + BackendRefs: []gapi.HTTPBackendRef{ + { + BackendRef: gapi.BackendRef{ + BackendObjectReference: gapi.BackendObjectReference{ + Group: &group, + Kind: ptr.To(gapi.Kind(BackendKind)), + Name: backendName, + Namespace: &backendNS, + }, + }, + }, + }, + }, + }, + }, + } +} + +func (c *gatewayComponent) backend() *envoyapi.Backend { + svcFQDN := c.cfg.BackendServiceName + "." + c.cfg.BackendNamespace + ".svc" + sni := gapi.PreciseHostname(svcFQDN) + + return &envoyapi.Backend{ + TypeMeta: metav1.TypeMeta{Kind: BackendKind, APIVersion: "gateway.envoyproxy.io/v1alpha1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.cfg.ResourcePrefix + "-backend", + Namespace: c.cfg.BackendNamespace, + }, + Spec: envoyapi.BackendSpec{ + Endpoints: []envoyapi.BackendEndpoint{ + { + FQDN: &envoyapi.FQDNEndpoint{ + Hostname: svcFQDN, + Port: c.cfg.BackendPort, + }, + }, + }, + TLS: &envoyapi.BackendTLSSettings{ + CACertificateRefs: []gapi.LocalObjectReference{ + { + Group: "", + Kind: "ConfigMap", + Name: gapi.ObjectName(c.cfg.BackendCABundleConfigMapName), + }, + }, + SNI: &sni, + }, + }, + } +} + +func (c *gatewayComponent) referenceGrant() *gapi.ReferenceGrant { + backendName := gapi.ObjectName(c.cfg.ResourcePrefix + "-backend") + + return &gapi.ReferenceGrant{ + TypeMeta: metav1.TypeMeta{Kind: "ReferenceGrant", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: c.cfg.ResourcePrefix + "-allow-gateway", + Namespace: c.cfg.BackendNamespace, + }, + Spec: gapi.ReferenceGrantSpec{ + From: []gapi.ReferenceGrantFrom{ + { + Group: gapi.GroupName, + Kind: "HTTPRoute", + Namespace: gapi.Namespace(c.cfg.GatewayNamespace), + }, + }, + To: []gapi.ReferenceGrantTo{ + { + Group: gapi.Group(EnvoyGatewayGroup), + Kind: BackendKind, + Name: &backendName, + }, + }, + }, + } +} + +// proxyNetworkPolicy creates a Calico NetworkPolicy that allows the Envoy +// proxy pod to function in calico-system (which has a default deny). +func (c *gatewayComponent) proxyNetworkPolicy() *v3.NetworkPolicy { + gatewayName := c.cfg.ResourcePrefix + "-gateway" + policyName := networkpolicy.CalicoComponentPolicyPrefix + gatewayName + "-proxy" + + egressRules := networkpolicy.AppendDNSEgressRules(nil, c.cfg.OpenShift) + egressRules = append(egressRules, + v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: networkpolicy.CreateEntityRule( + c.cfg.BackendNamespace, "calico-gateway-api-controller", + 18000, 18001, + ), + }, + v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: networkpolicy.CreateEntityRule( + c.cfg.BackendNamespace, c.cfg.BackendServiceName, + uint16(c.cfg.BackendPort), + ), + }, + ) + + return &v3.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + ObjectMeta: metav1.ObjectMeta{ + Name: policyName, + Namespace: c.cfg.GatewayNamespace, + }, + Spec: v3.NetworkPolicySpec{ + Order: &networkpolicy.HighPrecedenceOrder, + Tier: networkpolicy.CalicoTierName, + Selector: fmt.Sprintf("gateway.envoyproxy.io/owning-gateway-name == '%s'", gatewayName), + Types: []v3.PolicyType{v3.PolicyTypeIngress, v3.PolicyTypeEgress}, + Ingress: []v3.Rule{ + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{Nets: []string{"0.0.0.0/0"}}, + Destination: v3.EntityRule{ + // Envoy Gateway remaps privileged ports by adding 10000, + // so listener port 443 becomes container port 10443. + Ports: networkpolicy.Ports(10443), + }, + }, + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{Nets: []string{"::/0"}}, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(10443), + }, + }, + }, + Egress: egressRules, + }, + } +} + +// DeletionConfiguration is the minimal config needed to identify gateway +// resources for cleanup. No TLS keypair, hostname, or class is required. +type DeletionConfiguration struct { + ResourcePrefix string + GatewayNamespace string + BackendNamespace string + TLSSecretName string + Enterprise bool + + // MoveTargetNamespace, when set, marks this as cleanup after the gateway + // moved to that namespace while spec.gateway stayed configured. The + // Backend is kept — it lives in the backend namespace and the new render + // still routes to it. The ReferenceGrant is deleted only when the target + // is the backend namespace, where the render no longer emits it; for any + // other target the render updates it in place. + MoveTargetNamespace string +} + +// DeletionComponent returns a render.Component whose Objects() puts every +// gateway-managed resource into objsToDelete. The objects carry only TypeMeta +// and ObjectMeta — enough for the component handler to issue Delete calls. +func DeletionComponent(cfg *DeletionConfiguration) *gatewayDeletionComponent { + return &gatewayDeletionComponent{cfg: cfg} +} + +type gatewayDeletionComponent struct { + cfg *DeletionConfiguration +} + +func (c *gatewayDeletionComponent) ResolveImages(_ *operatorv1.ImageSet) error { return nil } +func (c *gatewayDeletionComponent) SupportedOSType() rmeta.OSType { return rmeta.OSTypeLinux } +func (c *gatewayDeletionComponent) Ready() bool { return true } + +func (c *gatewayDeletionComponent) Objects() (objsToCreate, objsToDelete []client.Object) { + gwNS := c.cfg.GatewayNamespace + bkNS := c.cfg.BackendNamespace + prefix := c.cfg.ResourcePrefix + + move := c.cfg.MoveTargetNamespace != "" + + objs := []client.Object{ + &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: c.cfg.TLSSecretName, Namespace: gwNS}, + }, + &gapi.Gateway{ + TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: prefix + "-gateway", Namespace: gwNS}, + }, + &gapi.HTTPRoute{ + TypeMeta: metav1.TypeMeta{Kind: "HTTPRoute", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: prefix + "-route", Namespace: gwNS}, + }, + } + + if !move { + objs = append(objs, + &envoyapi.Backend{ + TypeMeta: metav1.TypeMeta{Kind: BackendKind, APIVersion: "gateway.envoyproxy.io/v1alpha1"}, + ObjectMeta: metav1.ObjectMeta{Name: prefix + "-backend", Namespace: bkNS}, + }, + ) + } + + if gwNS != bkNS && (!move || c.cfg.MoveTargetNamespace == bkNS) { + objs = append(objs, + &gapi.ReferenceGrant{ + TypeMeta: metav1.TypeMeta{Kind: "ReferenceGrant", APIVersion: "gateway.networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: prefix + "-allow-gateway", Namespace: bkNS}, + }, + ) + } + + if c.cfg.Enterprise && gwNS == bkNS { + // Mirrors the main path: these are only rendered when the Gateway is + // in the backend namespace. In a custom namespace the SA and + // RoleBinding belong to the GatewayAPI controller's per-namespace + // lifecycle — deleting them here could break other Gateways in that + // namespace. + objs = append(objs, + rgatewayapi.GatewayNamespaceServiceAccount(gwNS), + rgatewayapi.GatewayNamespaceRoleBinding(gwNS), + &v3.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "projectcalico.org/v3"}, + ObjectMeta: metav1.ObjectMeta{ + Name: networkpolicy.CalicoComponentPolicyPrefix + prefix + "-gateway-proxy", + Namespace: gwNS, + }, + }, + ) + } + + return nil, objs +} diff --git a/pkg/render/gateway/component_test.go b/pkg/render/gateway/component_test.go new file mode 100644 index 0000000000..cc59056bbf --- /dev/null +++ b/pkg/render/gateway/component_test.go @@ -0,0 +1,465 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gateway_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + envoyapi "github.com/envoyproxy/gateway/api/v1alpha1" + v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gapi "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/render/common/networkpolicy" + "github.com/tigera/operator/pkg/render/gateway" + "github.com/tigera/operator/pkg/tls/certificatemanagement" +) + +var _ = Describe("Gateway component render", func() { + const ( + gwNS = "calico-system" + bkNS = "calico-system" + hostname = "manager.example.com" + prefix = "calico-manager" + className = "calico-gateway" + svcName = "calico-manager" + svcPort = int32(9443) + caBundleCM = "tigera-ca-bundle" + tlsName = "calico-manager-gateway-tls" + ) + + var ( + cfg *gateway.Configuration + toCreate []client.Object + toDelete []client.Object + ) + + BeforeEach(func() { + secret, err := certificatemanagement.CreateSelfSignedSecret(tlsName, common.OperatorNamespace(), tlsName, nil) + Expect(err).NotTo(HaveOccurred()) + kp := certificatemanagement.NewKeyPair(secret, []string{""}, "") + + cfg = &gateway.Configuration{ + Hostname: hostname, + GatewayNamespace: gwNS, + GatewayClassName: className, + BackendServiceName: svcName, + BackendPort: svcPort, + BackendNamespace: bkNS, + BackendCABundleConfigMapName: caBundleCM, + TLSKeyPair: kp, + ResourcePrefix: prefix, + Enterprise: true, + OpenShift: false, + } + }) + + Context("same namespace (gateway == backend)", func() { + JustBeforeEach(func() { + comp := gateway.Component(cfg) + toCreate, toDelete = comp.Objects() + }) + + It("returns objects to create and nothing to delete", func() { + Expect(toDelete).To(BeNil()) + Expect(toCreate).NotTo(BeEmpty()) + }) + + It("does not include cross-namespace resources", func() { + for _, obj := range toCreate { + if _, ok := obj.(*gapi.ReferenceGrant); ok { + Fail("ReferenceGrant should not be rendered when gateway and backend share a namespace") + } + } + }) + + It("renders a TLS secret in the gateway namespace", func() { + secret := findObject[*corev1.Secret](toCreate, tlsName, gwNS) + Expect(secret).NotTo(BeNil()) + Expect(secret.Type).To(Equal(corev1.SecretTypeTLS)) + }) + + It("labels the Gateway for label-driven cleanup", func() { + gw := findObject[*gapi.Gateway](toCreate, prefix+"-gateway", gwNS) + Expect(gw).NotTo(BeNil()) + Expect(gw.Labels).To(HaveKeyWithValue(gateway.GatewayLabel, prefix)) + }) + + It("renders a Gateway with the correct listener", func() { + gw := findObject[*gapi.Gateway](toCreate, prefix+"-gateway", gwNS) + Expect(gw).NotTo(BeNil()) + Expect(string(gw.Spec.GatewayClassName)).To(Equal(className)) + Expect(gw.Spec.Listeners).To(HaveLen(1)) + Expect(gw.Spec.Listeners[0].Protocol).To(Equal(gapi.HTTPSProtocolType)) + Expect(gw.Spec.Listeners[0].Port).To(Equal(gapi.PortNumber(443))) + Expect(*gw.Spec.Listeners[0].TLS.Mode).To(Equal(gapi.TLSModeTerminate)) + }) + + It("renders an HTTPRoute targeting the Backend", func() { + route := findObject[*gapi.HTTPRoute](toCreate, prefix+"-route", gwNS) + Expect(route).NotTo(BeNil()) + Expect(route.Spec.Rules).To(HaveLen(1)) + backendRef := route.Spec.Rules[0].BackendRefs[0] + Expect(string(backendRef.Name)).To(Equal(prefix + "-backend")) + Expect(*backendRef.Kind).To(Equal(gapi.Kind("Backend"))) + }) + + It("renders a Backend with TLS to the service", func() { + backend := findObject[*envoyapi.Backend](toCreate, prefix+"-backend", bkNS) + Expect(backend).NotTo(BeNil()) + Expect(backend.Spec.Endpoints).To(HaveLen(1)) + Expect(backend.Spec.Endpoints[0].FQDN.Hostname).To(Equal(svcName + "." + bkNS + ".svc")) + Expect(backend.Spec.Endpoints[0].FQDN.Port).To(Equal(svcPort)) + Expect(backend.Spec.TLS).NotTo(BeNil()) + Expect(backend.Spec.TLS.CACertificateRefs).To(HaveLen(1)) + Expect(string(backend.Spec.TLS.CACertificateRefs[0].Name)).To(Equal(caBundleCM)) + }) + }) + + Context("Enterprise resources", func() { + JustBeforeEach(func() { + comp := gateway.Component(cfg) + toCreate, toDelete = comp.Objects() + }) + + It("renders SA, RoleBinding, and NetworkPolicy when Enterprise is true", func() { + sa := findObject[*corev1.ServiceAccount](toCreate, "waf-http-filter", gwNS) + Expect(sa).NotTo(BeNil()) + np := findObject[*v3.NetworkPolicy](toCreate, networkpolicy.CalicoComponentPolicyPrefix+prefix+"-gateway-proxy", gwNS) + Expect(np).NotTo(BeNil()) + }) + + It("includes IPv4 and IPv6 ingress rules in the proxy NetworkPolicy", func() { + np := findObject[*v3.NetworkPolicy](toCreate, networkpolicy.CalicoComponentPolicyPrefix+prefix+"-gateway-proxy", gwNS) + Expect(np).NotTo(BeNil()) + Expect(np.Spec.Ingress).To(HaveLen(2)) + Expect(np.Spec.Ingress[0].Source.Nets).To(ConsistOf("0.0.0.0/0")) + Expect(np.Spec.Ingress[0].Destination.Ports).To(Equal(networkpolicy.Ports(10443))) + Expect(np.Spec.Ingress[1].Source.Nets).To(ConsistOf("::/0")) + Expect(np.Spec.Ingress[1].Destination.Ports).To(Equal(networkpolicy.Ports(10443))) + }) + + Context("when Enterprise is false", func() { + BeforeEach(func() { + cfg.Enterprise = false + }) + + It("skips SA and NetworkPolicy", func() { + for _, obj := range toCreate { + if _, ok := obj.(*corev1.ServiceAccount); ok { + Fail("ServiceAccount should not be rendered when Enterprise is false") + } + if _, ok := obj.(*v3.NetworkPolicy); ok { + Fail("NetworkPolicy should not be rendered when Enterprise is false") + } + } + }) + }) + }) + + Context("cross-namespace (gateway != backend)", func() { + BeforeEach(func() { + cfg.GatewayNamespace = "custom-gateway-ns" + }) + + JustBeforeEach(func() { + comp := gateway.Component(cfg) + toCreate, toDelete = comp.Objects() + }) + + It("includes ReferenceGrant in the backend namespace", func() { + rg := findObject[*gapi.ReferenceGrant](toCreate, prefix+"-allow-gateway", bkNS) + Expect(rg).NotTo(BeNil()) + Expect(rg.Spec.From).To(HaveLen(1)) + Expect(string(rg.Spec.From[0].Namespace)).To(Equal("custom-gateway-ns")) + }) + + It("does not include operator Roles", func() { + for _, obj := range toCreate { + if _, ok := obj.(*rbacv1.Role); ok { + Fail("Role should not be rendered — operator uses ClusterRole") + } + } + }) + + It("leaves SA, RoleBinding, and NetworkPolicy to the GatewayAPI controller even when Enterprise is true", func() { + for _, obj := range toCreate { + switch obj.(type) { + case *corev1.ServiceAccount: + Fail("ServiceAccount should not be rendered for a custom gateway namespace") + case *rbacv1.RoleBinding: + Fail("RoleBinding should not be rendered for a custom gateway namespace") + case *v3.NetworkPolicy: + Fail("NetworkPolicy should not be rendered for a custom gateway namespace") + } + } + }) + + It("renders the TLS secret after the Gateway", func() { + gatewayIdx, secretIdx := -1, -1 + for i, obj := range toCreate { + switch obj.(type) { + case *gapi.Gateway: + gatewayIdx = i + case *corev1.Secret: + secretIdx = i + } + } + Expect(gatewayIdx).To(BeNumerically(">=", 0)) + Expect(secretIdx).To(BeNumerically(">", gatewayIdx), + "the Gateway must be created before the TLS secret so the GatewayAPI controller can grant the operator secret access in the custom namespace") + }) + }) + + Context("OpenShift", func() { + BeforeEach(func() { + cfg.OpenShift = true + }) + + JustBeforeEach(func() { + comp := gateway.Component(cfg) + toCreate, toDelete = comp.Objects() + }) + + It("includes OpenShift DNS egress rules in NetworkPolicy", func() { + np := findObject[*v3.NetworkPolicy](toCreate, networkpolicy.CalicoComponentPolicyPrefix+prefix+"-gateway-proxy", gwNS) + Expect(np).NotTo(BeNil()) + Expect(len(np.Spec.Egress)).To(BeNumerically(">", 2)) + }) + }) + + Context("component interface", func() { + It("implements ResolveImages without error", func() { + Expect(gateway.Component(cfg).ResolveImages(nil)).To(Succeed()) + }) + + It("reports Ready", func() { + Expect(gateway.Component(cfg).Ready()).To(BeTrue()) + }) + + It("reports Linux OS type", func() { + Expect(string(gateway.Component(cfg).SupportedOSType())).To(Equal("linux")) + }) + }) + + Context("Gateway listener hostname", func() { + JustBeforeEach(func() { + comp := gateway.Component(cfg) + toCreate, toDelete = comp.Objects() + }) + + It("sets the hostname on the listener", func() { + gw := findObject[*gapi.Gateway](toCreate, prefix+"-gateway", gwNS) + Expect(gw.Spec.Listeners[0].Hostname).To(Equal(ptr.To(gapi.Hostname(hostname)))) + }) + + It("sets AllowedRoutes to Same namespace", func() { + gw := findObject[*gapi.Gateway](toCreate, prefix+"-gateway", gwNS) + Expect(*gw.Spec.Listeners[0].AllowedRoutes.Namespaces.From).To(Equal(gapi.NamespacesFromSame)) + }) + }) +}) + +var _ = Describe("Gateway deletion component", func() { + const ( + gwNS = "calico-system" + bkNS = "calico-system" + prefix = "calico-manager" + tlsSecret = "calico-manager-gateway-tls" + ) + + var ( + delCfg *gateway.DeletionConfiguration + toCreate []client.Object + toDelete []client.Object + ) + + BeforeEach(func() { + delCfg = &gateway.DeletionConfiguration{ + ResourcePrefix: prefix, + GatewayNamespace: gwNS, + BackendNamespace: bkNS, + TLSSecretName: tlsSecret, + Enterprise: true, + } + }) + + JustBeforeEach(func() { + comp := gateway.DeletionComponent(delCfg) + toCreate, toDelete = comp.Objects() + }) + + Context("same namespace", func() { + It("returns all objects in objsToDelete and nothing in objsToCreate", func() { + Expect(toCreate).To(BeNil()) + Expect(toDelete).NotTo(BeEmpty()) + }) + + It("targets the correct resource names", func() { + names := objectNames(toDelete) + Expect(names).To(ContainElements( + prefix+"-gateway", + prefix+"-route", + prefix+"-backend", + tlsSecret, + )) + }) + + It("does not include cross-namespace resources", func() { + for _, obj := range toDelete { + if _, ok := obj.(*gapi.ReferenceGrant); ok { + Fail("ReferenceGrant should not appear when namespaces match") + } + } + }) + + It("includes Enterprise resources", func() { + sa := findObject[*corev1.ServiceAccount](toDelete, "waf-http-filter", gwNS) + Expect(sa).NotTo(BeNil()) + np := findObject[*v3.NetworkPolicy](toDelete, networkpolicy.CalicoComponentPolicyPrefix+prefix+"-gateway-proxy", gwNS) + Expect(np).NotTo(BeNil()) + }) + }) + + Context("Enterprise false", func() { + BeforeEach(func() { + delCfg.Enterprise = false + }) + + It("skips Enterprise resources", func() { + for _, obj := range toDelete { + if _, ok := obj.(*corev1.ServiceAccount); ok { + Fail("ServiceAccount should not appear when Enterprise is false") + } + if _, ok := obj.(*v3.NetworkPolicy); ok { + Fail("NetworkPolicy should not appear when Enterprise is false") + } + } + }) + }) + + Context("cross-namespace", func() { + BeforeEach(func() { + delCfg.GatewayNamespace = "custom-gateway-ns" + }) + + It("includes ReferenceGrant in the backend namespace", func() { + rg := findObject[*gapi.ReferenceGrant](toDelete, prefix+"-allow-gateway", bkNS) + Expect(rg).NotTo(BeNil()) + }) + + It("does not delete GatewayAPI-controller-owned resources even when Enterprise is true", func() { + for _, obj := range toDelete { + switch obj.(type) { + case *corev1.ServiceAccount: + Fail("ServiceAccount in a custom namespace belongs to the GatewayAPI controller and must not be deleted here") + case *rbacv1.RoleBinding: + Fail("RoleBinding in a custom namespace belongs to the GatewayAPI controller and must not be deleted here") + case *v3.NetworkPolicy: + Fail("NetworkPolicy is not rendered for a custom namespace and must not be deleted here") + } + } + }) + }) + + Context("namespace move cleanup", func() { + BeforeEach(func() { + delCfg.GatewayNamespace = "old-ns" + delCfg.MoveTargetNamespace = "new-ns" + }) + + It("deletes the gateway-namespace objects", func() { + Expect(findObject[*gapi.Gateway](toDelete, prefix+"-gateway", "old-ns")).NotTo(BeNil()) + Expect(findObject[*gapi.HTTPRoute](toDelete, prefix+"-route", "old-ns")).NotTo(BeNil()) + Expect(findObject[*corev1.Secret](toDelete, tlsSecret, "old-ns")).NotTo(BeNil()) + }) + + It("never deletes the Backend on a move", func() { + for _, obj := range toDelete { + if _, ok := obj.(*envoyapi.Backend); ok { + Fail("Backend must not be deleted on a namespace move — the new render still routes to it") + } + } + }) + + It("keeps the ReferenceGrant when moving between custom namespaces", func() { + Expect(findObject[*gapi.ReferenceGrant](toDelete, prefix+"-allow-gateway", bkNS)).To(BeNil()) + }) + + Context("moving into the backend namespace", func() { + BeforeEach(func() { + delCfg.MoveTargetNamespace = bkNS + }) + + It("deletes the ReferenceGrant, which is no longer rendered", func() { + Expect(findObject[*gapi.ReferenceGrant](toDelete, prefix+"-allow-gateway", bkNS)).NotTo(BeNil()) + }) + }) + + Context("moving out of the backend namespace", func() { + BeforeEach(func() { + delCfg.GatewayNamespace = bkNS + delCfg.MoveTargetNamespace = "new-ns" + }) + + It("deletes the Enterprise SA, RoleBinding, and NetworkPolicy from the backend namespace", func() { + Expect(findObject[*corev1.ServiceAccount](toDelete, "waf-http-filter", bkNS)).NotTo(BeNil()) + np := findObject[*v3.NetworkPolicy](toDelete, networkpolicy.CalicoComponentPolicyPrefix+prefix+"-gateway-proxy", bkNS) + Expect(np).NotTo(BeNil()) + }) + + It("still keeps the Backend", func() { + for _, obj := range toDelete { + if _, ok := obj.(*envoyapi.Backend); ok { + Fail("Backend must not be deleted on a namespace move") + } + } + }) + }) + }) + + Context("component interface", func() { + It("implements ResolveImages without error", func() { + Expect(gateway.DeletionComponent(delCfg).ResolveImages(nil)).To(Succeed()) + }) + + It("reports Ready", func() { + Expect(gateway.DeletionComponent(delCfg).Ready()).To(BeTrue()) + }) + }) +}) + +func findObject[T client.Object](objs []client.Object, name, ns string) T { + for _, obj := range objs { + if t, ok := obj.(T); ok && obj.GetName() == name && obj.GetNamespace() == ns { + return t + } + } + var zero T + return zero +} + +func objectNames(objs []client.Object) []string { + names := make([]string, len(objs)) + for i, obj := range objs { + names[i] = obj.GetName() + } + return names +} diff --git a/pkg/render/gateway/suite_test.go b/pkg/render/gateway/suite_test.go new file mode 100644 index 0000000000..3bfe1cd0a2 --- /dev/null +++ b/pkg/render/gateway/suite_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gateway_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestRender(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + suiteConfig, reporterConfig := ginkgo.GinkgoConfiguration() + reporterConfig.JUnitReport = "../../../report/ut/gateway_render_suite.xml" + ginkgo.RunSpecs(t, "pkg/render/gateway Suite", suiteConfig, reporterConfig) +}