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,