From 7dcf4d2a6582fadb3ccf604a1f16d2b8046e866a Mon Sep 17 00:00:00 2001 From: Casey Davenport Date: Thu, 20 Aug 2026 16:31:45 -0400 Subject: [PATCH] Move Enterprise gateway API rendering behind the extensions boundary The base gateway API render produces the same objects for both variants. The Enterprise extension supplies the tigera envoy images, the WAF filter's RBAC and per-namespace identity, and the l7-log-collector alongside envoy. --- .../gatewayapi/gatewayapi_controller.go | 56 +- .../gatewayapi/gatewayapi_controller_test.go | 8 +- pkg/enterprise/gatewayapi/envoyproxy.go | 319 +++++++ pkg/enterprise/gatewayapi/extension.go | 171 ++++ pkg/enterprise/gatewayapi/extension_test.go | 794 ++++++++++++++++++ .../gatewayapi/extraargs_test.go} | 0 pkg/enterprise/gatewayapi/suite_test.go | 27 + pkg/enterprise/register.go | 2 + pkg/extensions/extensions.go | 8 + pkg/extensions/gatewayapi.go | 61 ++ pkg/render/gatewayapi/gateway_api.go | 431 ++-------- pkg/render/gatewayapi/gateway_api_test.go | 677 +-------------- test/gatewayapi_test.go | 3 + 13 files changed, 1493 insertions(+), 1064 deletions(-) create mode 100644 pkg/enterprise/gatewayapi/envoyproxy.go create mode 100644 pkg/enterprise/gatewayapi/extension.go create mode 100644 pkg/enterprise/gatewayapi/extension_test.go rename pkg/{render/gatewayapi/gateway_api_extraargs_test.go => enterprise/gatewayapi/extraargs_test.go} (100%) create mode 100644 pkg/enterprise/gatewayapi/suite_test.go create mode 100644 pkg/extensions/gatewayapi.go diff --git a/pkg/controller/gatewayapi/gatewayapi_controller.go b/pkg/controller/gatewayapi/gatewayapi_controller.go index f7dbc0a820..601ff31e78 100644 --- a/pkg/controller/gatewayapi/gatewayapi_controller.go +++ b/pkg/controller/gatewayapi/gatewayapi_controller.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/yaml" // gopkg.in/yaml.v2 didn't parse all the fields but this package did "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" + ctrl "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -45,15 +45,16 @@ import ( "github.com/go-logr/logr" operatorv1 "github.com/tigera/operator/api/v1" "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller" "github.com/tigera/operator/pkg/controller/certificatemanager" "github.com/tigera/operator/pkg/controller/options" "github.com/tigera/operator/pkg/controller/status" "github.com/tigera/operator/pkg/controller/utils" "github.com/tigera/operator/pkg/controller/utils/imageset" "github.com/tigera/operator/pkg/ctrlruntime" + "github.com/tigera/operator/pkg/extensions" "github.com/tigera/operator/pkg/render" "github.com/tigera/operator/pkg/render/common/networkpolicy" - "github.com/tigera/operator/pkg/render/common/secret" "github.com/tigera/operator/pkg/render/gatewayapi" "github.com/tigera/operator/pkg/tls/certificatemanagement" ) @@ -77,12 +78,13 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { status: status.New(mgr.GetClient(), "gatewayapi", opts.KubernetesVersion), clusterDomain: opts.ClusterDomain, variant: opts.Variant, + ext: opts.Extensions.GatewayAPI(), multiTenant: opts.MultiTenant, newComponentHandler: utils.NewComponentHandler, } r.status.Run(opts.ShutdownContext) - c, err := ctrlruntime.NewController("gatewayapi-controller", mgr, controller.Options{Reconciler: r}) + c, err := ctrlruntime.NewController("gatewayapi-controller", mgr, ctrl.Options{Reconciler: r}) if err != nil { return fmt.Errorf("failed to create gatewayapi-controller: %w", err) } @@ -181,6 +183,7 @@ type ReconcileGatewayAPI struct { status status.StatusManager clusterDomain string variant operatorv1.ProductVariant + ext extensions.GatewayAPIExtension multiTenant bool newComponentHandler func(log logr.Logger, client client.Client, scheme *runtime.Scheme, cr metav1.Object, opts ...utils.ComponentHandlerOption) utils.ComponentHandler watchEnvoyProxy func(namespacedName operatorv1.NamespacedName) error @@ -349,6 +352,7 @@ func (r *ReconcileGatewayAPI) Reconcile(ctx context.Context, request reconcile.R CurrentGatewayClasses: set.New[string](), IncludeV3NetworkPolicy: includeV3NetworkPolicy, TrustedBundle: trustedBundle, + ImageOverrides: r.ext.Images(), } if gatewayAPI.Spec.EnvoyGatewayConfigRef != nil { @@ -556,6 +560,26 @@ func (r *ReconcileGatewayAPI) Reconcile(ctx context.Context, request reconcile.R // Render non-CRD resources for Gateway API support, i.e. for our specific bundled // implementation of the Gateway API. For these we specify the GatewayAPI CR as the owner, // so that they all get automatically cleaned up if the GatewayAPI CR is removed again. + // Run the variant's gateway API extension to build the render inputs (creating no + // enterprise artifacts in core). + ci := controller.Inputs{ + RenderInputs: render.Inputs{ + Installation: installationSpec, + ClusterDomain: r.clusterDomain, + TrustedBundle: trustedBundle, + }, + Client: r.client, + } + ci, err = r.ext.ExtendInputs(ctx, ci) + if err != nil { + if reason, ok := extensions.DegradedReason(err); ok { + r.status.SetDegraded(reason, err.Error(), nil, reqLogger) + return reconcile.Result{}, err + } + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error preparing gateway API extension", err, reqLogger) + return reconcile.Result{}, err + } + nonCRDComponent, err := gatewayapi.GatewayAPIImplementationComponent(gatewayConfig) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error rendering Gateway API resources", err, log) @@ -572,14 +596,17 @@ func (r *ReconcileGatewayAPI) Reconcile(ctx context.Context, request reconcile.R return reconcile.Result{}, err } - err = r.newComponentHandler(log, r.client, r.scheme, gatewayAPI).CreateOrUpdateOrDelete(ctx, nonCRDComponent, r.status) + modifier := utils.WithModifier(func(c render.Component) render.Component { + return r.ext.Modify(c, ci.RenderInputs) + }) + err = r.newComponentHandler(log, r.client, r.scheme, gatewayAPI, modifier).CreateOrUpdateOrDelete(ctx, nonCRDComponent, r.status) if err != nil { r.status.SetDegraded(operatorv1.ResourceCreateError, "Error rendering GatewayAPI resources", err, log) return reconcile.Result{}, err } // Per-namespace resources, owned by the namespace's Gateways so the GC cleans them up. - if err = r.reconcileGatewayNamespaceResources(ctx, trustedBundle, pullSecrets, r.variant.IsEnterprise(), gwList.Items, ownedClass); err != nil { + if err = r.reconcileGatewayNamespaceResources(ctx, trustedBundle, pullSecrets, gwList.Items, ownedClass); err != nil { r.status.SetDegraded(operatorv1.ResourceUpdateError, "Error writing per-namespace Gateway resources", err, log) return reconcile.Result{}, err } @@ -663,13 +690,13 @@ func (r *ReconcileGatewayAPI) maintainFinalizer(ctx context.Context, gatewayAPI // reconcileGatewayNamespaceResources writes the per-namespace resources owned by the namespace's // Gateways, so the GC removes them once the last Gateway is gone (and the GatewayAPI CR's deletion -// doesn't strand them). Reserved namespaces are skipped; trust bundle on both variants, the rest on -// Enterprise. +// doesn't strand them). Reserved namespaces are skipped; the trust bundle is written for every +// variant, and the variant's extension adds whatever else the namespace needs. // Each object is written once per owning Gateway, because the component handler takes a single // owner. MultipleOwnersLabel makes it merge that owner reference into the references already on the // object instead of replacing them, which is what keeps the namespace's other Gateways — and any // reference another feature added, such as the waypoint controller's Istio CR — in place. -func (r *ReconcileGatewayAPI) reconcileGatewayNamespaceResources(ctx context.Context, bundle certificatemanagement.TrustedBundle, pullSecrets []*corev1.Secret, enterprise bool, gateways []gapi.Gateway, ownedClass map[string]bool) error { +func (r *ReconcileGatewayAPI) reconcileGatewayNamespaceResources(ctx context.Context, bundle certificatemanagement.TrustedBundle, pullSecrets []*corev1.Secret, gateways []gapi.Gateway, ownedClass map[string]bool) error { gatewaysByNamespace := map[string][]*gapi.Gateway{} for i := range gateways { gw := &gateways[i] @@ -682,7 +709,7 @@ func (r *ReconcileGatewayAPI) reconcileGatewayNamespaceResources(ctx context.Con for _, gw := range gws { // Rendered per pass: the handler stamps its owner reference onto the objects it // is given and strips the label before writing them. - objs := gatewayNamespaceObjects(namespace, bundle, pullSecrets, enterprise) + objs := gatewayNamespaceObjects(namespace, bundle, r.ext.GatewayNamespaceObjects(namespace, pullSecrets)) hdlr := r.newComponentHandler(log, r.client, r.scheme, gw) if err := hdlr.CreateOrUpdateOrDelete(ctx, render.NewPassthrough(objs, nil), nil); err != nil { return err @@ -694,19 +721,12 @@ func (r *ReconcileGatewayAPI) reconcileGatewayNamespaceResources(ctx context.Con // gatewayNamespaceObjects returns the resources a namespace hosting our Gateways needs, each marked // for merged ownership. -func gatewayNamespaceObjects(namespace string, bundle certificatemanagement.TrustedBundle, pullSecrets []*corev1.Secret, enterprise bool) []client.Object { +func gatewayNamespaceObjects(namespace string, bundle certificatemanagement.TrustedBundle, extra []client.Object) []client.Object { var objs []client.Object if bundle != nil { objs = append(objs, bundle.ConfigMap(namespace)) } - if enterprise { - objs = append(objs, - gatewayapi.GatewayNamespaceServiceAccount(namespace), - gatewayapi.GatewayNamespaceRoleBinding(namespace), - render.CreateOperatorSecretsRoleBinding(namespace), - ) - objs = append(objs, secret.ToRuntimeObjects(secret.CopyToNamespace(namespace, pullSecrets...)...)...) - } + objs = append(objs, extra...) for _, obj := range objs { labels := common.MapExistsOrInitialize(obj.GetLabels()) labels[common.MultipleOwnersLabel] = "true" diff --git a/pkg/controller/gatewayapi/gatewayapi_controller_test.go b/pkg/controller/gatewayapi/gatewayapi_controller_test.go index 44ec5c2686..62a1638608 100644 --- a/pkg/controller/gatewayapi/gatewayapi_controller_test.go +++ b/pkg/controller/gatewayapi/gatewayapi_controller_test.go @@ -47,6 +47,7 @@ import ( "github.com/tigera/operator/pkg/controller/utils" ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" "github.com/tigera/operator/pkg/dns" + egatewayapi "github.com/tigera/operator/pkg/enterprise/gatewayapi" "github.com/tigera/operator/pkg/render" "github.com/tigera/operator/pkg/render/gatewayapi" "github.com/tigera/operator/pkg/tls/certificatemanagement" @@ -118,6 +119,7 @@ var _ = Describe("Gateway API controller tests", func() { scheme: scheme, status: mockStatus, variant: operatorv1.CalicoEnterprise, + ext: egatewayapi.New(operatorv1.CalicoEnterprise), tierWatchReady: &utils.ReadyFlag{}, newComponentHandler: FakeComponentHandler, watchEnvoyProxy: func(namespacedName operatorv1.NamespacedName) error { return nil }, @@ -758,7 +760,7 @@ var _ = Describe("Gateway API controller tests", func() { {ObjectMeta: metav1.ObjectMeta{Namespace: "other-ns", Name: "gw3", UID: "u3"}, Spec: gapi.GatewaySpec{GatewayClassName: "not-ours"}}, {ObjectMeta: metav1.ObjectMeta{Namespace: common.CalicoNamespace, Name: "gw4", UID: "u4"}, Spec: gapi.GatewaySpec{GatewayClassName: gatewayapi.GatewayClassName}}, } - Expect(r.reconcileGatewayNamespaceResources(ctx, bundle, pullSecrets, true, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) + Expect(r.reconcileGatewayNamespaceResources(ctx, bundle, pullSecrets, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) By("creating the bundle + WAF SA/RoleBindings/pull-secret in app-ns, owned by both Gateways") ownerNames := func(o client.Object) []string { @@ -805,7 +807,7 @@ var _ = Describe("Gateway API controller tests", func() { gateways := []gapi.Gateway{ {ObjectMeta: metav1.ObjectMeta{Namespace: "app-ns", Name: "gw1", UID: "u1"}, Spec: gapi.GatewaySpec{GatewayClassName: gatewayapi.GatewayClassName}}, } - Expect(r.reconcileGatewayNamespaceResources(ctx, nil, pullSecrets, true, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) + Expect(r.reconcileGatewayNamespaceResources(ctx, nil, pullSecrets, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) By("keeping the Istio reference and adding our Gateway alongside it") ownerKinds := func(o client.Object) []string { @@ -844,7 +846,7 @@ var _ = Describe("Gateway API controller tests", func() { {ObjectMeta: metav1.ObjectMeta{Namespace: "app-ns", Name: "gw1", UID: "u1"}, Spec: gapi.GatewaySpec{GatewayClassName: gatewayapi.GatewayClassName}}, {ObjectMeta: metav1.ObjectMeta{Namespace: "app-ns", Name: "flipped", UID: "u-flipped"}, Spec: gapi.GatewaySpec{GatewayClassName: "not-ours"}}, } - Expect(r.reconcileGatewayNamespaceResources(ctx, nil, nil, true, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) + Expect(r.reconcileGatewayNamespaceResources(ctx, nil, nil, gateways, map[string]bool{gatewayapi.GatewayClassName: true})).NotTo(HaveOccurred()) updatedRB := &rbacv1.RoleBinding{} Expect(c.Get(ctx, client.ObjectKey{Namespace: "app-ns", Name: "tigera-operator-secrets"}, updatedRB)).NotTo(HaveOccurred()) diff --git a/pkg/enterprise/gatewayapi/envoyproxy.go b/pkg/enterprise/gatewayapi/envoyproxy.go new file mode 100644 index 0000000000..593107879f --- /dev/null +++ b/pkg/enterprise/gatewayapi/envoyproxy.go @@ -0,0 +1,319 @@ +// 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 gatewayapi + +import ( + "encoding/json" + + corev1 "k8s.io/api/core/v1" + apiextenv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/ptr" + + envoyapi "github.com/envoyproxy/gateway/api/v1alpha1" + + "github.com/tigera/operator/pkg/render/common/securitycontext" + "github.com/tigera/operator/pkg/render/gatewayapi" +) + +const ( + // wafLogComponentWasm is the Envoy "wasm" logger component. Envoy Gateway does not + // define a const for it (its enum omits wasm), but EnvoyProxy.Spec.Logging.Level + // passes arbitrary component keys through to Envoy's --component-log-level arg, and + // Envoy recognises "wasm". Setting it to info surfaces the Coraza WASM filter's + // "AuditLog:" lines (emitted via proxywasm.LogInfo) in Envoy's application log. + wafLogComponentWasm = envoyapi.ProxyLogComponent("wasm") + + // wafAuditLogPath is the file that Envoy's application log is redirected to via + // --log-path, and that the l7-log-collector tails for Coraza "AuditLog:" lines + // (WAF_AUDIT_LOG_PATH). It lives on the "access-logs" emptyDir that is already + // mounted in both the envoy container (which writes it) and the l7-log-collector + // (which reads it), so no extra volume or mount is needed. Envoy will not create + // parent directories for --log-path, so this is a file directly under the existing + // /access_logs mount, not a new subdirectory. + wafAuditLogPath = "/access_logs/envoy.log" +) + +var ( + accessLogType envoyapi.ProxyAccessLogType = "Route" + + // Owning Gateway name and namespace are exposed via pod labels set by EnvoyProxy. + // These allow the l7-log-collector to know which Gateway it is collecting logs for + // without needing to query the Kubernetes API. + OwningGatewayNameEnvVar = corev1.EnvVar{ + Name: "OWNING_GATEWAY_NAME", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: "metadata.labels['gateway.envoyproxy.io/owning-gateway-name']", + }, + }, + } + OwningGatewayNamespaceEnvVar = corev1.EnvVar{ + Name: "OWNING_GATEWAY_NAMESPACE", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: "metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']", + }, + }, + } +) + +// applyWAFAndLogCollector adds the WAF HTTP filter's Envoy configuration and the +// l7-log-collector container to a rendered EnvoyProxy. +func applyWAFAndLogCollector(envoyProxy *envoyapi.EnvoyProxy, image string) { + // The WAF HTTP filter is not supported when the envoy proxy is deployed as a DaemonSet + // as there is no support for init containers in a DaemonSet. + if envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment != nil { + // Tune Envoy log levels for WAF audit capture: the wasm component logs at + // info so the Coraza filter's "AuditLog:" lines reach Envoy's application + // log, while the default stays at warn to keep the redirected log file + // approximately just the audit lines. A user-supplied default level (e.g. + // for debugging) is preserved. + if envoyProxy.Spec.Logging.Level == nil { + envoyProxy.Spec.Logging.Level = map[envoyapi.ProxyLogComponent]envoyapi.LogLevel{} + } + if _, ok := envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault]; !ok { + envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault] = envoyapi.LogLevelWarn + } + envoyProxy.Spec.Logging.Level[wafLogComponentWasm] = envoyapi.LogLevelInfo + + // Redirect Envoy's application log (where the wasm filter's "AuditLog:" lines land) + // to a file on the "access-logs" emptyDir so the l7-log-collector can tail it (the + // collector already mounts that volume, and can only read files under /access_logs). + // EnvoyProxy has no native log-path field, and a Patch on the envoy container's args + // would replace Envoy Gateway's generated args, so use ExtraArgs, which EG appends to + // the proxy command line. func-e parses each element as a single token, so the flag + // and value are separate elements. The operator owns --log-path whenever WAF audit + // capture is enabled: it must match WAF_AUDIT_LOG_PATH on the l7-log-collector and + // live on the shared access-logs volume, so set it to wafAuditLogPath, replacing any + // value carried over from a custom base EnvoyProxy. + envoyProxy.Spec.ExtraArgs = ensureExtraArg(envoyProxy.Spec.ExtraArgs, "--log-path", wafAuditLogPath) + + l7LogCollector := corev1.Container{ + Name: "l7-log-collector", + Image: image, + Env: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + { + Name: "FELIX_DIAL_TARGET", + Value: "/var/run/felix/nodeagent/socket", + }, + { + Name: "ENVOY_ACCESS_LOG_PATH", + Value: "/access_logs/access.log", + }, + // WAF audit capture: file the collector tails for the wasm filter's + // Coraza "AuditLog:" lines (Envoy's app log, redirected via --log-path). + { + Name: "WAF_AUDIT_LOG_PATH", + Value: wafAuditLogPath, + }, + // Owning Gateway info from pod labels (set by EnvoyProxy) + OwningGatewayNameEnvVar, + OwningGatewayNamespaceEnvVar, + }, + RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), + VolumeMounts: []corev1.VolumeMount{ + { + Name: "access-logs", + MountPath: "/access_logs", + }, + { + Name: "felix-sync", + MountPath: "/var/run/felix", + }, + }, + SecurityContext: securitycontext.NewRootContext(true), + } + + hasL7LogCollector := false + for i, initContainer := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers { + if initContainer.Name == l7LogCollector.Name { + hasL7LogCollector = true + // Handle update + if initContainer.Image != l7LogCollector.Image { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].Image = l7LogCollector.Image + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].Env = l7LogCollector.Env + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].VolumeMounts = l7LogCollector.VolumeMounts + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].RestartPolicy = l7LogCollector.RestartPolicy + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].SecurityContext = l7LogCollector.SecurityContext + } + } + } + if !hasL7LogCollector { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers, l7LogCollector) + } + + accessLogsName := "access-logs" + // Add or update Container volume mount + l7SocketVolumeMount := corev1.VolumeMount{ + Name: accessLogsName, + MountPath: "/access_logs", + } + + hasAccessLogsVolumeMount := false + for i, volumeMount := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts { + if volumeMount.Name == l7SocketVolumeMount.Name { + hasAccessLogsVolumeMount = true + if volumeMount.MountPath != l7SocketVolumeMount.MountPath { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts[i] = l7SocketVolumeMount + } + } + } + if !hasAccessLogsVolumeMount { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts, l7SocketVolumeMount) + } + + // Add or update Pod volumes + AccessLogsVolume := []corev1.Volume{ + { + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + Name: accessLogsName, + }, + { + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: "csi.tigera.io", + }, + }, + Name: "felix-sync", + }, + } + hasAccessLogsVolume := false + for i, volume := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes { + for _, acVolume := range AccessLogsVolume { + if volume.Name == acVolume.Name { + hasAccessLogsVolume = true + if acVolume.VolumeSource != volume.VolumeSource { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes[i] = acVolume + } + } + } + } + if !hasAccessLogsVolume { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes, AccessLogsVolume...) + } + + // Configure the envoy-proxy pod's service account, used by the l7-log-collector + // for license verification and Gateway-API reads. + // Use EnvoyProxy patch mechanism to set serviceAccountName and automountServiceAccountToken + serviceAccountPatch := map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "serviceAccountName": gatewayapi.WAFFilterName, + "automountServiceAccountToken": true, + }, + }, + }, + } + + // Convert patch to JSON + patchBytes, err := json.Marshal(serviceAccountPatch) + if err == nil { + if envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch == nil { + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch = &envoyapi.KubernetesPatchSpec{} + } + envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch.Value = apiextenv1.JSON{Raw: patchBytes} + } + + if envoyProxy.Spec.Telemetry != nil { + if envoyProxy.Spec.Telemetry.AccessLog == nil { + envoyProxy.Spec.Telemetry.AccessLog = &envoyapi.ProxyAccessLog{ + Settings: []envoyapi.ProxyAccessLogSetting{}, + } + } + } else { + envoyProxy.Spec.Telemetry = &envoyapi.ProxyTelemetry{ + AccessLog: &envoyapi.ProxyAccessLog{ + Settings: []envoyapi.ProxyAccessLogSetting{}, + }, + } + } + + envoyProxy.Spec.Telemetry.AccessLog.Settings = []envoyapi.ProxyAccessLogSetting{ + { + Sinks: []envoyapi.ProxyAccessLogSink{ + { + Type: envoyapi.ProxyAccessLogSinkTypeFile, + File: &envoyapi.FileEnvoyProxyAccessLog{ + Path: "/access_logs/access.log", + }, + }, + }, + Format: &envoyapi.ProxyAccessLogFormat{ + Type: ptr.To(envoyapi.ProxyAccessLogFormatTypeJSON), + JSON: map[string]string{ + "reporter": "gateway", + "start_time": "%START_TIME%", + "duration": "%DURATION%", + "response_code": "%RESPONSE_CODE%", + "bytes_sent": "%BYTES_SENT%", + "bytes_received": "%BYTES_RECEIVED%", + "user_agent": "%REQ(USER-AGENT)%", + "request_path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", + "request_method": "%REQ(:METHOD)%", + "request_id": "%REQ(X-REQUEST-ID)%", + "type": "{{.}}", + "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", + "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", + "downstream_direct_remote_address": "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%", + "domain": "%REQ(HOST?:AUTHORITY)%", + "upstream_host": "%UPSTREAM_HOST%", + "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", + "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", + "route_name": "%ROUTE_NAME%", + }, + }, + Type: &accessLogType, + }, + } + } +} + +// ensureExtraArg sets "flag value" in an Envoy Gateway ExtraArgs slice (func-e parses each token as +// a separate element), replacing the value if flag is already present as an option, or inserting the +// flag/value pair if not. A bare "--" terminates option parsing, so tokens at or after it are left +// alone: the flag is matched only before "--", and a newly inserted pair goes before it. The slice is +// copied, so this never mutates a slice backing a cached EnvoyProxy object. +func ensureExtraArg(args []string, flag, value string) []string { + // Options end at the first bare "--"; anything from there on is a non-option token. + sep := len(args) + for i, a := range args { + if a == "--" { + sep = i + break + } + } + out := make([]string, 0, len(args)+2) + for i := 0; i < sep; i++ { + if args[i] == flag { + out = append(out, flag, value) + next := i + 1 + if next < sep { // drop the existing value, if any + next++ + } + return append(out, args[next:]...) + } + out = append(out, args[i]) + } + // flag is not present as an option: insert it just before the "--" (or at the end). + out = append(out, flag, value) + return append(out, args[sep:]...) +} diff --git a/pkg/enterprise/gatewayapi/extension.go b/pkg/enterprise/gatewayapi/extension.go new file mode 100644 index 0000000000..571e670d7e --- /dev/null +++ b/pkg/enterprise/gatewayapi/extension.go @@ -0,0 +1,171 @@ +// 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 gatewayapi + +import ( + "context" + "fmt" + "slices" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + envoyapi "github.com/envoyproxy/gateway/api/v1alpha1" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/components" + "github.com/tigera/operator/pkg/controller" + "github.com/tigera/operator/pkg/controller/utils/imageset" + "github.com/tigera/operator/pkg/extensions" + "github.com/tigera/operator/pkg/imageoverride" + "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/common/secret" + "github.com/tigera/operator/pkg/render/gatewayapi" +) + +// legacyNamespace is where the pre-namespaced Gateway install put its resources. +const legacyNamespace = "tigera-gateway" + +// Extension is the Calico Enterprise behavior for the gateway API controller. +type Extension struct { + variant operatorv1.ProductVariant + images *imageoverride.Overrides +} + +var _ extensions.GatewayAPIExtension = &Extension{} + +// New returns the gateway API extension for the variant the operator resolved. +func New(variant operatorv1.ProductVariant) *Extension { + images := imageoverride.New() + images.Register(variant, gatewayapi.ComponentNameEnvoyGateway, components.ComponentGatewayAPIEnvoyGateway) + images.Register(variant, gatewayapi.ComponentNameEnvoyProxy, components.ComponentGatewayAPIEnvoyProxy) + images.Register(variant, gatewayapi.ComponentNameEnvoyRatelimit, components.ComponentGatewayAPIEnvoyRatelimit) + + return &Extension{variant: variant, images: images} +} + +func (e *Extension) Images() *imageoverride.Overrides { + return e.images +} + +// gatewayAPIRenderData is the controller-produced data the gateway API extension hands +// to its modifier through Inputs.Extension. +type gatewayAPIRenderData struct { + // l7LogCollectorImage runs alongside envoy to collect access and WAF audit logs. + // The base render never resolves it, and a modifier runs with no ImageSet. + l7LogCollectorImage string +} + +// gatewayAPIData pulls the extension's render data back out of the render inputs, +// returning the zero value when none is set. +func gatewayAPIData(ri render.Inputs) gatewayAPIRenderData { + return render.ExtractExtensionData[gatewayAPIRenderData](ri) +} + +// ExtendInputs resolves the l7-log-collector image for the modifier. +func (e *Extension) ExtendInputs(ctx context.Context, ci controller.Inputs) (controller.Inputs, error) { + in := ci.RenderInputs.Installation + + imageSet, err := imageset.GetImageSet(ctx, ci.Client, in.Variant) + if err != nil { + return ci, extensions.Degradedf(operatorv1.ResourceReadError, "error getting ImageSet: %w", err) + } + image, err := components.GetReference(components.ComponentGatewayL7Collector, in.Registry, in.ImagePath, in.ImagePrefix, imageSet) + if err != nil { + return ci, extensions.Degradedf(operatorv1.ResourceUpdateError, "error with images from ImageSet: %w", err) + } + + ci.RenderInputs.Extension = gatewayAPIRenderData{l7LogCollectorImage: image} + return ci, nil +} + +// Modify dispatches over the components the gateway API controller renders. +func (e *Extension) Modify(c render.Component, ri render.Inputs) render.Component { + switch t := c.(type) { + case gatewayapi.ImplementationComponent: + return extensions.Decorate(c, ri, e.variant, func(create, del []client.Object) ([]client.Object, []client.Object) { + return modifyImplementation(ri, t.GetConfig(), create, del) + }) + default: + return c + } +} + +// modifyImplementation adds the WAF HTTP filter's RBAC, layers the filter and the +// l7-log-collector onto each rendered EnvoyProxy, and cleans up the legacy install's +// service account and the cluster role bindings that bound it. +func modifyImplementation(ri render.Inputs, cfg *gatewayapi.GatewayAPIImplementationConfig, create, del []client.Object) ([]client.Object, []client.Object) { + create = append(create, gatewayapi.WAFClusterScopedRole(), gatewayapi.WAFGatewayResourcesRole()) + + // The shared binding's subjects are recomputed each reconcile, and it goes away + // once no Gateway namespaces remain. + if len(cfg.GatewayNamespaces) > 0 { + create = append(create, gatewayapi.GatewayNamespacesCRB(cfg.GatewayNamespaces)) + } else { + del = append(del, gatewayapi.GatewayNamespacesCRB(nil)) + } + + image := gatewayAPIData(ri).l7LogCollectorImage + patched := 0 + for _, o := range create { + if proxy, ok := o.(*envoyapi.EnvoyProxy); ok { + applyWAFAndLogCollector(proxy, image) + patched++ + } + } + + // The render emits one EnvoyProxy per GatewayClass. A mismatch means a proxy that + // serves traffic would run without the WAF filter. + if expected := len(cfg.GatewayAPI.Spec.GatewayClasses); patched != expected { + panic(fmt.Sprintf("BUG: applied the WAF filter to %d EnvoyProxies for %d GatewayClasses", patched, expected)) + } + + // The legacy install's service account, unless a Gateway lives there and the + // controller is managing it. + if !slices.Contains(cfg.GatewayNamespaces, legacyNamespace) { + del = append(del, &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: gatewayapi.WAFFilterName, Namespace: legacyNamespace}, + }) + } + + // The orphaned bindings that bound it: a current install uses the shared binding + // and per-namespace role bindings instead. + del = append(del, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: gatewayapi.WAFClusterScopedRole().Name}, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: gatewayapi.WAFGatewayResourcesRole().Name}, + }, + ) + + return create, del +} + +// GatewayNamespaceObjects returns the WAF HTTP filter's per-namespace identity and the +// pull secrets its container needs. +func (e *Extension) GatewayNamespaceObjects(namespace string, pullSecrets []*corev1.Secret) []client.Object { + objs := []client.Object{ + gatewayapi.GatewayNamespaceServiceAccount(namespace), + gatewayapi.GatewayNamespaceRoleBinding(namespace), + render.CreateOperatorSecretsRoleBinding(namespace), + } + return append(objs, secret.ToRuntimeObjects(secret.CopyToNamespace(namespace, pullSecrets...)...)...) +} diff --git a/pkg/enterprise/gatewayapi/extension_test.go b/pkg/enterprise/gatewayapi/extension_test.go new file mode 100644 index 0000000000..e08f959e4a --- /dev/null +++ b/pkg/enterprise/gatewayapi/extension_test.go @@ -0,0 +1,794 @@ +// 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 gatewayapi + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + admissionregv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextenv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + + envoyapi "github.com/envoyproxy/gateway/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gapi "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/yaml" + + operatorv1 "github.com/tigera/operator/api/v1" + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/components" + "github.com/tigera/operator/pkg/controller" + "github.com/tigera/operator/pkg/render" + rtest "github.com/tigera/operator/pkg/render/common/test" + "github.com/tigera/operator/pkg/render/gatewayapi" +) + +// l7CollectorImage stands in for the image ExtendInputs resolves. +const l7CollectorImage = "test-registry/l7-collector:latest" + +func testScheme() *runtime.Scheme { + s := runtime.NewScheme() + Expect(scheme.AddToScheme(s)).ShouldNot(HaveOccurred()) + Expect(apiextenv1.AddToScheme(s)).ShouldNot(HaveOccurred()) + Expect(admissionregv1.AddToScheme(s)).ShouldNot(HaveOccurred()) + Expect(operatorv1.AddToScheme(s)).ShouldNot(HaveOccurred()) + return s +} + +// enterpriseComponent renders the gateway API implementation the way the controller +// does, with the extension's image overrides and modifier applied. +func enterpriseComponent(cfg *gatewayapi.GatewayAPIImplementationConfig) render.Component { + ext := New(operatorv1.CalicoEnterprise) + cfg.Scheme = testScheme() + cfg.ImageOverrides = ext.Images() + + comp, err := gatewayapi.GatewayAPIImplementationComponent(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(comp.ResolveImages(nil)).NotTo(HaveOccurred()) + + return ext.Modify(comp, render.Inputs{ + Installation: cfg.Installation, + Extension: gatewayAPIRenderData{l7LogCollectorImage: l7CollectorImage}, + }) +} + +var _ = Describe("Gateway API enterprise extension", func() { + AccessLogSettings := []envoyapi.ProxyAccessLogSetting{ + { + Sinks: []envoyapi.ProxyAccessLogSink{ + { + Type: envoyapi.ProxyAccessLogSinkTypeFile, + File: &envoyapi.FileEnvoyProxyAccessLog{ + Path: "/access_logs/access.log", + }, + }, + }, + Format: &envoyapi.ProxyAccessLogFormat{ + Type: ptr.To(envoyapi.ProxyAccessLogFormatTypeJSON), + JSON: map[string]string{ + "reporter": "gateway", + "start_time": "%START_TIME%", + "duration": "%DURATION%", + "response_code": "%RESPONSE_CODE%", + "bytes_sent": "%BYTES_SENT%", + "bytes_received": "%BYTES_RECEIVED%", + "user_agent": "%REQ(USER-AGENT)%", + "request_path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", + "request_method": "%REQ(:METHOD)%", + "request_id": "%REQ(X-REQUEST-ID)%", + "type": "{{.}}", + "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", + "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", + "downstream_direct_remote_address": "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%", + "domain": "%REQ(HOST?:AUTHORITY)%", + "upstream_host": "%UPSTREAM_HOST%", + "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", + "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", + "route_name": "%ROUTE_NAME%", + }, + }, + Type: &accessLogType, + }, + } + + It("patches every EnvoyProxy the render emits", func() { + installation := &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise} + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "one"}, {Name: "two"}, {Name: "three"}}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + }) + + objsToCreate, _ := gatewayComp.Objects() + + proxies := 0 + for _, o := range objsToCreate { + if proxy, ok := o.(*envoyapi.EnvoyProxy); ok { + proxies++ + Expect(proxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes).NotTo(BeEmpty()) + } + } + Expect(proxies).To(Equal(3)) + }) + + It("panics when a GatewayClass has no EnvoyProxy to patch", func() { + cfg := &gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "one"}, {Name: "two"}}, + }, + }, + } + ri := render.Inputs{ + Installation: cfg.Installation, + Extension: gatewayAPIRenderData{l7LogCollectorImage: l7CollectorImage}, + } + create := []client.Object{&envoyapi.EnvoyProxy{}} + + Expect(func() { modifyImplementation(ri, cfg, create, nil) }).To(Panic()) + }) + + It("tolerates a GatewayAPI with no GatewayClasses", func() { + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{}, + }) + + Expect(func() { gatewayComp.Objects() }).NotTo(Panic()) + }) + + It("should deploy l7-log-collector (no waf-http-filter sidecar) for Enterprise", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + IncludeV3NetworkPolicy: true, + }) + objsToCreate, _ := gatewayComp.Objects() + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gatewayapi.GatewayClassName, common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + + envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment + Expect(envoyDeployment).ToNot(BeNil()) + + Expect(envoyDeployment.Pod).ToNot(BeNil()) + Expect(envoyDeployment.Pod.Volumes).To(HaveLen(2)) + Expect(envoyDeployment.Pod.Volumes[0].Name).To(Equal("access-logs")) + Expect(envoyDeployment.Pod.Volumes[0].EmptyDir).ToNot(BeNil()) + Expect(envoyDeployment.Pod.Volumes[1].Name).To(Equal("felix-sync")) + Expect(envoyDeployment.Pod.Volumes[1].CSI.Driver).To(Equal("csi.tigera.io")) + + Expect(envoyDeployment.InitContainers).To(HaveLen(1)) + Expect(envoyDeployment.InitContainers[0].Name).To(Equal("l7-log-collector")) + Expect(*envoyDeployment.InitContainers[0].RestartPolicy).To(Equal(corev1.ContainerRestartPolicyAlways)) + Expect(envoyDeployment.InitContainers[0].VolumeMounts).To(HaveLen(2)) + Expect(envoyDeployment.InitContainers[0].VolumeMounts).To(ContainElements([]corev1.VolumeMount{ + { + Name: "access-logs", + MountPath: "/access_logs", + }, + { + Name: "felix-sync", + MountPath: "/var/run/felix", + }, + })) + // WAF audit capture: the l7-log-collector tails the redirected Envoy app log on + // the access-logs volume it already mounts. + Expect(envoyDeployment.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_AUDIT_LOG_PATH", + Value: "/access_logs/envoy.log", + })) + + Expect(envoyDeployment.Container).ToNot(BeNil()) + Expect(envoyDeployment.Container.VolumeMounts).To(HaveLen(1)) + Expect(envoyDeployment.Container.VolumeMounts).To(ContainElement(corev1.VolumeMount{ + Name: "access-logs", + MountPath: "/access_logs", + })) + + Expect(proxy.Spec.Telemetry.AccessLog.Settings).To(Equal(AccessLogSettings)) + + // WAF audit capture: the wasm component logs at info so Coraza "AuditLog:" lines + // reach Envoy's application log, while everything else stays at warn so the + // redirected log file is approximately just the audit lines. + Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.LogComponentDefault, envoyapi.LogLevelWarn)) + Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.ProxyLogComponent("wasm"), envoyapi.LogLevelInfo)) + + // WAF audit capture: Envoy's application log is redirected to a file on the + // var-log-calico HostPath volume via --log-path (appended through ExtraArgs, + // which Envoy Gateway adds to the proxy args verbatim - each token a separate + // element). The l7-log-collector tails this file. + Expect(proxy.Spec.ExtraArgs).To(Equal([]string{"--log-path", "/access_logs/envoy.log"})) + }) + + It("should deploy l7-log-collector (no waf-http-filter sidecar) for Enterprise when using a custom proxy", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{ + Name: "custom-class", + EnvoyProxyRef: &operatorv1.NamespacedName{ + Namespace: "default", + Name: "my-proxy", + }, + }}, + }, + } + envoyProxy := &envoyapi.EnvoyProxy{ + TypeMeta: metav1.TypeMeta{ + Kind: "EnvoyProxy", + APIVersion: "gateway.envoyproxy.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-proxy", + Namespace: "default", + }, + Spec: envoyapi.EnvoyProxySpec{ + Provider: &envoyapi.EnvoyProxyProvider{ + Type: envoyapi.EnvoyProxyProviderTypeKubernetes, + Kubernetes: &envoyapi.EnvoyProxyKubernetesProvider{ + EnvoyDeployment: &envoyapi.KubernetesDeploymentSpec{ + InitContainers: []corev1.Container{ + { + Name: "some-other-sidecar", + RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), + VolumeMounts: []corev1.VolumeMount{ + { + Name: "some-other-volume", + MountPath: "/test", + }, + }, + }, + }, + Container: &envoyapi.KubernetesContainerSpec{ + VolumeMounts: []corev1.VolumeMount{ + { + Name: "some-other-volume", + MountPath: "/test", + }, + }, + }, + Pod: &envoyapi.KubernetesPodSpec{ + Volumes: []corev1.Volume{ + { + Name: "some-other-volume", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + }, + }, + }, + }, + }, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + CustomEnvoyProxies: map[string]*envoyapi.EnvoyProxy{ + "custom-class": envoyProxy, + }, + }) + objsToCreate, _ := gatewayComp.Objects() + + // Get the four expected GatewayClasses. + gc, err := rtest.GetResourceOfType[*gapi.GatewayClass](objsToCreate, "custom-class", "") + Expect(err).NotTo(HaveOccurred()) + + // Get their four EnvoyProxies. + Expect(gc.Spec.ParametersRef).NotTo(BeNil()) + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gc.Spec.ParametersRef.Name, string(*gc.Spec.ParametersRef.Namespace)) + Expect(err).NotTo(HaveOccurred()) + + envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment + Expect(envoyDeployment).ToNot(BeNil()) + + Expect(envoyDeployment.InitContainers).To(HaveLen(2)) + Expect(envoyDeployment.InitContainers[0].Name).To(Equal("some-other-sidecar")) + + Expect(envoyDeployment.InitContainers[1].Name).To(Equal("l7-log-collector")) + Expect(*envoyDeployment.InitContainers[1].RestartPolicy).To(Equal(corev1.ContainerRestartPolicyAlways)) + Expect(envoyDeployment.InitContainers[1].VolumeMounts).To(HaveLen(2)) + Expect(envoyDeployment.InitContainers[1].VolumeMounts).To(ContainElements([]corev1.VolumeMount{ + { + Name: "access-logs", + MountPath: "/access_logs", + }, + { + Name: "felix-sync", + MountPath: "/var/run/felix", + }, + })) + Expect(envoyDeployment.InitContainers[1].Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_AUDIT_LOG_PATH", + Value: "/access_logs/envoy.log", + })) + + Expect(envoyDeployment.Container).ToNot(BeNil()) + Expect(envoyDeployment.Container.VolumeMounts).To(ContainElements( + corev1.VolumeMount{ + Name: "some-other-volume", + MountPath: "/test", + }, corev1.VolumeMount{ + Name: "access-logs", + MountPath: "/access_logs", + }, + )) + + Expect(envoyDeployment.Pod).ToNot(BeNil()) + Expect(envoyDeployment.Pod.Volumes).To(HaveLen(3)) + Expect(envoyDeployment.Pod.Volumes[0].Name).To(Equal("some-other-volume")) + Expect(envoyDeployment.Pod.Volumes[0].EmptyDir).ToNot(BeNil()) + Expect(envoyDeployment.Pod.Volumes[1].Name).To(Equal("access-logs")) + Expect(envoyDeployment.Pod.Volumes[1].EmptyDir).ToNot(BeNil()) + Expect(envoyDeployment.Pod.Volumes[2].Name).To(Equal("felix-sync")) + Expect(envoyDeployment.Pod.Volumes[2].CSI.Driver).To(Equal("csi.tigera.io")) + Expect(proxy.Spec.Telemetry.AccessLog.Settings).To(Equal(AccessLogSettings)) + }) + + It("should set owning gateway environment variables in l7-log-collector for Enterprise", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + IncludeV3NetworkPolicy: true, + }) + objsToCreate, _ := gatewayComp.Objects() + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gatewayapi.GatewayClassName, common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + + envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment + Expect(envoyDeployment).ToNot(BeNil()) + Expect(envoyDeployment.InitContainers).To(HaveLen(1)) + + // Find the l7-log-collector init container + var l7LogCollector *corev1.Container + for i := range envoyDeployment.InitContainers { + if envoyDeployment.InitContainers[i].Name == "l7-log-collector" { + l7LogCollector = &envoyDeployment.InitContainers[i] + break + } + } + + Expect(l7LogCollector).ToNot(BeNil(), "l7-log-collector container should exist") + + // Verify the owning gateway environment variables are present + Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNameEnvVar)) + Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNamespaceEnvVar)) + + // Verify the structure of the environment variables + var foundNameEnvVar, foundNamespaceEnvVar bool + for _, env := range l7LogCollector.Env { + if env.Name == "OWNING_GATEWAY_NAME" { + foundNameEnvVar = true + Expect(env.ValueFrom).ToNot(BeNil()) + Expect(env.ValueFrom.FieldRef).ToNot(BeNil()) + Expect(env.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-name']")) + } + if env.Name == "OWNING_GATEWAY_NAMESPACE" { + foundNamespaceEnvVar = true + Expect(env.ValueFrom).ToNot(BeNil()) + Expect(env.ValueFrom.FieldRef).ToNot(BeNil()) + Expect(env.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']")) + } + } + Expect(foundNameEnvVar).To(BeTrue(), "OWNING_GATEWAY_NAME environment variable should be set") + Expect(foundNamespaceEnvVar).To(BeTrue(), "OWNING_GATEWAY_NAMESPACE environment variable should be set") + }) + + It("should set owning gateway environment variables in l7-log-collector when using custom proxy", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{ + Name: "custom-class", + EnvoyProxyRef: &operatorv1.NamespacedName{ + Namespace: "default", + Name: "my-proxy", + }, + }}, + }, + } + envoyProxy := &envoyapi.EnvoyProxy{ + TypeMeta: metav1.TypeMeta{ + Kind: "EnvoyProxy", + APIVersion: "gateway.envoyproxy.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-proxy", + Namespace: "default", + }, + Spec: envoyapi.EnvoyProxySpec{ + Provider: &envoyapi.EnvoyProxyProvider{ + Type: envoyapi.EnvoyProxyProviderTypeKubernetes, + Kubernetes: &envoyapi.EnvoyProxyKubernetesProvider{ + EnvoyDeployment: &envoyapi.KubernetesDeploymentSpec{ + InitContainers: []corev1.Container{ + { + Name: "some-other-sidecar", + RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), + Env: []corev1.EnvVar{ + { + Name: "OTHER_VAR", + Value: "other-value", + }, + }, + }, + }, + }, + }, + }, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + CustomEnvoyProxies: map[string]*envoyapi.EnvoyProxy{ + "custom-class": envoyProxy, + }, + }) + objsToCreate, _ := gatewayComp.Objects() + + gc, err := rtest.GetResourceOfType[*gapi.GatewayClass](objsToCreate, "custom-class", "") + Expect(err).NotTo(HaveOccurred()) + + Expect(gc.Spec.ParametersRef).NotTo(BeNil()) + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gc.Spec.ParametersRef.Name, string(*gc.Spec.ParametersRef.Namespace)) + Expect(err).NotTo(HaveOccurred()) + + envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment + Expect(envoyDeployment).ToNot(BeNil()) + + // Find the l7-log-collector init container + var l7LogCollector *corev1.Container + for i := range envoyDeployment.InitContainers { + if envoyDeployment.InitContainers[i].Name == "l7-log-collector" { + l7LogCollector = &envoyDeployment.InitContainers[i] + break + } + } + + Expect(l7LogCollector).ToNot(BeNil(), "l7-log-collector container should exist") + + // Verify the owning gateway environment variables are present + Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNameEnvVar)) + Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNamespaceEnvVar)) + + // Verify environment variables include all expected values + envVarNames := make([]string, len(l7LogCollector.Env)) + for i, env := range l7LogCollector.Env { + envVarNames[i] = env.Name + } + Expect(envVarNames).To(ContainElement("LOG_LEVEL")) + Expect(envVarNames).To(ContainElement("FELIX_DIAL_TARGET")) + Expect(envVarNames).To(ContainElement("ENVOY_ACCESS_LOG_PATH")) + Expect(envVarNames).To(ContainElement("OWNING_GATEWAY_NAME")) + Expect(envVarNames).To(ContainElement("OWNING_GATEWAY_NAMESPACE")) + }) + + It("should verify owning gateway env vars use correct field paths", func() { + // Test the global env var definitions + Expect(OwningGatewayNameEnvVar.Name).To(Equal("OWNING_GATEWAY_NAME")) + Expect(OwningGatewayNameEnvVar.ValueFrom).ToNot(BeNil()) + Expect(OwningGatewayNameEnvVar.ValueFrom.FieldRef).ToNot(BeNil()) + Expect(OwningGatewayNameEnvVar.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-name']")) + + Expect(OwningGatewayNamespaceEnvVar.Name).To(Equal("OWNING_GATEWAY_NAMESPACE")) + Expect(OwningGatewayNamespaceEnvVar.ValueFrom).ToNot(BeNil()) + Expect(OwningGatewayNamespaceEnvVar.ValueFrom.FieldRef).ToNot(BeNil()) + Expect(OwningGatewayNamespaceEnvVar.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']")) + }) + + It("should not set owning gateway env vars in l7-log-collector for DaemonSet deployments", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + daemonSet := operatorv1.GatewayKindDaemonSet + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{ + Name: "tigera-gateway-class-daemonset", + GatewayKind: &daemonSet, + }}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + IncludeV3NetworkPolicy: true, + }) + objsToCreate, _ := gatewayComp.Objects() + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, "tigera-gateway-class-daemonset", common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + + // DaemonSet should not have l7-log-collector or waf-http-filter + Expect(proxy.Spec.Provider.Kubernetes.EnvoyDaemonSet).ToNot(BeNil()) + Expect(proxy.Spec.Provider.Kubernetes.EnvoyDeployment).To(BeNil()) + // DaemonSet init containers are not supported, so these should not be present + // This is expected behavior as mentioned in the code comments + }) + + It("should create correct shared WAF ClusterRoles for L7 log collector enrichment", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + IncludeV3NetworkPolicy: true, + }) + objsToCreate, _ := gatewayComp.Objects() + + // Verify cluster-scoped ClusterRole exists with license key + token review rules. + csRole, err := rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-cluster-scoped", "") + Expect(err).NotTo(HaveOccurred()) + Expect(csRole.Rules).To(HaveLen(2)) + Expect(csRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"crd.projectcalico.org", "projectcalico.org"}, + Resources: []string{"licensekeys"}, + Verbs: []string{"get", "watch"}, + })) + Expect(csRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"authentication.k8s.io"}, + Resources: []string{"tokenreviews"}, + Verbs: []string{"create"}, + })) + + // Verify gateway-resources ClusterRole exists with route rules only. + grRole, err := rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-gateway-resources", "") + Expect(err).NotTo(HaveOccurred()) + Expect(grRole.Rules).To(HaveLen(1)) + Expect(grRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch"}, + })) + + // With no GatewayNamespaces declared, no per-namespace SAs or CRBs/RoleBindings + // are emitted — they only appear when a Gateway is created in a user namespace. + _, err = rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, gatewayapi.GatewayNamespacesCRBName, "") + Expect(err).To(HaveOccurred()) + }) + + It("renders the shared WAF CRB with a subject per Gateway namespace; per-namespace resources are controller-managed (Enterprise)", func() { + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{Spec: operatorv1.GatewayAPISpec{GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}}}, + PullSecrets: []*corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "tigera-pull-secret", Namespace: "tigera-operator"}}}, + GatewayNamespaces: []string{"default", "app-ns"}, + }) + objsToCreate, _ := gatewayComp.Objects() + + // The shared CRB carries one subject per Gateway namespace. + crb, err := rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, gatewayapi.GatewayNamespacesCRBName, "") + Expect(err).NotTo(HaveOccurred()) + Expect(crb.RoleRef.Name).To(Equal("waf-http-filter-cluster-scoped")) + nsSubjects := []string{} + for _, s := range crb.Subjects { + nsSubjects = append(nsSubjects, s.Namespace) + } + Expect(nsSubjects).To(ConsistOf("default", "app-ns")) + + // The per-namespace SA / RoleBinding / pull-secret are written by the controller (Gateway-owned), + // not rendered here. + _, err = rtest.GetResourceOfType[*corev1.ServiceAccount](objsToCreate, "waf-http-filter", "default") + Expect(err).To(HaveOccurred()) + _, err = rtest.GetResourceOfType[*rbacv1.RoleBinding](objsToCreate, "waf-http-filter-gateway-resources", "default") + Expect(err).To(HaveOccurred()) + _, err = rtest.GetResourceOfType[*corev1.Secret](objsToCreate, "tigera-pull-secret", "default") + Expect(err).To(HaveOccurred()) + }) + + It("should not create per-namespace resources when no Gateway namespaces are provided (Enterprise)", func() { + installation := &operatorv1.InstallationSpec{ + Variant: operatorv1.CalicoEnterprise, + } + gatewayAPI := &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: gatewayAPI, + IncludeV3NetworkPolicy: true, + }) + objsToCreate, _ := gatewayComp.Objects() + + // With no GatewayNamespaces declared, no shared per-namespace CRB is created. + _, err := rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, gatewayapi.GatewayNamespacesCRBName, "") + Expect(err).To(HaveOccurred()) + + // Shared WAF ClusterRoles must always be present on Enterprise so per-namespace + // CRBs can bind to them once a Gateway shows up. + _, err = rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-cluster-scoped", "") + Expect(err).NotTo(HaveOccurred()) + _, err = rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-gateway-resources", "") + Expect(err).NotTo(HaveOccurred()) + }) + + It("resolves the Enterprise envoy images", func() { + installation := &operatorv1.InstallationSpec{ + Registry: "myregistry.io/", + Variant: operatorv1.CalicoEnterprise, + } + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: installation, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + }, + }) + + objsToCreate, _ := gatewayComp.Objects() + + deploy, err := rtest.GetResourceOfType[*appsv1.Deployment](objsToCreate, "envoy-gateway", common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + Expect(deploy.Spec.Template.Spec.Containers[0].Image).To( + Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) + + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gatewayapi.GatewayClassName, common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + Expect(*proxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.Image).To( + Equal("myregistry.io/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version)) + + gatewayCM, err := rtest.GetResourceOfType[*corev1.ConfigMap](objsToCreate, "envoy-gateway-config", common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + gatewayConfig := &envoyapi.EnvoyGateway{} + Expect(yaml.Unmarshal([]byte(gatewayCM.Data[gatewayapi.EnvoyGatewayConfigKey]), gatewayConfig)).NotTo(HaveOccurred()) + Expect(*gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Container.Image).To( + Equal("myregistry.io/tigera/envoy-ratelimit:" + components.ComponentGatewayAPIEnvoyRatelimit.Version)) + }) + + It("runs the l7-log-collector on the image the controller resolved", func() { + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + }, + }) + + objsToCreate, _ := gatewayComp.Objects() + proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gatewayapi.GatewayClassName, common.CalicoNamespace) + Expect(err).NotTo(HaveOccurred()) + Expect(proxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[0].Image).To(Equal(l7CollectorImage)) + }) + + It("resolves the l7-log-collector image from the installation", func() { + ci := controller.Inputs{ + RenderInputs: render.Inputs{ + Installation: &operatorv1.InstallationSpec{ + Registry: "myregistry.io/", + Variant: operatorv1.CalicoEnterprise, + }, + }, + Client: fake.NewClientBuilder().WithScheme(testScheme()).Build(), + } + ci, err := New(operatorv1.CalicoEnterprise).ExtendInputs(context.Background(), ci) + Expect(err).NotTo(HaveOccurred()) + Expect(gatewayAPIData(ci.RenderInputs).l7LogCollectorImage).To( + Equal("myregistry.io/tigera/gateway-l7-collector:" + components.ComponentGatewayL7Collector.Version)) + }) + + It("queues the legacy install's WAF service account and the bindings that bound it", func() { + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + }, + }) + + _, objsToDelete := gatewayComp.Objects() + rtest.ExpectResourceInList(objsToDelete, "waf-http-filter", "tigera-gateway", "", "v1", "ServiceAccount") + rtest.ExpectResourceInList(objsToDelete, "waf-http-filter-cluster-scoped", "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding") + rtest.ExpectResourceInList(objsToDelete, "waf-http-filter-gateway-resources", "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding") + }) + + It("leaves the legacy WAF service account alone when a Gateway lives in that namespace", func() { + gatewayComp := enterpriseComponent(&gatewayapi.GatewayAPIImplementationConfig{ + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + }, + GatewayNamespaces: []string{"tigera-gateway"}, + }) + + _, objsToDelete := gatewayComp.Objects() + for _, o := range objsToDelete { + if sa, ok := o.(*corev1.ServiceAccount); ok && sa.Namespace == "tigera-gateway" { + Expect(sa.Name).NotTo(Equal("waf-http-filter")) + } + } + }) + + It("gives each Gateway namespace the WAF filter's identity and pull secrets", func() { + pullSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tigera-pull-secret", Namespace: common.OperatorNamespace()}, + } + objs := New(operatorv1.CalicoEnterprise).GatewayNamespaceObjects("app-ns", []*corev1.Secret{pullSecret}) + + rtest.ExpectResourceInList(objs, "waf-http-filter", "app-ns", "", "v1", "ServiceAccount") + rtest.ExpectResourceInList(objs, "waf-http-filter-gateway-resources", "app-ns", "rbac.authorization.k8s.io", "v1", "RoleBinding") + rtest.ExpectResourceInList(objs, "tigera-operator-secrets", "app-ns", "rbac.authorization.k8s.io", "v1", "RoleBinding") + rtest.ExpectResourceInList(objs, "tigera-pull-secret", "app-ns", "", "", "") + }) + + It("makes no changes when the installation is Calico", func() { + cfg := &gatewayapi.GatewayAPIImplementationConfig{ + Scheme: testScheme(), + Installation: &operatorv1.InstallationSpec{Variant: operatorv1.Calico}, + GatewayAPI: &operatorv1.GatewayAPI{ + Spec: operatorv1.GatewayAPISpec{ + GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, + }, + }, + } + comp, err := gatewayapi.GatewayAPIImplementationComponent(cfg) + Expect(err).NotTo(HaveOccurred()) + Expect(comp.ResolveImages(nil)).NotTo(HaveOccurred()) + baseCreate, baseDelete := comp.Objects() + + decorated := New(operatorv1.CalicoEnterprise).Modify(comp, render.Inputs{Installation: cfg.Installation}) + create, del := decorated.Objects() + Expect(create).To(HaveLen(len(baseCreate))) + Expect(del).To(HaveLen(len(baseDelete))) + }) +}) diff --git a/pkg/render/gatewayapi/gateway_api_extraargs_test.go b/pkg/enterprise/gatewayapi/extraargs_test.go similarity index 100% rename from pkg/render/gatewayapi/gateway_api_extraargs_test.go rename to pkg/enterprise/gatewayapi/extraargs_test.go diff --git a/pkg/enterprise/gatewayapi/suite_test.go b/pkg/enterprise/gatewayapi/suite_test.go new file mode 100644 index 0000000000..63b036f881 --- /dev/null +++ b/pkg/enterprise/gatewayapi/suite_test.go @@ -0,0 +1,27 @@ +// 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 gatewayapi + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGatewayAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "pkg/enterprise/gatewayapi Suite") +} diff --git a/pkg/enterprise/register.go b/pkg/enterprise/register.go index 131313968e..6ad4887e89 100644 --- a/pkg/enterprise/register.go +++ b/pkg/enterprise/register.go @@ -19,6 +19,7 @@ import ( "github.com/tigera/operator/pkg/enterprise/apiserver" "github.com/tigera/operator/pkg/enterprise/clusterconnection" "github.com/tigera/operator/pkg/enterprise/csr" + "github.com/tigera/operator/pkg/enterprise/gatewayapi" "github.com/tigera/operator/pkg/enterprise/goldmane" "github.com/tigera/operator/pkg/enterprise/installation" "github.com/tigera/operator/pkg/enterprise/istio" @@ -46,6 +47,7 @@ func New(variant operatorv1.ProductVariant, o eoptions.Options) extensions.Exten Istio: istio.New(), Goldmane: goldmane.New(variant), Whisker: whisker.New(variant), + GatewayAPI: gatewayapi.New(variant), }) } diff --git a/pkg/extensions/extensions.go b/pkg/extensions/extensions.go index 64a4c547c4..f34e5318c0 100644 --- a/pkg/extensions/extensions.go +++ b/pkg/extensions/extensions.go @@ -26,6 +26,7 @@ type Set struct { Istio IstioExtension Goldmane GoldmaneExtension Whisker WhiskerExtension + GatewayAPI GatewayAPIExtension } // Extensions is the variant behavior the operator runs with. The zero value extends @@ -102,3 +103,10 @@ func (e Extensions) Whisker() WhiskerExtension { } return e.set.Whisker } + +func (e Extensions) GatewayAPI() GatewayAPIExtension { + if e.set.GatewayAPI == nil { + return noopGatewayAPI{} + } + return e.set.GatewayAPI +} diff --git a/pkg/extensions/gatewayapi.go b/pkg/extensions/gatewayapi.go new file mode 100644 index 0000000000..16db24bcb0 --- /dev/null +++ b/pkg/extensions/gatewayapi.go @@ -0,0 +1,61 @@ +// 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 ( + "context" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/tigera/operator/pkg/controller" + "github.com/tigera/operator/pkg/imageoverride" + "github.com/tigera/operator/pkg/render" +) + +// GatewayAPIExtension is the variant's hook into the gateway API controller. +type GatewayAPIExtension interface { + // Images overrides the images the gateway API render resolves. + Images() *imageoverride.Overrides + + // ExtendInputs resolves what the modifier needs but cannot read for itself. + ExtendInputs(ctx context.Context, ci controller.Inputs) (controller.Inputs, error) + + // Modify layers the variant onto a component the controller rendered. + Modify(c render.Component, ri render.Inputs) render.Component + + // GatewayNamespaceObjects returns what a namespace hosting a Gateway needs + // beyond the trusted bundle the core operator writes there. + GatewayNamespaceObjects(namespace string, pullSecrets []*corev1.Secret) []client.Object +} + +// noopGatewayAPI runs the core operator's behavior unchanged. +type noopGatewayAPI struct{} + +func (noopGatewayAPI) Images() *imageoverride.Overrides { + return nil +} + +func (noopGatewayAPI) ExtendInputs(_ context.Context, ci controller.Inputs) (controller.Inputs, error) { + return ci, nil +} + +func (noopGatewayAPI) Modify(c render.Component, _ render.Inputs) render.Component { + return c +} + +func (noopGatewayAPI) GatewayNamespaceObjects(string, []*corev1.Secret) []client.Object { + return nil +} diff --git a/pkg/render/gatewayapi/gateway_api.go b/pkg/render/gatewayapi/gateway_api.go index 145b9daaac..277f11edf8 100644 --- a/pkg/render/gatewayapi/gateway_api.go +++ b/pkg/render/gatewayapi/gateway_api.go @@ -29,12 +29,12 @@ import ( operatorv1 "github.com/tigera/operator/api/v1" "github.com/tigera/operator/pkg/common" "github.com/tigera/operator/pkg/components" + "github.com/tigera/operator/pkg/imageoverride" "github.com/tigera/operator/pkg/render" rcomp "github.com/tigera/operator/pkg/render/common/components" rmeta "github.com/tigera/operator/pkg/render/common/meta" "github.com/tigera/operator/pkg/render/common/networkpolicy" "github.com/tigera/operator/pkg/render/common/secret" - "github.com/tigera/operator/pkg/render/common/securitycontext" "github.com/tigera/operator/pkg/tls/certificatemanagement" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart/loader" @@ -48,7 +48,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" k8syaml "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/utils/ptr" "k8s.io/utils/set" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -60,8 +59,6 @@ var ( //go:embed gateway-helm.tgz gatewayHelmChart []byte - AccessLogType envoyapi.ProxyAccessLogType = "Route" - log = logf.Log.WithName("gateway_api") ) @@ -77,6 +74,13 @@ const ( EnvoyGatewayPolicySelector = "k8s-app == '" + GatewayControllerLabel + "' || k8s-app == '" + GatewayCertgenLabel + "'" ) +// Component names, which key the image overrides a variant resolves through. +const ( + ComponentNameEnvoyGateway = "envoy-gateway" + ComponentNameEnvoyProxy = "envoy-proxy" + ComponentNameEnvoyRatelimit = "envoy-ratelimit" +) + // gatewayAPIResources defines all of the resources that we expect to read from the rendered Envoy Gateway // helm chart (as of the version indicated by `ENVOY_GATEWAY_VERSION` in `Makefile`). type gatewayAPIResources struct { @@ -110,45 +114,7 @@ const ( EnvoyGatewayConfigKey = "envoy-gateway.yaml" EnvoyGatewayDeploymentContainerName = "envoy-gateway" EnvoyGatewayJobContainerName = "envoy-gateway-certgen" - wafFilterName = "waf-http-filter" - - // wafLogComponentWasm is the Envoy "wasm" logger component. Envoy Gateway does not - // define a const for it (its enum omits wasm), but EnvoyProxy.Spec.Logging.Level - // passes arbitrary component keys through to Envoy's --component-log-level arg, and - // Envoy recognises "wasm". Setting it to info surfaces the Coraza WASM filter's - // "AuditLog:" lines (emitted via proxywasm.LogInfo) in Envoy's application log. - wafLogComponentWasm = envoyapi.ProxyLogComponent("wasm") - - // wafAuditLogPath is the file that Envoy's application log is redirected to via - // --log-path, and that the l7-log-collector tails for Coraza "AuditLog:" lines - // (WAF_AUDIT_LOG_PATH). It lives on the "access-logs" emptyDir that is already - // mounted in both the envoy container (which writes it) and the l7-log-collector - // (which reads it) - so no extra volume or mount is needed. Envoy will not create - // parent directories for --log-path, so this is a file directly under the existing - // /access_logs mount, not a new subdirectory. - wafAuditLogPath = "/access_logs/envoy.log" -) - -var ( - // Owning Gateway name and namespace are exposed via pod labels set by EnvoyProxy. - // These allow the l7-log-collector to know which Gateway it is collecting logs for - // without needing to query the Kubernetes API. - OwningGatewayNameEnvVar = corev1.EnvVar{ - Name: "OWNING_GATEWAY_NAME", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "metadata.labels['gateway.envoyproxy.io/owning-gateway-name']", - }, - }, - } - OwningGatewayNamespaceEnvVar = corev1.EnvVar{ - Name: "OWNING_GATEWAY_NAMESPACE", - ValueFrom: &corev1.EnvVarSource{ - FieldRef: &corev1.ObjectFieldSelector{ - FieldPath: "metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']", - }, - }, - } + WAFFilterName = "waf-http-filter" ) // helmOpts represents the helm values passed when rendering the Envoy Gateway chart. @@ -399,9 +365,10 @@ type GatewayAPIImplementationConfig struct { CustomEnvoyProxies map[string]*envoyapi.EnvoyProxy CurrentGatewayClasses set.Set[string] IncludeV3NetworkPolicy bool + ImageOverrides *imageoverride.Overrides // GatewayNamespaces is the list of namespaces containing a Gateway managed by - // this operator, used to keep the shared WAF CRB's subjects in sync (Enterprise only). + // this operator. GatewayNamespaces []string // TrustedBundle carries the public CA bundle (extracted from the operator's UBI @@ -416,12 +383,18 @@ type gatewayAPIImplementationComponent struct { envoyGatewayImage string envoyProxyImage string envoyRatelimitImage string - L7LogCollectorImage string // Pre-rendered helm chart resources. chart *gatewayAPIResources } +// ImplementationComponent is the gateway API implementation, exposed so a variant +// extension can reach the config it rendered from. +type ImplementationComponent interface { + render.Component + GetConfig() *GatewayAPIImplementationConfig +} + func GatewayAPIImplementationComponent(cfg *GatewayAPIImplementationConfig) (render.Component, error) { chart, err := chartResourcesFor(cfg.Scheme) if err != nil { @@ -431,41 +404,24 @@ func GatewayAPIImplementationComponent(cfg *GatewayAPIImplementationConfig) (ren } func (pr *gatewayAPIImplementationComponent) ResolveImages(is *operatorv1.ImageSet) error { - reg := pr.cfg.Installation.Registry - path := pr.cfg.Installation.ImagePath - prefix := pr.cfg.Installation.ImagePrefix + in := pr.cfg.Installation + reg, path, prefix := in.Registry, in.ImagePath, in.ImagePrefix var err error - if pr.cfg.Installation.Variant.IsEnterprise() { - pr.envoyGatewayImage, err = components.GetReference(components.ComponentGatewayAPIEnvoyGateway, reg, path, prefix, is) - if err != nil { - return err - } - pr.envoyProxyImage, err = components.GetReference(components.ComponentGatewayAPIEnvoyProxy, reg, path, prefix, is) - if err != nil { - return err - } - pr.envoyRatelimitImage, err = components.GetReference(components.ComponentGatewayAPIEnvoyRatelimit, reg, path, prefix, is) - if err != nil { - return err - } - pr.L7LogCollectorImage, err = components.GetReference(components.ComponentGatewayL7Collector, reg, path, prefix, is) - if err != nil { - return err - } - } else { - pr.envoyGatewayImage, err = components.GetReference(components.ComponentCalicoEnvoyGateway, reg, path, prefix, is) - if err != nil { - return err - } - pr.envoyProxyImage, err = components.GetReference(components.ComponentCalicoEnvoyProxy, reg, path, prefix, is) - if err != nil { - return err - } - pr.envoyRatelimitImage, err = components.GetReference(components.ComponentCalicoEnvoyRatelimit, reg, path, prefix, is) - if err != nil { - return err - } + pr.envoyGatewayImage, err = components.GetReference( + pr.cfg.ImageOverrides.Resolve(ComponentNameEnvoyGateway, components.ComponentCalicoEnvoyGateway, in), reg, path, prefix, is) + if err != nil { + return err + } + pr.envoyProxyImage, err = components.GetReference( + pr.cfg.ImageOverrides.Resolve(ComponentNameEnvoyProxy, components.ComponentCalicoEnvoyProxy, in), reg, path, prefix, is) + if err != nil { + return err + } + pr.envoyRatelimitImage, err = components.GetReference( + pr.cfg.ImageOverrides.Resolve(ComponentNameEnvoyRatelimit, components.ComponentCalicoEnvoyRatelimit, in), reg, path, prefix, is) + if err != nil { + return err } return nil } @@ -501,23 +457,9 @@ func (pr *gatewayAPIImplementationComponent) Objects() ([]client.Object, []clien pr.cfg.CurrentGatewayClasses.Delete(className) } - // Per-namespace resources (trust bundle + Enterprise WAF SA/RoleBindings/pull-secret) are + // Per-namespace resources (trust bundle, plus whatever the variant adds) are // controller-managed and Gateway-owned, so the GC cleans them up — not rendered here. - if pr.cfg.Installation.Variant.IsEnterprise() { - // Shared WAF ClusterRoles bound per-namespace by the controller-managed SAs. - objs = append(objs, - pr.wafHttpFilterClusterScopedRole(), - pr.wafHttpFilterGatewayResourcesRole(), - ) - // Shared CRB: subjects recomputed each reconcile, removed when no Gateway namespaces remain. - if len(pr.cfg.GatewayNamespaces) > 0 { - objs = append(objs, pr.gatewayNamespacesCRB(pr.cfg.GatewayNamespaces)) - } else { - objsToDelete = append(objsToDelete, pr.gatewayNamespacesCRB(nil)) - } - } - objsToDelete = append(objsToDelete, pr.legacyTeardownObjects(objs)...) for _, gcName := range pr.cfg.CurrentGatewayClasses.UnsortedList() { @@ -557,7 +499,6 @@ func (pr *gatewayAPIImplementationComponent) legacyTeardownObjects(creating []cl // If a Gateway lives in tigera-gateway, the controller manages its per-namespace resources // (Gateway-owned) — don't queue those for legacy delete or we'd fight it every reconcile. if slices.Contains(pr.cfg.GatewayNamespaces, legacyNS) { - skip.Insert(key(GatewayNamespaceServiceAccount(legacyNS))) skip.Insert(key(render.CreateOperatorSecretsRoleBinding(legacyNS))) for _, s := range secret.ToRuntimeObjects(secret.CopyToNamespace(legacyNS, pr.cfg.PullSecrets...)...) { skip.Insert(key(s)) @@ -629,26 +570,6 @@ func (pr *gatewayAPIImplementationComponent) legacyTeardownObjects(creating []cl }) } - // Enterprise-only WAF SA in tigera-gateway, plus the orphaned legacy CRBs - // that bound it (the new install uses waf-http-filter-gateway-namespaces - // and per-namespace RoleBindings instead). - if pr.cfg.Installation.Variant.IsEnterprise() { - objs = append(objs, - &corev1.ServiceAccount{ - TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: wafFilterName, Namespace: legacyNS}, - }, - &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{Name: wafFilterClusterScopedRoleName}, - }, - &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{Name: wafFilterGatewayResourcesRoleName}, - }, - ) - } - // tigera-operator-secrets RoleBinding last — must outlive the Secrets above. objs = append(objs, render.CreateOperatorSecretsRoleBinding(legacyNS)) @@ -661,11 +582,11 @@ func (pr *gatewayAPIImplementationComponent) legacyTeardownObjects(creating []cl }, &rbacv1.ClusterRole{ TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{Name: wafFilterName}, + ObjectMeta: metav1.ObjectMeta{Name: WAFFilterName}, }, &rbacv1.ClusterRoleBinding{ TypeMeta: metav1.TypeMeta{Kind: "ClusterRoleBinding", APIVersion: "rbac.authorization.k8s.io/v1"}, - ObjectMeta: metav1.ObjectMeta{Name: wafFilterName}, + ObjectMeta: metav1.ObjectMeta{Name: WAFFilterName}, }, ) @@ -825,37 +746,6 @@ func (pr *gatewayAPIImplementationComponent) controllerObjects() []client.Object return objs } -// ensureExtraArg sets "flag value" in an Envoy Gateway ExtraArgs slice (func-e parses each token as -// a separate element), replacing the value if flag is already present as an option, or inserting the -// flag/value pair if not. A bare "--" terminates option parsing, so tokens at or after it are left -// alone: the flag is matched only before "--", and a newly inserted pair goes before it. The slice is -// copied, so this never mutates a slice backing a cached EnvoyProxy object. -func ensureExtraArg(args []string, flag, value string) []string { - // Options end at the first bare "--"; anything from there on is a non-option token. - sep := len(args) - for i, a := range args { - if a == "--" { - sep = i - break - } - } - out := make([]string, 0, len(args)+2) - for i := 0; i < sep; i++ { - if args[i] == flag { - out = append(out, flag, value) - next := i + 1 - if next < sep { // drop the existing value, if any - next++ - } - return append(out, args[next:]...) - } - out = append(out, args[i]) - } - // flag is not present as an option: insert it just before the "--" (or at the end). - out = append(out, flag, value) - return append(out, args[sep:]...) -} - func (pr *gatewayAPIImplementationComponent) envoyProxyConfig(className, ns string, envoyProxy *envoyapi.EnvoyProxy, classSpec *operatorv1.GatewayClassSpec) *envoyapi.EnvoyProxy { // Ensure the minimal structure that we need for basic correctness and for the following // customizations. Note, we always create the running EnvoyProxy in our own namespace, even @@ -953,223 +843,6 @@ func (pr *gatewayAPIImplementationComponent) envoyProxyConfig(className, ns stri } applyEnvoyProxyServiceOverrides(envoyProxy, classSpec.GatewayService) - // Setup WAF HTTP Filter and l7 Log collector on Enterprise. - if pr.cfg.Installation.Variant.IsEnterprise() { - // The WAF HTTP filter is not supported when the envoy proxy is deployed as a DaemonSet - // as there is no support for init containers in a DaemonSet. - if envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment != nil { - // Tune Envoy log levels for WAF audit capture: the wasm component logs at - // info so the Coraza filter's "AuditLog:" lines reach Envoy's application - // log, while the default stays at warn to keep the redirected log file - // approximately just the audit lines. A user-supplied default level (e.g. - // for debugging) is preserved. - if envoyProxy.Spec.Logging.Level == nil { - envoyProxy.Spec.Logging.Level = map[envoyapi.ProxyLogComponent]envoyapi.LogLevel{} - } - if _, ok := envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault]; !ok { - envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault] = envoyapi.LogLevelWarn - } - envoyProxy.Spec.Logging.Level[wafLogComponentWasm] = envoyapi.LogLevelInfo - - // Redirect Envoy's application log (where the wasm filter's "AuditLog:" lines land) - // to a file on the "access-logs" emptyDir so the l7-log-collector can tail it (the - // collector already mounts that volume, and can only read files under /access_logs). - // EnvoyProxy has no native log-path field, and a Patch on the envoy container's args - // would replace Envoy Gateway's generated args, so use ExtraArgs, which EG appends to - // the proxy command line. func-e parses each element as a single token, so the flag - // and value are separate elements. The operator owns --log-path whenever WAF audit - // capture is enabled: it must match WAF_AUDIT_LOG_PATH on the l7-log-collector and - // live on the shared access-logs volume, so set it to wafAuditLogPath, replacing any - // value carried over from a custom base EnvoyProxy. - envoyProxy.Spec.ExtraArgs = ensureExtraArg(envoyProxy.Spec.ExtraArgs, "--log-path", wafAuditLogPath) - - l7LogCollector := corev1.Container{ - Name: "l7-log-collector", - Image: pr.L7LogCollectorImage, - Env: []corev1.EnvVar{ - { - Name: "LOG_LEVEL", - Value: "info", - }, - { - Name: "FELIX_DIAL_TARGET", - Value: "/var/run/felix/nodeagent/socket", - }, - { - Name: "ENVOY_ACCESS_LOG_PATH", - Value: "/access_logs/access.log", - }, - // WAF audit capture: file the collector tails for the wasm filter's - // Coraza "AuditLog:" lines (Envoy's app log, redirected via --log-path). - { - Name: "WAF_AUDIT_LOG_PATH", - Value: wafAuditLogPath, - }, - // Owning Gateway info from pod labels (set by EnvoyProxy) - OwningGatewayNameEnvVar, - OwningGatewayNamespaceEnvVar, - }, - RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), - VolumeMounts: []corev1.VolumeMount{ - { - Name: "access-logs", - MountPath: "/access_logs", - }, - { - Name: "felix-sync", - MountPath: "/var/run/felix", - }, - }, - SecurityContext: securitycontext.NewRootContext(true), - } - - hasL7LogCollector := false - for i, initContainer := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers { - if initContainer.Name == l7LogCollector.Name { - hasL7LogCollector = true - // Handle update - if initContainer.Image != l7LogCollector.Image { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].Image = l7LogCollector.Image - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].Env = l7LogCollector.Env - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].VolumeMounts = l7LogCollector.VolumeMounts - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].RestartPolicy = l7LogCollector.RestartPolicy - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers[i].SecurityContext = l7LogCollector.SecurityContext - } - } - } - if !hasL7LogCollector { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.InitContainers, l7LogCollector) - } - - accessLogsName := "access-logs" - // Add or update Container volume mount - l7SocketVolumeMount := corev1.VolumeMount{ - Name: accessLogsName, - MountPath: "/access_logs", - } - - hasAccessLogsVolumeMount := false - for i, volumeMount := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts { - if volumeMount.Name == l7SocketVolumeMount.Name { - hasAccessLogsVolumeMount = true - if volumeMount.MountPath != l7SocketVolumeMount.MountPath { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts[i] = l7SocketVolumeMount - } - } - } - if !hasAccessLogsVolumeMount { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.VolumeMounts, l7SocketVolumeMount) - } - - // Add or update Pod volumes - AccessLogsVolume := []corev1.Volume{ - { - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - Name: accessLogsName, - }, - { - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: "csi.tigera.io", - }, - }, - Name: "felix-sync", - }, - } - hasAccessLogsVolume := false - for i, volume := range envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes { - for _, acVolume := range AccessLogsVolume { - if volume.Name == acVolume.Name { - hasAccessLogsVolume = true - if acVolume.VolumeSource != volume.VolumeSource { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes[i] = acVolume - } - } - } - } - if !hasAccessLogsVolume { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes = append(envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.Volumes, AccessLogsVolume...) - } - - // Configure the envoy-proxy pod's service account, used by the l7-log-collector - // for license verification and Gateway-API reads. - // Use EnvoyProxy patch mechanism to set serviceAccountName and automountServiceAccountToken - serviceAccountPatch := map[string]interface{}{ - "spec": map[string]interface{}{ - "template": map[string]interface{}{ - "spec": map[string]interface{}{ - "serviceAccountName": wafFilterName, - "automountServiceAccountToken": true, - }, - }, - }, - } - - // Convert patch to JSON - patchBytes, err := json.Marshal(serviceAccountPatch) - if err == nil { - if envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch == nil { - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch = &envoyapi.KubernetesPatchSpec{} - } - envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment.Patch.Value = apiextenv1.JSON{Raw: patchBytes} - } - - if envoyProxy.Spec.Telemetry != nil { - if envoyProxy.Spec.Telemetry.AccessLog == nil { - envoyProxy.Spec.Telemetry.AccessLog = &envoyapi.ProxyAccessLog{ - Settings: []envoyapi.ProxyAccessLogSetting{}, - } - } - } else { - envoyProxy.Spec.Telemetry = &envoyapi.ProxyTelemetry{ - AccessLog: &envoyapi.ProxyAccessLog{ - Settings: []envoyapi.ProxyAccessLogSetting{}, - }, - } - } - - envoyProxy.Spec.Telemetry.AccessLog.Settings = []envoyapi.ProxyAccessLogSetting{ - { - Sinks: []envoyapi.ProxyAccessLogSink{ - { - Type: envoyapi.ProxyAccessLogSinkTypeFile, - File: &envoyapi.FileEnvoyProxyAccessLog{ - Path: "/access_logs/access.log", - }, - }, - }, - Format: &envoyapi.ProxyAccessLogFormat{ - Type: ptr.To(envoyapi.ProxyAccessLogFormatTypeJSON), - JSON: map[string]string{ - "reporter": "gateway", - "start_time": "%START_TIME%", - "duration": "%DURATION%", - "response_code": "%RESPONSE_CODE%", - "bytes_sent": "%BYTES_SENT%", - "bytes_received": "%BYTES_RECEIVED%", - "user_agent": "%REQ(USER-AGENT)%", - "request_path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", - "request_method": "%REQ(:METHOD)%", - "request_id": "%REQ(X-REQUEST-ID)%", - "type": "{{.}}", - "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", - "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", - "downstream_direct_remote_address": "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%", - "domain": "%REQ(HOST?:AUTHORITY)%", - "upstream_host": "%UPSTREAM_HOST%", - "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", - "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", - "route_name": "%ROUTE_NAME%", - }, - }, - Type: &AccessLogType, - }, - } - } - } - return envoyProxy } @@ -1239,13 +912,13 @@ func applyEnvoyProxyServiceOverrides(ep *envoyapi.EnvoyProxy, overrides *operato } const ( - wafFilterClusterScopedRoleName = wafFilterName + "-cluster-scoped" - wafFilterGatewayResourcesRoleName = wafFilterName + "-gateway-resources" + wafFilterClusterScopedRoleName = WAFFilterName + "-cluster-scoped" + wafFilterGatewayResourcesRoleName = WAFFilterName + "-gateway-resources" ) -// wafHttpFilterClusterScopedRole creates the ClusterRole granting access to cluster-scoped +// WAFClusterScopedRole creates the ClusterRole granting access to cluster-scoped // resources (license keys, token reviews) needed by every WAF HTTP Filter / L7 Log Collector. -func (pr *gatewayAPIImplementationComponent) wafHttpFilterClusterScopedRole() *rbacv1.ClusterRole { +func WAFClusterScopedRole() *rbacv1.ClusterRole { return &rbacv1.ClusterRole{ TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{ @@ -1266,10 +939,10 @@ func (pr *gatewayAPIImplementationComponent) wafHttpFilterClusterScopedRole() *r } } -// wafHttpFilterGatewayResourcesRole grants read access to namespaced Gateway +// WAFGatewayResourcesRole grants read access to namespaced Gateway // API resources (used by the L7 Log Collector), bound per-namespace via -// gatewayNamespaceRoleBinding so each proxy can only read its own namespace. -func (pr *gatewayAPIImplementationComponent) wafHttpFilterGatewayResourcesRole() *rbacv1.ClusterRole { +// GatewayNamespaceRoleBinding so each proxy can only read its own namespace. +func WAFGatewayResourcesRole() *rbacv1.ClusterRole { return &rbacv1.ClusterRole{ TypeMeta: metav1.TypeMeta{Kind: "ClusterRole", APIVersion: "rbac.authorization.k8s.io/v1"}, ObjectMeta: metav1.ObjectMeta{ @@ -1290,7 +963,7 @@ func GatewayNamespaceServiceAccount(namespace string) *corev1.ServiceAccount { return &corev1.ServiceAccount{ TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{ - Name: wafFilterName, + Name: WAFFilterName, Namespace: namespace, }, } @@ -1298,17 +971,17 @@ func GatewayNamespaceServiceAccount(namespace string) *corev1.ServiceAccount { // GatewayNamespacesCRBName is the name of the shared ClusterRoleBinding that binds the // waf-http-filter ClusterRole to ServiceAccounts in all Gateway namespaces. -const GatewayNamespacesCRBName = wafFilterName + "-gateway-namespaces" +const GatewayNamespacesCRBName = WAFFilterName + "-gateway-namespaces" -// gatewayNamespacesCRB binds the cluster-scoped WAF ClusterRole to the +// GatewayNamespacesCRB binds the cluster-scoped WAF ClusterRole to the // waf-http-filter SA in each Gateway namespace via a single shared CRB. -// Gateway API resource access is scoped per namespace via gatewayNamespaceRoleBinding. -func (pr *gatewayAPIImplementationComponent) gatewayNamespacesCRB(namespaces []string) *rbacv1.ClusterRoleBinding { +// Gateway API resource access is scoped per namespace via GatewayNamespaceRoleBinding. +func GatewayNamespacesCRB(namespaces []string) *rbacv1.ClusterRoleBinding { subjects := make([]rbacv1.Subject, 0, len(namespaces)) for _, ns := range namespaces { subjects = append(subjects, rbacv1.Subject{ Kind: "ServiceAccount", - Name: wafFilterName, + Name: WAFFilterName, Namespace: ns, }) } @@ -1326,8 +999,6 @@ func (pr *gatewayAPIImplementationComponent) gatewayNamespacesCRB(namespaces []s } } -// gatewayNamespaceRoleBinding scopes the WAF SA's Gateway API read access -// to its own namespace (least privilege for proxies in user namespaces). // GatewayNamespaceRoleBinding returns the waf-http-filter-gateway-resources RoleBinding for a Gateway namespace. func GatewayNamespaceRoleBinding(namespace string) *rbacv1.RoleBinding { return &rbacv1.RoleBinding{ @@ -1344,7 +1015,7 @@ func GatewayNamespaceRoleBinding(namespace string) *rbacv1.RoleBinding { Subjects: []rbacv1.Subject{ { Kind: "ServiceAccount", - Name: wafFilterName, + Name: WAFFilterName, Namespace: namespace, }, }, diff --git a/pkg/render/gatewayapi/gateway_api_test.go b/pkg/render/gatewayapi/gateway_api_test.go index a29ef6cf3c..60fd4fe9e5 100644 --- a/pkg/render/gatewayapi/gateway_api_test.go +++ b/pkg/render/gatewayapi/gateway_api_test.go @@ -36,7 +36,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" gapi "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/yaml" @@ -67,16 +66,10 @@ func testScheme() *runtime.Scheme { } // expectLegacyCleanup asserts the legacy tigera-gateway upgrade-cleanup is -// queued in objsToDelete and the Namespace itself is *not* deleted. enterprise -// adds the WAF SA, the two orphaned legacy CRBs, and the shared gateway-namespaces -// CRB (removed when no Gateway namespaces remain); hasPullSecret accounts for the -// tigera-pull-secret previously copied into the legacy namespace. -func expectLegacyCleanup(objsToDelete []client.Object, enterprise, hasPullSecret bool) { +// queued in objsToDelete and the Namespace itself is *not* deleted. hasPullSecret +// accounts for the tigera-pull-secret previously copied into the legacy namespace. +func expectLegacyCleanup(objsToDelete []client.Object, hasPullSecret bool) { expected := 12 + 4 + 1 + 3 // helm-rendered in-namespace + certgen Secrets + tigera-operator-secrets RB + cluster-scoped (MWC, CR, CRB). - if enterprise { - expected += 4 - rtest.ExpectResourceInList(objsToDelete, GatewayNamespacesCRBName, "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding") - } if hasPullSecret { expected += 1 } @@ -94,43 +87,6 @@ func expectLegacyCleanup(objsToDelete []client.Object, enterprise, hasPullSecret } var _ = Describe("Gateway API rendering tests", func() { - AccessLogSettings := []envoyapi.ProxyAccessLogSetting{ - { - Sinks: []envoyapi.ProxyAccessLogSink{ - { - Type: envoyapi.ProxyAccessLogSinkTypeFile, - File: &envoyapi.FileEnvoyProxyAccessLog{ - Path: "/access_logs/access.log", - }, - }, - }, - Format: &envoyapi.ProxyAccessLogFormat{ - Type: ptr.To(envoyapi.ProxyAccessLogFormatTypeJSON), - JSON: map[string]string{ - "reporter": "gateway", - "start_time": "%START_TIME%", - "duration": "%DURATION%", - "response_code": "%RESPONSE_CODE%", - "bytes_sent": "%BYTES_SENT%", - "bytes_received": "%BYTES_RECEIVED%", - "user_agent": "%REQ(USER-AGENT)%", - "request_path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", - "request_method": "%REQ(:METHOD)%", - "request_id": "%REQ(X-REQUEST-ID)%", - "type": "{{.}}", - "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", - "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", - "downstream_direct_remote_address": "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%", - "domain": "%REQ(HOST?:AUTHORITY)%", - "upstream_host": "%UPSTREAM_HOST%", - "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", - "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", - "route_name": "%ROUTE_NAME%", - }, - }, - Type: &AccessLogType, - }, - } // Helm-rendered resources for the envoy-gateway controller in calico-system, // plus the auto-provisioned default GatewayClass. @@ -319,7 +275,7 @@ var _ = Describe("Gateway API rendering tests", func() { objsToCreate, objsToDelete := gatewayComp.Objects() // Legacy tigera-gateway install cleanup (operator-owned only; the // Namespace itself is intentionally not deleted). - expectLegacyCleanup(objsToDelete, false, false) + expectLegacyCleanup(objsToDelete, false) Expect(objsToCreate).NotTo(BeEmpty()) expected := append([]client.Object{}, bootstrapExpected...) @@ -407,7 +363,7 @@ var _ = Describe("Gateway API rendering tests", func() { objsToCreate, objsToDelete := gatewayComp.Objects() // Legacy tigera-gateway install cleanup, including the pull secret // previously copied into tigera-gateway by the legacy install. - expectLegacyCleanup(objsToDelete, false, true) + expectLegacyCleanup(objsToDelete, true) // calico-system is core-owned, so pull secrets are not copied here — the // controller Deployment still references them via ImagePullSecrets. @@ -450,89 +406,6 @@ var _ = Describe("Gateway API rendering tests", func() { Expect(gatewayConfig.ExtensionAPIs.EnableBackend).To(BeTrue()) }) - It("should honour private registry (Enterprise)", func() { - pullSecretRefs := []corev1.LocalObjectReference{{ - Name: "secret1", - }} - pullSecrets := []*corev1.Secret{} - for _, ref := range pullSecretRefs { - pullSecrets = append(pullSecrets, &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: ref.Name, Namespace: common.OperatorNamespace()}, - }) - } - installation := &operatorv1.InstallationSpec{ - Registry: "myregistry.io/", - ImagePullSecrets: pullSecretRefs, - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - PullSecrets: pullSecrets, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - Expect(gatewayComp.ResolveImages(nil)).NotTo(HaveOccurred()) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyGatewayImage).To(Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyRatelimitImage).To(Equal("myregistry.io/tigera/envoy-ratelimit:" + components.ComponentGatewayAPIEnvoyRatelimit.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyProxyImage).To(Equal("myregistry.io/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version)) - - objsToCreate, objsToDelete := gatewayComp.Objects() - // Legacy tigera-gateway install cleanup (Enterprise variant adds the - // WAF SA + the orphaned legacy CRBs that bound it). - expectLegacyCleanup(objsToDelete, true, true) - - // Enterprise still renders the shared WAF ClusterRoles even with no Gateway - // namespaces declared; per-namespace SAs/RoleBindings only appear when - // GatewayNamespaces is set. - expected := append([]client.Object{}, bootstrapExpected...) - expected = append(expected, controllerExpected...) - expected = append(expected, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "waf-http-filter-cluster-scoped"}}, - &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "waf-http-filter-gateway-resources"}}, - ) - rtest.ExpectResources(objsToCreate, expected) - - deploy, err := rtest.GetResourceOfType[*appsv1.Deployment](objsToCreate, "envoy-gateway", common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - Expect(deploy.Spec.Template.Spec.Containers).To(ContainElement(And( - HaveField("Name", "envoy-gateway"), - HaveField("Image", "myregistry.io/tigera/envoy-gateway:"+components.ComponentGatewayAPIEnvoyGateway.Version), - ))) - Expect(deploy.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(pullSecretRefs[0])) - - job, err := rtest.GetResourceOfType[*batchv1.Job](objsToCreate, "tigera-gateway-api-gateway-helm-certgen", common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - Expect(job.Spec.Template.Spec.Containers).To(ContainElement(And( - HaveField("Name", "envoy-gateway-certgen"), - HaveField("Image", "myregistry.io/tigera/envoy-gateway:"+components.ComponentGatewayAPIEnvoyGateway.Version), - ))) - Expect(job.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(pullSecretRefs[0])) - - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, GatewayClassName, common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - Expect(*proxy.Spec.Provider.Kubernetes.EnvoyDeployment.Container.Image).To(Equal("myregistry.io/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version)) - Expect(proxy.Spec.Provider.Kubernetes.EnvoyDeployment.Pod.ImagePullSecrets).To(ContainElement(pullSecretRefs[0])) - - gatewayCM, err := rtest.GetResourceOfType[*corev1.ConfigMap](objsToCreate, "envoy-gateway-config", common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - gatewayConfig := &envoyapi.EnvoyGateway{} - Expect(yaml.Unmarshal([]byte(gatewayCM.Data[EnvoyGatewayConfigKey]), gatewayConfig)).NotTo(HaveOccurred()) - Expect(gatewayConfig.APIVersion).NotTo(Equal(""), fmt.Sprintf("gatewayConfig = %#v", *gatewayConfig)) - Expect(gatewayConfig.Provider.Kubernetes.RateLimitDeployment).NotTo(BeNil()) - Expect(gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Container).NotTo(BeNil()) - Expect(*gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Container.Image).To(Equal("myregistry.io/tigera/envoy-ratelimit:" + components.ComponentGatewayAPIEnvoyRatelimit.Version)) - Expect(gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Pod.ImagePullSecrets).To(ContainElement(pullSecretRefs[0])) - Expect(*gatewayConfig.Provider.Kubernetes.ShutdownManager.Image).To(Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) - }) - It("honours gateway controller customizations", func() { installation := &operatorv1.InstallationSpec{ Registry: "myregistry.io/", @@ -583,12 +456,12 @@ var _ = Describe("Gateway API rendering tests", func() { Expect(gatewayCompErr).NotTo(HaveOccurred()) Expect(gatewayComp.ResolveImages(nil)).NotTo(HaveOccurred()) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyGatewayImage).To(Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyRatelimitImage).To(Equal("myregistry.io/tigera/envoy-ratelimit:" + components.ComponentGatewayAPIEnvoyRatelimit.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyProxyImage).To(Equal("myregistry.io/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyGatewayImage).To(Equal("myregistry.io/calico/envoy-gateway:" + components.ComponentCalicoEnvoyGateway.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyRatelimitImage).To(Equal("myregistry.io/calico/envoy-ratelimit:" + components.ComponentCalicoEnvoyRatelimit.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyProxyImage).To(Equal("myregistry.io/calico/envoy-proxy:" + components.ComponentCalicoEnvoyProxy.Version)) objsToCreate, objsToDelete := gatewayComp.Objects() - expectLegacyCleanup(objsToDelete, true, false) + expectLegacyCleanup(objsToDelete, false) deploy, err := rtest.GetResourceOfType[*appsv1.Deployment](objsToCreate, "envoy-gateway", common.CalicoNamespace) Expect(err).NotTo(HaveOccurred()) @@ -604,7 +477,7 @@ var _ = Describe("Gateway API rendering tests", func() { Expect(gatewayConfig.Provider.Kubernetes.RateLimitDeployment).NotTo(BeNil()) Expect(gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Name).NotTo(BeNil()) Expect(*gatewayConfig.Provider.Kubernetes.RateLimitDeployment.Name).To(Equal(customName)) - Expect(*gatewayConfig.Provider.Kubernetes.ShutdownManager.Image).To(Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) + Expect(*gatewayConfig.Provider.Kubernetes.ShutdownManager.Image).To(Equal("myregistry.io/calico/envoy-gateway:" + components.ComponentCalicoEnvoyGateway.Version)) Expect(gatewayConfig.ExtensionAPIs).NotTo(BeNil()) Expect(gatewayConfig.ExtensionAPIs.EnableBackend).To(BeTrue()) Expect(gatewayConfig.ExtensionAPIs.EnableEnvoyPatchPolicy).To(BeTrue()) @@ -784,12 +657,12 @@ var _ = Describe("Gateway API rendering tests", func() { Expect(gatewayCompErr).NotTo(HaveOccurred()) Expect(gatewayComp.ResolveImages(nil)).NotTo(HaveOccurred()) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyGatewayImage).To(Equal("myregistry.io/tigera/envoy-gateway:" + components.ComponentGatewayAPIEnvoyGateway.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyRatelimitImage).To(Equal("myregistry.io/tigera/envoy-ratelimit:" + components.ComponentGatewayAPIEnvoyRatelimit.Version)) - Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyProxyImage).To(Equal("myregistry.io/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyGatewayImage).To(Equal("myregistry.io/calico/envoy-gateway:" + components.ComponentCalicoEnvoyGateway.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyRatelimitImage).To(Equal("myregistry.io/calico/envoy-ratelimit:" + components.ComponentCalicoEnvoyRatelimit.Version)) + Expect(gatewayComp.(*gatewayAPIImplementationComponent).envoyProxyImage).To(Equal("myregistry.io/calico/envoy-proxy:" + components.ComponentCalicoEnvoyProxy.Version)) objsToCreate, objsToDelete := gatewayComp.Objects() - expectLegacyCleanup(objsToDelete, true, false) + expectLegacyCleanup(objsToDelete, false) // The user-declared GatewayClasses fully replace the default — the controller // only patches in tigera-gateway-class when Spec.GatewayClasses is nil. @@ -1062,493 +935,6 @@ value: Expect(envoyDeployment.Container.VolumeMounts).To(BeNil()) }) - It("should deploy l7-log-collector (no waf-http-filter sidecar) for Enterprise", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, GatewayClassName, common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - - envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment - Expect(envoyDeployment).ToNot(BeNil()) - - Expect(envoyDeployment.Pod).ToNot(BeNil()) - Expect(envoyDeployment.Pod.Volumes).To(HaveLen(2)) - Expect(envoyDeployment.Pod.Volumes[0].Name).To(Equal("access-logs")) - Expect(envoyDeployment.Pod.Volumes[0].EmptyDir).ToNot(BeNil()) - Expect(envoyDeployment.Pod.Volumes[1].Name).To(Equal("felix-sync")) - Expect(envoyDeployment.Pod.Volumes[1].CSI.Driver).To(Equal("csi.tigera.io")) - - Expect(envoyDeployment.InitContainers).To(HaveLen(1)) - Expect(envoyDeployment.InitContainers[0].Name).To(Equal("l7-log-collector")) - Expect(*envoyDeployment.InitContainers[0].RestartPolicy).To(Equal(corev1.ContainerRestartPolicyAlways)) - Expect(envoyDeployment.InitContainers[0].VolumeMounts).To(HaveLen(2)) - Expect(envoyDeployment.InitContainers[0].VolumeMounts).To(ContainElements([]corev1.VolumeMount{ - { - Name: "access-logs", - MountPath: "/access_logs", - }, - { - Name: "felix-sync", - MountPath: "/var/run/felix", - }, - })) - // WAF audit capture: the l7-log-collector tails the redirected Envoy app log on - // the access-logs volume it already mounts. - Expect(envoyDeployment.InitContainers[0].Env).To(ContainElement(corev1.EnvVar{ - Name: "WAF_AUDIT_LOG_PATH", - Value: "/access_logs/envoy.log", - })) - - Expect(envoyDeployment.Container).ToNot(BeNil()) - Expect(envoyDeployment.Container.VolumeMounts).To(HaveLen(1)) - Expect(envoyDeployment.Container.VolumeMounts).To(ContainElement(corev1.VolumeMount{ - Name: "access-logs", - MountPath: "/access_logs", - })) - - Expect(proxy.Spec.Telemetry.AccessLog.Settings).To(Equal(AccessLogSettings)) - - // WAF audit capture: the wasm component logs at info so Coraza "AuditLog:" lines - // reach Envoy's application log, while everything else stays at warn so the - // redirected log file is approximately just the audit lines. - Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.LogComponentDefault, envoyapi.LogLevelWarn)) - Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.ProxyLogComponent("wasm"), envoyapi.LogLevelInfo)) - - // WAF audit capture: Envoy's application log is redirected to a file on the - // var-log-calico HostPath volume via --log-path (appended through ExtraArgs, - // which Envoy Gateway adds to the proxy args verbatim - each token a separate - // element). The l7-log-collector tails this file. - Expect(proxy.Spec.ExtraArgs).To(Equal([]string{"--log-path", "/access_logs/envoy.log"})) - }) - - It("should deploy l7-log-collector (no waf-http-filter sidecar) for Enterprise when using a custom proxy", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{ - Name: "custom-class", - EnvoyProxyRef: &operatorv1.NamespacedName{ - Namespace: "default", - Name: "my-proxy", - }, - }}, - }, - } - envoyProxy := &envoyapi.EnvoyProxy{ - TypeMeta: metav1.TypeMeta{ - Kind: "EnvoyProxy", - APIVersion: "gateway.envoyproxy.io/v1alpha1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "my-proxy", - Namespace: "default", - }, - Spec: envoyapi.EnvoyProxySpec{ - Provider: &envoyapi.EnvoyProxyProvider{ - Type: envoyapi.EnvoyProxyProviderTypeKubernetes, - Kubernetes: &envoyapi.EnvoyProxyKubernetesProvider{ - EnvoyDeployment: &envoyapi.KubernetesDeploymentSpec{ - InitContainers: []corev1.Container{ - { - Name: "some-other-sidecar", - RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), - VolumeMounts: []corev1.VolumeMount{ - { - Name: "some-other-volume", - MountPath: "/test", - }, - }, - }, - }, - Container: &envoyapi.KubernetesContainerSpec{ - VolumeMounts: []corev1.VolumeMount{ - { - Name: "some-other-volume", - MountPath: "/test", - }, - }, - }, - Pod: &envoyapi.KubernetesPodSpec{ - Volumes: []corev1.Volume{ - { - Name: "some-other-volume", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, - }, - }, - }, - }, - }, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - CustomEnvoyProxies: map[string]*envoyapi.EnvoyProxy{ - "custom-class": envoyProxy, - }, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - - // Get the four expected GatewayClasses. - gc, err := rtest.GetResourceOfType[*gapi.GatewayClass](objsToCreate, "custom-class", "") - Expect(err).NotTo(HaveOccurred()) - - // Get their four EnvoyProxies. - Expect(gc.Spec.ParametersRef).NotTo(BeNil()) - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gc.Spec.ParametersRef.Name, string(*gc.Spec.ParametersRef.Namespace)) - Expect(err).NotTo(HaveOccurred()) - - envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment - Expect(envoyDeployment).ToNot(BeNil()) - - Expect(envoyDeployment.InitContainers).To(HaveLen(2)) - Expect(envoyDeployment.InitContainers[0].Name).To(Equal("some-other-sidecar")) - - Expect(envoyDeployment.InitContainers[1].Name).To(Equal("l7-log-collector")) - Expect(*envoyDeployment.InitContainers[1].RestartPolicy).To(Equal(corev1.ContainerRestartPolicyAlways)) - Expect(envoyDeployment.InitContainers[1].VolumeMounts).To(HaveLen(2)) - Expect(envoyDeployment.InitContainers[1].VolumeMounts).To(ContainElements([]corev1.VolumeMount{ - { - Name: "access-logs", - MountPath: "/access_logs", - }, - { - Name: "felix-sync", - MountPath: "/var/run/felix", - }, - })) - Expect(envoyDeployment.InitContainers[1].Env).To(ContainElement(corev1.EnvVar{ - Name: "WAF_AUDIT_LOG_PATH", - Value: "/access_logs/envoy.log", - })) - - Expect(envoyDeployment.Container).ToNot(BeNil()) - Expect(envoyDeployment.Container.VolumeMounts).To(ContainElements( - corev1.VolumeMount{ - Name: "some-other-volume", - MountPath: "/test", - }, corev1.VolumeMount{ - Name: "access-logs", - MountPath: "/access_logs", - }, - )) - - Expect(envoyDeployment.Pod).ToNot(BeNil()) - Expect(envoyDeployment.Pod.Volumes).To(HaveLen(3)) - Expect(envoyDeployment.Pod.Volumes[0].Name).To(Equal("some-other-volume")) - Expect(envoyDeployment.Pod.Volumes[0].EmptyDir).ToNot(BeNil()) - Expect(envoyDeployment.Pod.Volumes[1].Name).To(Equal("access-logs")) - Expect(envoyDeployment.Pod.Volumes[1].EmptyDir).ToNot(BeNil()) - Expect(envoyDeployment.Pod.Volumes[2].Name).To(Equal("felix-sync")) - Expect(envoyDeployment.Pod.Volumes[2].CSI.Driver).To(Equal("csi.tigera.io")) - Expect(proxy.Spec.Telemetry.AccessLog.Settings).To(Equal(AccessLogSettings)) - }) - - It("should set owning gateway environment variables in l7-log-collector for Enterprise", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, GatewayClassName, common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - - envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment - Expect(envoyDeployment).ToNot(BeNil()) - Expect(envoyDeployment.InitContainers).To(HaveLen(1)) - - // Find the l7-log-collector init container - var l7LogCollector *corev1.Container - for i := range envoyDeployment.InitContainers { - if envoyDeployment.InitContainers[i].Name == "l7-log-collector" { - l7LogCollector = &envoyDeployment.InitContainers[i] - break - } - } - - Expect(l7LogCollector).ToNot(BeNil(), "l7-log-collector container should exist") - - // Verify the owning gateway environment variables are present - Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNameEnvVar)) - Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNamespaceEnvVar)) - - // Verify the structure of the environment variables - var foundNameEnvVar, foundNamespaceEnvVar bool - for _, env := range l7LogCollector.Env { - if env.Name == "OWNING_GATEWAY_NAME" { - foundNameEnvVar = true - Expect(env.ValueFrom).ToNot(BeNil()) - Expect(env.ValueFrom.FieldRef).ToNot(BeNil()) - Expect(env.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-name']")) - } - if env.Name == "OWNING_GATEWAY_NAMESPACE" { - foundNamespaceEnvVar = true - Expect(env.ValueFrom).ToNot(BeNil()) - Expect(env.ValueFrom.FieldRef).ToNot(BeNil()) - Expect(env.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']")) - } - } - Expect(foundNameEnvVar).To(BeTrue(), "OWNING_GATEWAY_NAME environment variable should be set") - Expect(foundNamespaceEnvVar).To(BeTrue(), "OWNING_GATEWAY_NAMESPACE environment variable should be set") - }) - - It("should set owning gateway environment variables in l7-log-collector when using custom proxy", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{ - Name: "custom-class", - EnvoyProxyRef: &operatorv1.NamespacedName{ - Namespace: "default", - Name: "my-proxy", - }, - }}, - }, - } - envoyProxy := &envoyapi.EnvoyProxy{ - TypeMeta: metav1.TypeMeta{ - Kind: "EnvoyProxy", - APIVersion: "gateway.envoyproxy.io/v1alpha1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "my-proxy", - Namespace: "default", - }, - Spec: envoyapi.EnvoyProxySpec{ - Provider: &envoyapi.EnvoyProxyProvider{ - Type: envoyapi.EnvoyProxyProviderTypeKubernetes, - Kubernetes: &envoyapi.EnvoyProxyKubernetesProvider{ - EnvoyDeployment: &envoyapi.KubernetesDeploymentSpec{ - InitContainers: []corev1.Container{ - { - Name: "some-other-sidecar", - RestartPolicy: ptr.To(corev1.ContainerRestartPolicyAlways), - Env: []corev1.EnvVar{ - { - Name: "OTHER_VAR", - Value: "other-value", - }, - }, - }, - }, - }, - }, - }, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - CustomEnvoyProxies: map[string]*envoyapi.EnvoyProxy{ - "custom-class": envoyProxy, - }, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - - gc, err := rtest.GetResourceOfType[*gapi.GatewayClass](objsToCreate, "custom-class", "") - Expect(err).NotTo(HaveOccurred()) - - Expect(gc.Spec.ParametersRef).NotTo(BeNil()) - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, gc.Spec.ParametersRef.Name, string(*gc.Spec.ParametersRef.Namespace)) - Expect(err).NotTo(HaveOccurred()) - - envoyDeployment := proxy.Spec.Provider.Kubernetes.EnvoyDeployment - Expect(envoyDeployment).ToNot(BeNil()) - - // Find the l7-log-collector init container - var l7LogCollector *corev1.Container - for i := range envoyDeployment.InitContainers { - if envoyDeployment.InitContainers[i].Name == "l7-log-collector" { - l7LogCollector = &envoyDeployment.InitContainers[i] - break - } - } - - Expect(l7LogCollector).ToNot(BeNil(), "l7-log-collector container should exist") - - // Verify the owning gateway environment variables are present - Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNameEnvVar)) - Expect(l7LogCollector.Env).To(ContainElement(OwningGatewayNamespaceEnvVar)) - - // Verify environment variables include all expected values - envVarNames := make([]string, len(l7LogCollector.Env)) - for i, env := range l7LogCollector.Env { - envVarNames[i] = env.Name - } - Expect(envVarNames).To(ContainElement("LOG_LEVEL")) - Expect(envVarNames).To(ContainElement("FELIX_DIAL_TARGET")) - Expect(envVarNames).To(ContainElement("ENVOY_ACCESS_LOG_PATH")) - Expect(envVarNames).To(ContainElement("OWNING_GATEWAY_NAME")) - Expect(envVarNames).To(ContainElement("OWNING_GATEWAY_NAMESPACE")) - }) - - It("should verify owning gateway env vars use correct field paths", func() { - // Test the global env var definitions - Expect(OwningGatewayNameEnvVar.Name).To(Equal("OWNING_GATEWAY_NAME")) - Expect(OwningGatewayNameEnvVar.ValueFrom).ToNot(BeNil()) - Expect(OwningGatewayNameEnvVar.ValueFrom.FieldRef).ToNot(BeNil()) - Expect(OwningGatewayNameEnvVar.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-name']")) - - Expect(OwningGatewayNamespaceEnvVar.Name).To(Equal("OWNING_GATEWAY_NAMESPACE")) - Expect(OwningGatewayNamespaceEnvVar.ValueFrom).ToNot(BeNil()) - Expect(OwningGatewayNamespaceEnvVar.ValueFrom.FieldRef).ToNot(BeNil()) - Expect(OwningGatewayNamespaceEnvVar.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.labels['gateway.envoyproxy.io/owning-gateway-namespace']")) - }) - - It("should not set owning gateway env vars in l7-log-collector for DaemonSet deployments", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - daemonSet := operatorv1.GatewayKindDaemonSet - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{ - Name: "tigera-gateway-class-daemonset", - GatewayKind: &daemonSet, - }}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - proxy, err := rtest.GetResourceOfType[*envoyapi.EnvoyProxy](objsToCreate, "tigera-gateway-class-daemonset", common.CalicoNamespace) - Expect(err).NotTo(HaveOccurred()) - - // DaemonSet should not have l7-log-collector or waf-http-filter - Expect(proxy.Spec.Provider.Kubernetes.EnvoyDaemonSet).ToNot(BeNil()) - Expect(proxy.Spec.Provider.Kubernetes.EnvoyDeployment).To(BeNil()) - // DaemonSet init containers are not supported, so these should not be present - // This is expected behavior as mentioned in the code comments - }) - - It("should create correct shared WAF ClusterRoles for L7 log collector enrichment", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - - // Verify cluster-scoped ClusterRole exists with license key + token review rules. - csRole, err := rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-cluster-scoped", "") - Expect(err).NotTo(HaveOccurred()) - Expect(csRole.Rules).To(HaveLen(2)) - Expect(csRole.Rules).To(ContainElement(rbacv1.PolicyRule{ - APIGroups: []string{"crd.projectcalico.org", "projectcalico.org"}, - Resources: []string{"licensekeys"}, - Verbs: []string{"get", "watch"}, - })) - Expect(csRole.Rules).To(ContainElement(rbacv1.PolicyRule{ - APIGroups: []string{"authentication.k8s.io"}, - Resources: []string{"tokenreviews"}, - Verbs: []string{"create"}, - })) - - // Verify gateway-resources ClusterRole exists with route rules only. - grRole, err := rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-gateway-resources", "") - Expect(err).NotTo(HaveOccurred()) - Expect(grRole.Rules).To(HaveLen(1)) - Expect(grRole.Rules).To(ContainElement(rbacv1.PolicyRule{ - APIGroups: []string{"gateway.networking.k8s.io"}, - Resources: []string{"gateways", "httproutes", "grpcroutes"}, - Verbs: []string{"get", "list", "watch"}, - })) - - // With no GatewayNamespaces declared, no per-namespace SAs or CRBs/RoleBindings - // are emitted — they only appear when a Gateway is created in a user namespace. - _, err = rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, GatewayNamespacesCRBName, "") - Expect(err).To(HaveOccurred()) - }) - - It("renders the shared WAF CRB with a subject per Gateway namespace; per-namespace resources are controller-managed (Enterprise)", func() { - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise}, - GatewayAPI: &operatorv1.GatewayAPI{Spec: operatorv1.GatewayAPISpec{GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}}}, - PullSecrets: []*corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "tigera-pull-secret", Namespace: "tigera-operator"}}}, - GatewayNamespaces: []string{"default", "app-ns"}, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - - // The shared CRB carries one subject per Gateway namespace. - crb, err := rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, GatewayNamespacesCRBName, "") - Expect(err).NotTo(HaveOccurred()) - Expect(crb.RoleRef.Name).To(Equal("waf-http-filter-cluster-scoped")) - nsSubjects := []string{} - for _, s := range crb.Subjects { - nsSubjects = append(nsSubjects, s.Namespace) - } - Expect(nsSubjects).To(ConsistOf("default", "app-ns")) - - // The per-namespace SA / RoleBinding / pull-secret are written by the controller (Gateway-owned), - // not rendered here. - _, err = rtest.GetResourceOfType[*corev1.ServiceAccount](objsToCreate, "waf-http-filter", "default") - Expect(err).To(HaveOccurred()) - _, err = rtest.GetResourceOfType[*rbacv1.RoleBinding](objsToCreate, "waf-http-filter-gateway-resources", "default") - Expect(err).To(HaveOccurred()) - _, err = rtest.GetResourceOfType[*corev1.Secret](objsToCreate, "tigera-pull-secret", "default") - Expect(err).To(HaveOccurred()) - }) - It("should not legacy-delete operator resources that the per-NS loop is re-creating in tigera-gateway", func() { // User Gateway in tigera-gateway: per-NS create must win over legacy delete. pullSecret := &corev1.Secret{ @@ -1580,37 +966,6 @@ value: } }) - It("should not create per-namespace resources when no Gateway namespaces are provided (Enterprise)", func() { - installation := &operatorv1.InstallationSpec{ - Variant: operatorv1.CalicoEnterprise, - } - gatewayAPI := &operatorv1.GatewayAPI{ - Spec: operatorv1.GatewayAPISpec{ - GatewayClasses: []operatorv1.GatewayClassSpec{{Name: "tigera-gateway-class"}}, - }, - } - gatewayComp, gatewayCompErr := GatewayAPIImplementationComponent(&GatewayAPIImplementationConfig{ - Scheme: testScheme(), - Installation: installation, - GatewayAPI: gatewayAPI, - IncludeV3NetworkPolicy: true, - }) - Expect(gatewayCompErr).NotTo(HaveOccurred()) - - objsToCreate, _ := gatewayComp.Objects() - - // With no GatewayNamespaces declared, no shared per-namespace CRB is created. - _, err := rtest.GetResourceOfType[*rbacv1.ClusterRoleBinding](objsToCreate, GatewayNamespacesCRBName, "") - Expect(err).To(HaveOccurred()) - - // Shared WAF ClusterRoles must always be present on Enterprise so per-namespace - // CRBs can bind to them once a Gateway shows up. - _, err = rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-cluster-scoped", "") - Expect(err).NotTo(HaveOccurred()) - _, err = rtest.GetResourceOfType[*rbacv1.ClusterRole](objsToCreate, "waf-http-filter-gateway-resources", "") - Expect(err).NotTo(HaveOccurred()) - }) - It("should deploy a single envoy-gateway controller in calico-system", func() { installation := &operatorv1.InstallationSpec{Variant: operatorv1.CalicoEnterprise} gatewayAPI := &operatorv1.GatewayAPI{ @@ -1768,10 +1123,6 @@ value: rtest.ExpectResourceInList(objsToDelete, "tigera-gateway-api-gateway-helm-infra-manager", "tigera-gateway", "rbac.authorization.k8s.io", "v1", "RoleBinding") rtest.ExpectResourceInList(objsToDelete, "tigera-gateway-api-gateway-helm-leader-election-role", "tigera-gateway", "rbac.authorization.k8s.io", "v1", "Role") rtest.ExpectResourceInList(objsToDelete, "tigera-gateway-api-gateway-helm-leader-election-rolebinding", "tigera-gateway", "rbac.authorization.k8s.io", "v1", "RoleBinding") - // Enterprise-only WAF SA + the orphaned legacy CRBs that bound it. - rtest.ExpectResourceInList(objsToDelete, "waf-http-filter", "tigera-gateway", "", "v1", "ServiceAccount") - rtest.ExpectResourceInList(objsToDelete, "waf-http-filter-cluster-scoped", "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding") - rtest.ExpectResourceInList(objsToDelete, "waf-http-filter-gateway-resources", "", "rbac.authorization.k8s.io", "v1", "ClusterRoleBinding") // Operator-secrets RoleBinding + cluster-scoped legacy bits. rtest.ExpectResourceInList(objsToDelete, "tigera-operator-secrets", "tigera-gateway", "rbac.authorization.k8s.io", "v1", "RoleBinding") rtest.ExpectResourceInList(objsToDelete, "envoy-gateway-topology-injector.tigera-gateway", "", "admissionregistration.k8s.io", "v1", "MutatingWebhookConfiguration") diff --git a/test/gatewayapi_test.go b/test/gatewayapi_test.go index 4b3fe1f587..f8cb8fd540 100644 --- a/test/gatewayapi_test.go +++ b/test/gatewayapi_test.go @@ -42,6 +42,8 @@ import ( "github.com/tigera/operator/pkg/controller/options" "github.com/tigera/operator/pkg/controller/utils" "github.com/tigera/operator/pkg/dns" + "github.com/tigera/operator/pkg/enterprise" + eoptions "github.com/tigera/operator/pkg/enterprise/options" gapi "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/yaml" // gopkg.in/yaml.v2 didn't parse all the fields but this package did ) @@ -64,6 +66,7 @@ var _ = Describe("GatewayAPI tests", func() { }).SetupWithManager(mgr, options.ControllerOptions{ DetectedProvider: operator.ProviderNone, Variant: operator.CalicoEnterprise, + Extensions: enterprise.New(operator.CalicoEnterprise, eoptions.Options{}), ManageCRDs: ManageCRDsDisable, ShutdownContext: shutdownContext, K8sClientset: clientset,