diff --git a/api/v1/ingress_gateway_types.go b/api/v1/ingress_gateway_types.go new file mode 100644 index 0000000000..a4ec51be43 --- /dev/null +++ b/api/v1/ingress_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 + +// IngressGatewaySpec 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 IngressGatewaySpec 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 *IngressGatewaySpec) 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 d6e7223844..058688ea16 100644 --- a/api/v1/manager_types.go +++ b/api/v1/manager_types.go @@ -26,6 +26,13 @@ type ManagerSpec struct { // ManagerDeployment configures the Manager Deployment. // +optional ManagerDeployment *ManagerDeployment `json:"managerDeployment,omitempty"` + + // IngressGateway 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 + IngressGateway *IngressGatewaySpec `json:"ingressGateway,omitempty"` } // ManagerDeployment is the configuration for the Manager Deployment. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 563cb0247f..a0a6435b33 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -5111,6 +5111,31 @@ func (in *Indices) DeepCopy() *Indices { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IngressGatewaySpec) DeepCopyInto(out *IngressGatewaySpec) { + *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 IngressGatewaySpec. +func (in *IngressGatewaySpec) DeepCopy() *IngressGatewaySpec { + if in == nil { + return nil + } + out := new(IngressGatewaySpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Installation) DeepCopyInto(out *Installation) { *out = *in @@ -7131,6 +7156,11 @@ func (in *ManagerSpec) DeepCopyInto(out *ManagerSpec) { *out = new(ManagerDeployment) (*in).DeepCopyInto(*out) } + if in.IngressGateway != nil { + in, out := &in.IngressGateway, &out.IngressGateway + *out = new(IngressGatewaySpec) + (*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 583f5aad35..95d3f6865a 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,13 +34,16 @@ 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" operatorv1 "github.com/tigera/operator/api/v1" "github.com/tigera/operator/pkg/common" "github.com/tigera/operator/pkg/controller/certificatemanager" + "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 ( relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/networkpolicy" "github.com/tigera/operator/pkg/render/common/rbacmanagement" + 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" @@ -155,6 +166,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) @@ -181,6 +195,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) } @@ -740,6 +782,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.ingressGateway 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.IngressGateway != nil { + r.status.SetDegraded(operatorv1.InvalidConfigurationError, "spec.ingressGateway is not supported in multi-tenant clusters", nil, logc) + return reconcile.Result{}, nil + } + } else if instance.Spec.IngressGateway != 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.IngressGateway.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, gatewayCRDsPresent, 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 gatewayCRDsPresent && !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, @@ -749,14 +872,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, }), } @@ -764,6 +881,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) @@ -771,6 +890,16 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ } } + if instance.Spec.IngressGateway != 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.IngressGateway.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, @@ -875,3 +1004,228 @@ 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. +// The bool reports whether the Gateway API CRDs are installed at all: when +// they are not, no gateway object can exist and cleanup must be skipped — +// even a Delete call fails against a kind the API server does not serve. +func (r *ReconcileManager) managerGatewayNamespaces(ctx context.Context) ([]string, bool, error) { + if r.opts.MultiTenant { + return nil, false, 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, false, nil + } + return nil, false, 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, true, nil +} + +// resolveGateway validates the Manager spec.ingressGateway 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.IngressGateway + + // 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.ingressGateway.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.ingressGateway.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.ingressGateway.gatewayClassName or the GatewayAPI CR's configured classes. +func resolveGatewayClassName(gw *operatorv1.IngressGatewaySpec, 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.ingressGateway.gatewayClassName to select one") + } +} diff --git a/pkg/controller/manager/manager_controller_test.go b/pkg/controller/manager/manager_controller_test.go index 5c2d47d93c..b1be3c3828 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" @@ -1234,6 +1235,28 @@ var _ = Describe("Manager controller tests", func() { Expect(err).NotTo(HaveOccurred()) }) + It("should degrade when spec.ingressGateway 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.IngressGateway = &operatorv1.IngressGatewaySpec{Hostname: "manager.example.com"} + Expect(c.Update(ctx, manager)).NotTo(HaveOccurred()) + + mockStatus.On("SetDegraded", operatorv1.InvalidConfigurationError, "spec.ingressGateway 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.ingressGateway 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()) @@ -1486,6 +1509,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"})) + }) + }) }) // failingGateReadClient fails the read of the gate ConfigMap and passes everything else diff --git a/pkg/imports/crds/operator/operator.tigera.io_managers.yaml b/pkg/imports/crds/operator/operator.tigera.io_managers.yaml index 5e77333404..f2bf1ea5f8 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: + ingressGateway: + description: |- + IngressGateway 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..3b0301d52f --- /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.ingressGateway 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) +}