Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pkg/controller/whisker/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func newReconciler(
clusterDomain: opts.ClusterDomain,
variant: opts.Variant,
ext: opts.Extensions.Whisker(),
gwExt: opts.Extensions.UIGateway(),
}
c.status.Run(opts.ShutdownContext)
return c
Expand All @@ -158,6 +159,7 @@ type Reconciler struct {
clusterDomain string
variant operatorv1.ProductVariant
ext extensions.WhiskerExtension
gwExt extensions.UIGatewayExtension
}

// Reconcile reads that state of the cluster for a Whisker object and makes changes based on the
Expand All @@ -177,7 +179,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (

// Gateway objects are garbage-collected with the CR, but a namespace the
// operator created for them has no owner reference and must be torn down here.
gwHelper := uigateway.NewHelper(r.cli, uigateway.Config{
gwHelper := uigateway.NewHelper(r.cli, r.gwExt, uigateway.Config{
ResourcePrefix: whisker.GatewayResourcePrefix,
TLSSecretName: whisker.GatewayTLSSecretName,
BackendNamespace: whisker.WhiskerNamespace,
Expand Down Expand Up @@ -305,7 +307,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (
cfg.ClusterID = clusterInfo.Spec.ClusterGUID
}

gwHelper := uigateway.NewHelper(r.cli, uigateway.Config{
gwHelper := uigateway.NewHelper(r.cli, r.gwExt, uigateway.Config{
ResourcePrefix: whisker.GatewayResourcePrefix,
TLSSecretName: whisker.GatewayTLSSecretName,
BackendNamespace: whisker.WhiskerNamespace,
Expand Down
5 changes: 2 additions & 3 deletions pkg/enterprise/controller/manager/manager_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -753,14 +753,13 @@ func (r *ReconcileManager) Reconcile(ctx context.Context, request reconcile.Requ
// observed on the cluster.
var gatewayComponents []render.Component
var gatewayTLSKeyPair certificatemanagement.KeyPairInterface
gwHelper := uigateway.NewHelper(r.client, uigateway.Config{
gwHelper := uigateway.NewHelper(r.client, r.opts.Extensions.UIGateway(), uigateway.Config{
ResourcePrefix: ManagerGatewayResourcePrefix,
TLSSecretName: ManagerGatewayTLSSecretName,
BackendNamespace: helper.InstallNamespace(),
BackendServiceName: render.ManagerServiceName,
BackendPort: render.ManagerPort,
BackendCABundleConfigMapName: certificatemanagement.TrustedCertConfigMapName,
ExtraProxyObjects: euigateway.ProxyObjects(helper.InstallNamespace()),
Provider: r.opts.DetectedProvider,
Azure: installationSpec.Azure,
})
Expand Down Expand Up @@ -941,7 +940,7 @@ func (r *ReconcileManager) resolveAdditionalTunnelCert(

const (
ManagerGatewayTLSSecretName = "calico-manager-gateway-tls"
ManagerGatewayResourcePrefix = "calico-manager"
ManagerGatewayResourcePrefix = euigateway.ManagerGatewayResourcePrefix
)

// resolveGateway validates the Manager spec.ingressGateway configuration, resolves the
Expand Down
2 changes: 2 additions & 0 deletions pkg/enterprise/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/tigera/operator/pkg/enterprise/istio"
eoptions "github.com/tigera/operator/pkg/enterprise/options"
"github.com/tigera/operator/pkg/enterprise/tiers"
"github.com/tigera/operator/pkg/enterprise/uigateway"
"github.com/tigera/operator/pkg/enterprise/whisker"
"github.com/tigera/operator/pkg/enterprise/windows"
"github.com/tigera/operator/pkg/extensions"
Expand Down Expand Up @@ -85,6 +86,7 @@ func New(variant operatorv1.ProductVariant, o eoptions.Options) extensions.Exten
set.Goldmane = goldmane.New(variant)
set.Whisker = whisker.New(variant)
set.GatewayAPI = gatewayapi.New(variant)
set.UIGateway = uigateway.New()
case variant == operatorv1.Calico:
// Clean up what a prior Enterprise installation left behind.
set.APIServer = apiserver.CalicoCleanup{}
Expand Down
27 changes: 24 additions & 3 deletions pkg/enterprise/uigateway/uigateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,38 @@
// limitations under the License.

// Package uigateway layers the Calico Enterprise pieces onto a UI component's
// ingress gateway. The common gateway code carries no variant knowledge; an
// Enterprise controller passes what this package builds, and an OSS controller
// passes nothing.
// ingress gateway. The common gateway code carries no variant knowledge; it
// reaches this package only through the registered UIGateway extension.
package uigateway

import (
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/tigera/operator/pkg/extensions"
rgatewayapi "github.com/tigera/operator/pkg/render/gatewayapi"
)

// ManagerGatewayResourcePrefix names the Manager's CIG resources; the OSS
// counterpart is whisker.GatewayResourcePrefix.
const ManagerGatewayResourcePrefix = "calico-manager"

// Extension is the Calico Enterprise behavior for UI ingress gateways.
type Extension struct{}

var _ extensions.UIGatewayExtension = Extension{}

// New returns the UI gateway extension for Calico Enterprise.
func New() Extension { return Extension{} }

// ProxyObjects returns the WAF filter objects for the Manager gateway; other
// components get nothing.
func (Extension) ProxyObjects(resourcePrefix, namespace string) []client.Object {
if resourcePrefix != ManagerGatewayResourcePrefix {
return nil
}
return ProxyObjects(namespace)
}

// ProxyObjects returns the Enterprise-only objects that run beside a UI
// gateway's Envoy proxy in the backend namespace: the WAF HTTP filter's
// ServiceAccount and the RoleBinding giving it Gateway API reads.
Expand Down
8 changes: 8 additions & 0 deletions pkg/extensions/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Set struct {
Goldmane GoldmaneExtension
Whisker WhiskerExtension
GatewayAPI GatewayAPIExtension
UIGateway UIGatewayExtension

// Startup is the variant's hook into operator startup rather than into a controller.
Startup StartupExtension
Expand Down Expand Up @@ -114,6 +115,13 @@ func (e Extensions) GatewayAPI() GatewayAPIExtension {
return e.set.GatewayAPI
}

func (e Extensions) UIGateway() UIGatewayExtension {
if e.set.UIGateway == nil {
return noopUIGateway{}
}
return e.set.UIGateway
}

func (e Extensions) Startup() StartupExtension {
if e.set.Startup == nil {
return noopStartup{}
Expand Down
33 changes: 33 additions & 0 deletions pkg/extensions/uigateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 extensions

import (
"sigs.k8s.io/controller-runtime/pkg/client"
)

// UIGatewayExtension is the variant's hook into the ingress gateway a UI
// component (Manager, Whisker) exposes itself through.
type UIGatewayExtension interface {
// ProxyObjects returns the variant's objects rendered beside the named
// component's gateway proxy in the backend namespace, or nil when the
// variant adds none.
ProxyObjects(resourcePrefix, namespace string) []client.Object
}

// noopUIGateway runs the core operator's behavior unchanged.
type noopUIGateway struct{}

func (noopUIGateway) ProxyObjects(string, string) []client.Object { return nil }
55 changes: 38 additions & 17 deletions pkg/render/gateway/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ const (
// once no labeled Gateway from any component remains, so components that
// share a namespace never delete it out from under each other.
GatewayNamespaceLabel = "operator.tigera.io/gateway-namespace"

// RBACFinalizer holds a gateway access Role and RoleBinding until the
// gateway resources they authorize deleting are gone, so teardown cannot
// strip the operator's own write grant first and orphan them. The
// uigateway helper removes it once those resources no longer exist.
RBACFinalizer = "operator.tigera.io/gateway-rbac-finalizer"
)

// Configuration holds everything the shared gateway component needs to render
Expand Down Expand Up @@ -162,6 +168,16 @@ func GatewayName(prefix string) string { return prefix + "-gateway" }
// RouteName is the HTTPRoute object name for a component's resource prefix.
func RouteName(prefix string) string { return prefix + "-route" }

// BackendName is the Envoy Gateway Backend object name for a component's resource prefix.
func BackendName(prefix string) string { return prefix + "-backend" }

// ReferenceGrantName is the ReferenceGrant object name for a component's resource prefix.
func ReferenceGrantName(prefix string) string { return prefix + "-allow-gateway" }

// ListenerName is the Gateway's HTTPS listener name for a component's resource
// prefix. The HTTPRoute's parentRef sectionName must match it to attach.
func ListenerName(prefix string) string { return prefix + "-https" }

// gatewayAccess grants the operator the write permissions needed in the gateway namespace; the
// cluster-wide ClusterRole keeps the reads.
func (c *gatewayComponent) gatewayAccess() (*rbacv1.Role, *rbacv1.RoleBinding) {
Expand Down Expand Up @@ -191,22 +207,25 @@ func (c *gatewayComponent) backendAccess() (*rbacv1.Role, *rbacv1.RoleBinding) {
}

// access builds a Role with rules and a RoleBinding tying it to the operator's
// own ServiceAccount, the identity that renders the gateway resources.
// own ServiceAccount, the identity that renders the gateway resources. Both
// carry RBACFinalizer so the grant outlives the resources it covers.
func (c *gatewayComponent) access(name, namespace string, rules []rbacv1.PolicyRule) (*rbacv1.Role, *rbacv1.RoleBinding) {
return &rbacv1.Role{
TypeMeta: metav1.TypeMeta{Kind: "Role", APIVersion: "rbac.authorization.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{GatewayLabel: c.cfg.ResourcePrefix},
Name: name,
Namespace: namespace,
Labels: map[string]string{GatewayLabel: c.cfg.ResourcePrefix},
Finalizers: []string{RBACFinalizer},
},
Rules: rules,
}, &rbacv1.RoleBinding{
TypeMeta: metav1.TypeMeta{Kind: "RoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{GatewayLabel: c.cfg.ResourcePrefix},
Name: name,
Namespace: namespace,
Labels: map[string]string{GatewayLabel: c.cfg.ResourcePrefix},
Finalizers: []string{RBACFinalizer},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Expand All @@ -230,7 +249,7 @@ func (c *gatewayComponent) tlsSecret() *corev1.Secret {
}

func (c *gatewayComponent) gateway() *gapi.Gateway {
listenerName := gapi.SectionName(c.cfg.ResourcePrefix + "-https")
listenerName := gapi.SectionName(ListenerName(c.cfg.ResourcePrefix))
hostname := gapi.Hostname(c.cfg.Hostname)
tlsSecretName := c.cfg.TLSKeyPair.GetName()

Expand Down Expand Up @@ -272,8 +291,8 @@ func (c *gatewayComponent) gateway() *gapi.Gateway {

func (c *gatewayComponent) httpRoute() *gapi.HTTPRoute {
gatewayName := gapi.ObjectName(GatewayName(c.cfg.ResourcePrefix))
sectionName := gapi.SectionName(c.cfg.ResourcePrefix + "-https")
backendName := gapi.ObjectName(c.cfg.ResourcePrefix + "-backend")
sectionName := gapi.SectionName(ListenerName(c.cfg.ResourcePrefix))
backendName := gapi.ObjectName(BackendName(c.cfg.ResourcePrefix))
backendNS := gapi.Namespace(c.cfg.BackendNamespace)
group := gapi.Group(EnvoyGatewayGroup)

Expand Down Expand Up @@ -325,7 +344,7 @@ func (c *gatewayComponent) backend() *envoyapi.Backend {
return &envoyapi.Backend{
TypeMeta: metav1.TypeMeta{Kind: BackendKind, APIVersion: "gateway.envoyproxy.io/v1alpha1"},
ObjectMeta: metav1.ObjectMeta{
Name: c.cfg.ResourcePrefix + "-backend",
Name: BackendName(c.cfg.ResourcePrefix),
Namespace: c.cfg.BackendNamespace,
},
Spec: envoyapi.BackendSpec{
Expand Down Expand Up @@ -356,12 +375,12 @@ func (c *gatewayComponent) backend() *envoyapi.Backend {
// and CRDManagementPreferExisting leaves it alone). v1beta1 is still the
// storage version as of Gateway API v1.6.
func (c *gatewayComponent) referenceGrant() *gapiv1b1.ReferenceGrant {
backendName := gapi.ObjectName(c.cfg.ResourcePrefix + "-backend")
backendName := gapi.ObjectName(BackendName(c.cfg.ResourcePrefix))

return &gapiv1b1.ReferenceGrant{
TypeMeta: metav1.TypeMeta{Kind: "ReferenceGrant", APIVersion: "gateway.networking.k8s.io/v1beta1"},
ObjectMeta: metav1.ObjectMeta{
Name: c.cfg.ResourcePrefix + "-allow-gateway",
Name: ReferenceGrantName(c.cfg.ResourcePrefix),
Namespace: c.cfg.BackendNamespace,
},
Spec: gapiv1b1.ReferenceGrantSpec{
Expand Down Expand Up @@ -506,11 +525,11 @@ func (c *gatewayDeletionComponent) Objects() (objsToCreate, objsToDelete []clien
objs = append(objs,
&envoyapi.Backend{
TypeMeta: metav1.TypeMeta{Kind: BackendKind, APIVersion: "gateway.envoyproxy.io/v1alpha1"},
ObjectMeta: metav1.ObjectMeta{Name: prefix + "-backend", Namespace: bkNS},
ObjectMeta: metav1.ObjectMeta{Name: BackendName(prefix), Namespace: bkNS},
},
&gapiv1b1.ReferenceGrant{
TypeMeta: metav1.TypeMeta{Kind: "ReferenceGrant", APIVersion: "gateway.networking.k8s.io/v1beta1"},
ObjectMeta: metav1.ObjectMeta{Name: prefix + "-allow-gateway", Namespace: bkNS},
ObjectMeta: metav1.ObjectMeta{Name: ReferenceGrantName(prefix), Namespace: bkNS},
},
)
}
Expand All @@ -536,8 +555,10 @@ func (c *gatewayDeletionComponent) Objects() (objsToCreate, objsToDelete []clien
ObjectMeta: metav1.ObjectMeta{Name: GatewayName(prefix), Namespace: staleNS},
})

// The grants go after the resources they permit deleting. The backend grant
// is dropped only by the backend namespace's own component.
// The grants carry RBACFinalizer, so these deletes only mark them; they
// hold the operator's write access until the uigateway helper clears the
// finalizer once the resources above are gone. The backend grant is
// dropped only by the backend namespace's own component.
objs = append(objs, c.roleBinding(staleNS, gatewayAccessSuffix), c.role(staleNS, gatewayAccessSuffix))
if staleNS == bkNS && c.cfg.TargetNamespace == "" {
objs = append(objs, c.roleBinding(bkNS, backendAccessSuffix), c.role(bkNS, backendAccessSuffix))
Expand Down
11 changes: 11 additions & 0 deletions pkg/render/gateway/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ var _ = Describe("Gateway component render", func() {
Expect(gw.Labels).To(HaveKeyWithValue(gateway.GatewayLabel, prefix))
})

It("stamps the access grants with the cleanup finalizer", func() {
for _, name := range []string{prefix + "-ingressgateway-access", prefix + "-ingressgateway-backend-access"} {
role := findObject[*rbacv1.Role](toCreate, name, bkNS)
Expect(role).NotTo(BeNil(), "expected access Role "+name)
Expect(role.Finalizers).To(ConsistOf(gateway.RBACFinalizer))
binding := findObject[*rbacv1.RoleBinding](toCreate, name, bkNS)
Expect(binding).NotTo(BeNil(), "expected access RoleBinding "+name)
Expect(binding.Finalizers).To(ConsistOf(gateway.RBACFinalizer))
}
})

It("renders a Gateway with the correct listener", func() {
gw := findObject[*gapi.Gateway](toCreate, prefix+"-gateway", gwNS)
Expect(gw).NotTo(BeNil())
Expand Down
Loading