From fa5aae514751a2f5653f4ef0bbb4fe9956c25e9d Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 29 May 2026 14:03:08 +0100 Subject: [PATCH 01/12] feat(api): add GatewayAPI WAF extension gating field Add spec.extensions.waf.state (+ IsWAFGatewayExtensionEnabled helper) to the GatewayAPI CR to gate the WAF v3 (Gateway API add-on) surface, default-off. Regenerate deepcopy + CRD manifest. Refs EV-6657 --- api/v1/gatewayapi_types.go | 45 +++++++++++++++++++ api/v1/zz_generated.deepcopy.go | 45 +++++++++++++++++++ .../operator.tigera.io_gatewayapis.yaml | 25 +++++++++++ 3 files changed, 115 insertions(+) diff --git a/api/v1/gatewayapi_types.go b/api/v1/gatewayapi_types.go index 7e56ae8ede..857ccf3817 100644 --- a/api/v1/gatewayapi_types.go +++ b/api/v1/gatewayapi_types.go @@ -79,6 +79,51 @@ type GatewayAPISpec struct { // does not yet have any version of those CRDs. // +optional CRDManagement *CRDManagement `json:"crdManagement,omitempty"` + + // Extensions enables and configures Tigera-built add-ons that sit on top of the + // Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + // add-on field, and an empty add-on object all leave the add-on disabled. + // +optional + Extensions *GatewayAPIExtensions `json:"extensions,omitempty"` +} + +// GatewayAPIExtensions enables and configures Tigera-built Gateway API add-ons. +type GatewayAPIExtensions struct { + // WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + // + applicationlayer reconcilers). Default-off semantics: when WAF is nil, + // when WAF.State is nil, and when WAF.State is "Disabled", the operator does + // not render the WAF env vars or RBAC on calico-kube-controllers. Set + // WAF.State = "Enabled" to turn the feature on. See design + // `tigera/designs#25` (PMREQ-384) for the full surface. + // +optional + WAF *WAFExtensionSpec `json:"waf,omitempty"` +} + +// WAFExtensionSpec configures the WAF Gateway API add-on. +type WAFExtensionSpec struct { + // State turns the WAF Gateway API add-on on or off. Default (nil or + // "Disabled") means the operator does not render the WAF surface on + // calico-kube-controllers. Set to "Enabled" to opt in. + // +optional + State *WAFExtensionState `json:"state,omitempty"` +} + +// WAFExtensionState is the on/off enum for the WAF Gateway API add-on. +// +kubebuilder:validation:Enum=Enabled;Disabled +type WAFExtensionState string + +const ( + WAFExtensionStateEnabled WAFExtensionState = "Enabled" + WAFExtensionStateDisabled WAFExtensionState = "Disabled" +) + +// IsWAFGatewayExtensionEnabled returns true iff spec.extensions.waf.state == Enabled. +// Unset Extensions, unset WAF, unset State, and explicit Disabled all return false. +func (s *GatewayAPISpec) IsWAFGatewayExtensionEnabled() bool { + if s == nil || s.Extensions == nil || s.Extensions.WAF == nil || s.Extensions.WAF.State == nil { + return false + } + return *s.Extensions.WAF.State == WAFExtensionStateEnabled } type GatewayClassSpec struct { diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index dead9aa130..2d0faa7fd4 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -4362,6 +4362,26 @@ func (in *GatewayAPI) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayAPIExtensions) DeepCopyInto(out *GatewayAPIExtensions) { + *out = *in + if in.WAF != nil { + in, out := &in.WAF, &out.WAF + *out = new(WAFExtensionSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPIExtensions. +func (in *GatewayAPIExtensions) DeepCopy() *GatewayAPIExtensions { + if in == nil { + return nil + } + out := new(GatewayAPIExtensions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayAPIList) DeepCopyInto(out *GatewayAPIList) { *out = *in @@ -4424,6 +4444,11 @@ func (in *GatewayAPISpec) DeepCopyInto(out *GatewayAPISpec) { *out = new(CRDManagement) **out = **in } + if in.Extensions != nil { + in, out := &in.Extensions, &out.Extensions + *out = new(GatewayAPIExtensions) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPISpec. @@ -9893,6 +9918,26 @@ func (in *UserSearch) DeepCopy() *UserSearch { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WAFExtensionSpec) DeepCopyInto(out *WAFExtensionSpec) { + *out = *in + if in.State != nil { + in, out := &in.State, &out.State + *out = new(WAFExtensionState) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WAFExtensionSpec. +func (in *WAFExtensionSpec) DeepCopy() *WAFExtensionSpec { + if in == nil { + return nil + } + out := new(WAFExtensionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Whisker) DeepCopyInto(out *Whisker) { *out = *in diff --git a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml index ef44739d46..9416586be6 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml @@ -83,6 +83,31 @@ spec: - name - namespace type: object + extensions: + description: |- + Extensions enables and configures Tigera-built add-ons that sit on top of the + Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + add-on field, and an empty add-on object all leave the add-on disabled. + properties: + waf: + description: |- + WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + when WAF.State is nil, and when WAF.State is "Disabled", the operator does + not render the WAF env vars or RBAC on calico-kube-controllers. Set + WAF.State = "Enabled" to turn the feature on. See design + `tigera/designs#25` (PMREQ-384) for the full surface. + properties: + state: + description: |- + State turns the WAF Gateway API add-on on or off. Default (nil or + "Disabled") means the operator does not render the WAF surface on + calico-kube-controllers. Set to "Enabled" to opt in. + enum: + - Enabled + - Disabled + type: string + type: object + type: object gatewayCertgenJob: description: Allows customization of the gateway certgen job. properties: From 3a3b7a4adc7e0e962c24bf5fbbb6441644c18eaf Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 29 May 2026 15:49:44 +0100 Subject: [PATCH 02/12] feat(applicationlayer): render WAF v3 + in-process admission webhook Render the WAF v3 (Coraza WASM) surface on calico-kube-controllers, gated on the GatewayAPI WAF extension: - WASM_IMAGE/WASM_PULL_SECRET/WASM_CA_CERT env, ENABLED_CONTROLLERS, reconciler RBAC (wafpolicies/plugins, EnvoyExtensionPolicy, events, secret replication), coraza-wasm component (config/enterprise_versions.yml + gen-versions template + generated enterprise.go) + GatewayAddonsFeature constant. - In-process WAF SecLang validating admission webhook: a Service fronting the kube-controllers Pod + ValidatingWebhookConfiguration (wafplugins/wafpolicies, /validate-waf, FailurePolicy=Fail, caBundle=operator CA); the serving-cert mount + WAF_WEBHOOK_CERT_DIR env + container port 9443; and namespaces patch/update RBAC for the waf-id-range annotation. Refs EV-6657 --- config/enterprise_versions.yml | 3 + hack/gen-versions/enterprise.go.tpl | 10 + pkg/common/common.go | 4 + pkg/components/enterprise.go | 9 + pkg/render/applicationlayer/gateway_waf.go | 155 ++++++++++++++ .../applicationlayer/gateway_waf_test.go | 90 +++++++++ .../kubecontrollers/kube-controllers.go | 191 +++++++++++++++++- .../kubecontrollers/kube-controllers_test.go | 148 +++++++++++++- 8 files changed, 604 insertions(+), 6 deletions(-) create mode 100644 pkg/render/applicationlayer/gateway_waf.go create mode 100644 pkg/render/applicationlayer/gateway_waf_test.go diff --git a/config/enterprise_versions.yml b/config/enterprise_versions.yml index e5761a627a..0027779df2 100644 --- a/config/enterprise_versions.yml +++ b/config/enterprise_versions.yml @@ -84,6 +84,9 @@ components: dikastes: image: dikastes version: master + coraza-wasm: + image: coraza-wasm + version: master egress-gateway: image: egress-gateway version: master diff --git a/hack/gen-versions/enterprise.go.tpl b/hack/gen-versions/enterprise.go.tpl index 7ed9089073..264b34fb5f 100644 --- a/hack/gen-versions/enterprise.go.tpl +++ b/hack/gen-versions/enterprise.go.tpl @@ -180,6 +180,15 @@ var ( variant: enterpriseVariant, } {{- end }} +{{ with index .Components "coraza-wasm" }} + ComponentCorazaWASM = Component{ + Version: "{{ .Version }}", + Image: "{{ .Image }}", + Registry: "{{ .Registry }}", + imagePath: "{{ .ImagePath }}", + variant: enterpriseVariant, + } +{{- end }} {{ with index .Components "coreos-prometheus" }} ComponentCoreOSPrometheus = Component{ Version: "{{ .Version }}", @@ -316,6 +325,7 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/common/common.go b/pkg/common/common.go index 485046c415..45260b0952 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -36,6 +36,10 @@ const ( EgressAccessControlFeature = "egress-access-control" // PolicyRecommendation feature name PolicyRecommendationFeature = "policy-recommendation" + // GatewayAddonsFeature gates Tigera-built add-ons that layer on top of an + // ingress gateway (currently the WAF v2/v3 admission webhook). The bare + // ingress gateway data path is NOT licensed by this feature. + GatewayAddonsFeature = "ingress-gateway-addons" // MultipleOwnersLabel used to indicate multiple owner references. // If the render code places this label on an object, the object mergeState machinery will merge owner // references with any that already exist on the object rather than replace the owner references. Further diff --git a/pkg/components/enterprise.go b/pkg/components/enterprise.go index 3753ca105d..009d6581d6 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -162,6 +162,14 @@ var ( variant: enterpriseVariant, } + ComponentCorazaWASM = Component{ + Version: "master", + Image: "coraza-wasm", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + ComponentCoreOSPrometheus = Component{ Version: "v3.9.1", variant: enterpriseVariant, @@ -283,6 +291,7 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/render/applicationlayer/gateway_waf.go b/pkg/render/applicationlayer/gateway_waf.go new file mode 100644 index 0000000000..41dcac85c6 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf.go @@ -0,0 +1,155 @@ +// 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 applicationlayer + +import ( + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/tigera/operator/pkg/common" +) + +const ( + // WAFWebhookServerTLSSecretName is the serving-cert Secret for the in-process + // WAF admission webhook, issued for the WAFWebhookServiceName DNS name and + // mounted into calico-kube-controllers. + WAFWebhookServerTLSSecretName = "calico-kube-controllers-waf-webhook-tls" + + // WAFWebhookServiceName fronts the WAF SecLang validating admission webhook. + // The webhook is served in-process by the calico-kube-controllers Pod (see + // tigera/calico-private kube-controllers applicationlayer manager), so this + // Service selects the kube-controllers Pod rather than a dedicated + // Deployment. The webhook serving certificate is issued for this Service's + // DNS name and mounted into kube-controllers (see pkg/render/kubecontrollers). + WAFWebhookServiceName = "tigera-waf-webhook" + + // wafWebhookContainerPort is the in-process webhook server port on the + // calico-kube-controllers Pod (controller-runtime webhook server). Must match + // the port the kube-controllers applicationlayer manager listens on. + wafWebhookContainerPort = int32(9443) + + // wafWebhookPath is the admission path the kube-controllers webhook server + // registers. Must match WAFWebhookPath in the calico-private applicationlayer + // manager. + wafWebhookPath = "/validate-waf" + + // wafWebhookConfigName / wafWebhookName name the ValidatingWebhookConfiguration + // and its single webhook entry. + wafWebhookConfigName = "tigera-waf.applicationlayer.projectcalico.org" + wafWebhookName = "waf.applicationlayer.projectcalico.org" +) + +// WAFAdmissionWebhookComponents returns the objects required to expose the WAF +// SecLang validating admission webhook: a Service fronting the +// calico-kube-controllers Pod and the ValidatingWebhookConfiguration that points +// at it. The webhook itself runs in-process inside calico-kube-controllers — no +// separate Deployment, ServiceAccount, or ClusterRole; it reuses the +// kube-controllers ServiceAccount and ClusterRole (RBAC is rendered in +// pkg/render/kubecontrollers). The caller passes caBundle — the PEM of the CA +// that issued the webhook serving cert (the operator CA), so the apiserver can +// verify the in-process webhook endpoint. +// +// The caller is responsible for invoking this only when the gateway-addons +// license feature is present and the GatewayAPI WAF extension is enabled. +func WAFAdmissionWebhookComponents(caBundle []byte) []client.Object { + return []client.Object{ + wafWebhookService(), + wafValidatingWebhookConfiguration(caBundle), + } +} + +// wafWebhookService fronts the in-process webhook on the calico-kube-controllers +// Pod. The selector matches the kube-controllers Pod label (k8s-app), and the +// service port (443) forwards to the in-process webhook container port. +func wafWebhookService() *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookServiceName, + Namespace: common.CalicoNamespace, + Labels: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt32(wafWebhookContainerPort), + }, + }, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +// wafValidatingWebhookConfiguration rejects unsafe AO-supplied SecLang at +// admission. It intercepts CREATE/UPDATE on WAFPlugin and WAFPolicy (the +// resources that carry AO SecLang) and fails closed: FailurePolicy=Fail so an +// unavailable webhook blocks the (infrequent) WAF resource writes rather than +// admitting unvalidated directives. The in-cluster reconciler backstop is +// status-only, so the webhook is the hard admission gate. +func wafValidatingWebhookConfiguration(caBundle []byte) *admissionregistrationv1.ValidatingWebhookConfiguration { + failPolicy := admissionregistrationv1.Fail + sideEffects := admissionregistrationv1.SideEffectClassNone + timeoutSeconds := int32(10) + + return &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: "admissionregistration.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: wafWebhookConfigName, + Labels: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: wafWebhookName, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Update, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + APIVersions: []string{"v3"}, + Resources: []string{"wafplugins", "wafpolicies"}, + Scope: ptr.To(admissionregistrationv1.NamespacedScope), + }, + }, + }, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: common.CalicoNamespace, + Name: WAFWebhookServiceName, + Path: ptr.To(wafWebhookPath), + }, + CABundle: caBundle, + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + TimeoutSeconds: &timeoutSeconds, + FailurePolicy: &failPolicy, + }, + }, + } +} diff --git a/pkg/render/applicationlayer/gateway_waf_test.go b/pkg/render/applicationlayer/gateway_waf_test.go new file mode 100644 index 0000000000..eea74ddf40 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf_test.go @@ -0,0 +1,90 @@ +// 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 applicationlayer_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + + "github.com/tigera/operator/pkg/render/applicationlayer" +) + +var fakeCABundle = []byte("fake-ca-bundle") + +// The webhook runs in-process in calico-kube-controllers, so the render emits +// only a Service fronting the kube-controllers Pod plus the +// ValidatingWebhookConfiguration — no Deployment/ServiceAccount/ClusterRole. +func TestWAFAdmissionWebhookComponents_HasExpectedKinds(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + got := map[string]int{} + for _, o := range objs { + got[o.GetObjectKind().GroupVersionKind().Kind]++ + } + require.Len(t, objs, 2, "expected exactly 2 objects (Service + ValidatingWebhookConfiguration)") + require.Equal(t, 1, got["Service"], "expected 1 Service") + require.Equal(t, 1, got["ValidatingWebhookConfiguration"], "expected 1 ValidatingWebhookConfiguration") + require.Zero(t, got["Deployment"], "in-process webhook must not render a Deployment") + require.Zero(t, got["ServiceAccount"], "in-process webhook reuses the kube-controllers ServiceAccount") + require.Zero(t, got["ClusterRole"], "in-process webhook reuses the kube-controllers ClusterRole") +} + +// The Service must front the calico-kube-controllers Pod and forward to the +// in-process webhook port (9443). +func TestWAFAdmissionWebhookComponents_ServiceFrontsKubeControllers(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + var svc *corev1.Service + for _, o := range objs { + if s, ok := o.(*corev1.Service); ok { + svc = s + } + } + require.NotNil(t, svc, "expected a Service") + require.Equal(t, "calico-kube-controllers", svc.Spec.Selector["k8s-app"], "Service must select the kube-controllers Pod") + require.Len(t, svc.Spec.Ports, 1) + require.Equal(t, int32(443), svc.Spec.Ports[0].Port) + require.Equal(t, int32(9443), svc.Spec.Ports[0].TargetPort.IntVal, "must forward to the in-process webhook port") +} + +// The webhook must intercept WAFPlugin/WAFPolicy on the /validate-waf path, +// carry the supplied CA bundle, and fail closed. +func TestWAFAdmissionWebhookComponents_WebhookContract(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + var vwc *admissionregistrationv1.ValidatingWebhookConfiguration + for _, o := range objs { + if w, ok := o.(*admissionregistrationv1.ValidatingWebhookConfiguration); ok { + vwc = w + } + } + require.NotNil(t, vwc, "expected a ValidatingWebhookConfiguration") + require.Len(t, vwc.Webhooks, 1) + wh := vwc.Webhooks[0] + + require.Len(t, wh.Rules, 1) + require.ElementsMatch(t, []string{"wafplugins", "wafpolicies"}, wh.Rules[0].Resources) + require.Equal(t, []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, admissionregistrationv1.Update, + }, wh.Rules[0].Operations) + + require.NotNil(t, wh.ClientConfig.Service) + require.Equal(t, "tigera-waf-webhook", wh.ClientConfig.Service.Name) + require.Equal(t, "/validate-waf", *wh.ClientConfig.Service.Path) + require.Equal(t, fakeCABundle, wh.ClientConfig.CABundle, "caBundle must be the supplied issuing-CA PEM") + + require.NotNil(t, wh.FailurePolicy) + require.Equal(t, admissionregistrationv1.Fail, *wh.FailurePolicy, "webhook must fail closed") +} diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 7db1c163fd..c2f535adcd 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -16,6 +16,7 @@ package kubecontrollers import ( "fmt" + "path/filepath" "slices" "strconv" "strings" @@ -55,6 +56,12 @@ const ( KubeControllerMetrics = "calico-kube-controllers-metrics" KubeControllerNetworkPolicyName = networkpolicy.CalicoComponentPolicyPrefix + "kube-controller-access" + // wafWebhookContainerPort is the in-process WAF admission-webhook server + // port on calico-kube-controllers. Must match the TargetPort of the + // tigera-waf-webhook Service (see pkg/render/applicationlayer) and the port + // the calico-private applicationlayer manager's webhook server listens on. + wafWebhookContainerPort = int32(9443) + EsKubeController = "es-calico-kube-controllers" EsKubeControllerRole = "es-calico-kube-controllers" EsKubeControllerRoleBinding = "es-calico-kube-controllers" @@ -108,6 +115,23 @@ type KubeControllersConfiguration struct { // Tenant object provides tenant configuration for both single and multi-tenant modes. // If this is nil, then we should run in zero-tenant mode. Tenant *operatorv1.Tenant + + // WAFGatewayExtensionEnabled gates the WAF v3 (Gateway API add-on) surface + // on calico-kube-controllers: the applicationlayer controller enablement, + // the WAF / Gateway-API / EnvoyExtensionPolicy / event / secret-replication + // RBAC, the WASM_IMAGE / WASM_PULL_SECRET / WASM_CA_CERT env vars, and the + // coraza-wasm image resolution. Sourced from + // `GatewayAPI.spec.extensions.waf.state == Enabled` (default off). + // See design `tigera/designs#25` (PMREQ-384). + WAFGatewayExtensionEnabled bool + + // WAFWebhookServerTLS is the serving certificate for the in-process WAF + // SecLang validating admission webhook hosted by calico-kube-controllers. + // When set (WAF enabled), it is mounted into the Pod and the webhook server + // reads it from WAF_WEBHOOK_CERT_DIR. Issued for the tigera-waf-webhook + // Service DNS name. Nil leaves the Deployment untouched (and the in-process + // server self-disables when the cert is absent). + WAFWebhookServerTLS certificatemanagement.KeyPairInterface } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { @@ -155,6 +179,9 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController }, ) enabledControllers = append(enabledControllers, "service", "federatedservices", "usage") + if cfg.WAFGatewayExtensionEnabled { + enabledControllers = append(enabledControllers, "applicationlayer") + } } return &kubeControllersComponent{ @@ -234,6 +261,12 @@ type kubeControllersComponent struct { kubeControllerCalicoSystemPolicy *v3.NetworkPolicy enabledControllers []string + + // wasmImage is the fully-resolved OCI reference for the Coraza WAF wasm + // binary (Enterprise only). Surfaced to the kube-controllers binary via + // the WASM_IMAGE env var; consumed by the applicationlayer reconcilers + // in tigera/calico-private to program WAF policy attachments. + wasmImage string } func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error { @@ -242,7 +275,16 @@ func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error prefix := c.cfg.Installation.ImagePrefix var err error c.calicoImage, err = components.GetReference(components.CombinedCalicoImage(c.cfg.Installation), reg, path, prefix, is) - return err + if err != nil { + return err + } + if c.cfg.Installation.Variant.IsEnterprise() && c.cfg.WAFGatewayExtensionEnabled { + c.wasmImage, err = components.GetReference(components.ComponentCorazaWASM, reg, path, prefix, is) + if err != nil { + return err + } + } + return nil } func (c *kubeControllersComponent) SupportedOSType() rmeta.OSType { @@ -476,6 +518,92 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) }, } + if cfg.WAFGatewayExtensionEnabled { + // WAF v3 (Gateway API add-on) RBAC. Gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. + rules = append(rules, + // Application-layer (gateway-addons) reconcilers reconcile WAF resources + // against Gateway API targetRefs and emit events on the policy objects. + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + }, + rbacv1.PolicyRule{ + // Validate Gateway API targetRefs and surface attachment status. + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + }, + // controller-runtime Reconcilers (e.g. the applicationlayer manager) record + // events on watched objects via Recorder.Eventf; both core and events.k8s.io + // API groups are emitted depending on the kubernetes version. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + // Application-layer reconciler replicates the WAF wasm pull Secret from + // the controller namespace (calico-system) into each WAFPolicy's + // namespace so the rendered EnvoyExtensionPolicy can reference it. Also + // replicates CA-cert ConfigMaps when WASM_CA_CERT is set. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + // Application-layer reconciler emits one EnvoyExtensionPolicy per WAF + // targetRef to bind the Coraza wasm filter at the gateway / route. + rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + // Application-layer reconciler stamps each namespace with its + // allocated WAF rule-id range (applicationlayer.projectcalico.org/waf-id-range + // annotation) so application operators can author in-range rules. The + // base role already grants namespaces get/list/watch; the annotation + // write needs patch/update, gated to the WAF path. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "patch", "update"}, + }, + ) + } + if cfg.ManagementClusterConnection != nil { rules = append(rules, rbacv1.PolicyRule{ @@ -571,6 +699,39 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { if c.cfg.Installation.CalicoNetwork != nil && c.cfg.Installation.CalicoNetwork.MultiInterfaceMode != nil { env = append(env, corev1.EnvVar{Name: "MULTI_INTERFACE_MODE", Value: c.cfg.Installation.CalicoNetwork.MultiInterfaceMode.Value()}) } + + // Application-layer (gateway-addons / WAF v3) env vars, gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. When the gate is + // off (default), none of the WASM_* env vars are rendered and the + // kube-controllers binary skips the WAF reconcilers entirely (see the + // applicationlayer entry in enabledControllers). + if c.cfg.WAFGatewayExtensionEnabled { + // Application-layer (gateway-addons) reconcilers consume the Coraza WAF + // wasm OCI reference from this env var to program WAF policy attachments. + // Empty when ResolveImages was not called for the Calico variant; the + // reconciler stamps Programmed=False/WASMUnavailable in that case. + if c.wasmImage != "" { + env = append(env, corev1.EnvVar{Name: "WASM_IMAGE", Value: c.wasmImage}) + } + + // WASM_PULL_SECRET names the imagePullSecret the reconciler replicates + // from the kube-controllers namespace into a WAFPolicy's namespace so + // the rendered EnvoyExtensionPolicy can pull the wasm OCI artifact from + // a private Tigera registry. Source the name from the first + // Installation.ImagePullSecrets entry so multi-tenant / BYO-registry + // installs reuse whatever pull secret operator already attaches here. + if len(c.cfg.Installation.ImagePullSecrets) > 0 { + env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.Installation.ImagePullSecrets[0].Name}) + } + + // WASM_CA_CERT names the trusted CA bundle ConfigMap (already mounted + // on this Deployment via TrustedBundle) that the reconciler replicates + // alongside WASM_PULL_SECRET so the EnvoyExtensionPolicy wasm fetcher + // trusts the registry's TLS chain. + if c.cfg.TrustedBundle != nil { + env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName}) + } + } } if c.cfg.MetricsServerTLS != nil { @@ -585,6 +746,15 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { corev1.EnvVar{Name: "CA_CRT_PATH", Value: c.cfg.TrustedBundle.MountPath()}, ) } + if c.cfg.WAFWebhookServerTLS != nil { + // The in-process WAF admission webhook server (calico-private + // applicationlayer manager) reads its serving cert (tls.crt/tls.key) + // from this directory; the controller-runtime webhook server only + // registers when the cert is present. + env = append(env, + corev1.EnvVar{Name: "WAF_WEBHOOK_CERT_DIR", Value: filepath.Dir(c.cfg.WAFWebhookServerTLS.VolumeMountCertificateFilePath())}, + ) + } // UID 999 is used in kube-controller Dockerfile. sc := securitycontext.NewNonRootContext() @@ -628,6 +798,16 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { VolumeMounts: c.kubeControllersVolumeMounts(), } + if c.cfg.WAFWebhookServerTLS != nil { + // Expose the in-process WAF admission-webhook port that the + // tigera-waf-webhook Service forwards to. + container.Ports = append(container.Ports, corev1.ContainerPort{ + Name: "waf-webhook", + ContainerPort: wafWebhookContainerPort, + Protocol: corev1.ProtocolTCP, + }) + } + if c.kubeControllerName == EsKubeController && !c.cfg.Tenant.MultiTenant() { _, esHost, esPort, _ := url.ParseEndpoint(relasticsearch.GatewayEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, render.ElasticsearchNamespace)) container.Env = append(container.Env, []corev1.EnvVar{ @@ -643,6 +823,9 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { if c.cfg.MetricsServerTLS != nil && c.cfg.MetricsServerTLS.UseCertificateManagement() { initContainers = append(initContainers, c.cfg.MetricsServerTLS.InitContainer(c.cfg.Namespace, sc)) } + if c.cfg.WAFWebhookServerTLS != nil && c.cfg.WAFWebhookServerTLS.UseCertificateManagement() { + initContainers = append(initContainers, c.cfg.WAFWebhookServerTLS.InitContainer(c.cfg.Namespace, sc)) + } tolerations := appendUniqueTolerations(c.cfg.Installation.ControlPlaneTolerations, rmeta.TolerateCriticalAddonsAndControlPlane...) if c.cfg.Installation.KubernetesProvider.IsGKE() { tolerations = appendUniqueTolerations(tolerations, rmeta.TolerateGKEARM64NoSchedule) @@ -793,6 +976,9 @@ func (c *kubeControllersComponent) kubeControllersVolumeMounts() []corev1.Volume if c.cfg.MetricsServerTLS != nil { mounts = append(mounts, c.cfg.MetricsServerTLS.VolumeMount(c.SupportedOSType())) } + if c.cfg.WAFWebhookServerTLS != nil { + mounts = append(mounts, c.cfg.WAFWebhookServerTLS.VolumeMount(c.SupportedOSType())) + } return mounts } @@ -804,6 +990,9 @@ func (c *kubeControllersComponent) kubeControllersVolumes() []corev1.Volume { if c.cfg.MetricsServerTLS != nil { volumes = append(volumes, c.cfg.MetricsServerTLS.Volume()) } + if c.cfg.WAFWebhookServerTLS != nil { + volumes = append(volumes, c.cfg.WAFWebhookServerTLS.Volume()) + } return volumes } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index e56170908f..b511f2af5a 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -16,6 +16,7 @@ package kubecontrollers_test import ( "fmt" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -40,6 +41,7 @@ import ( ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" "github.com/tigera/operator/pkg/dns" "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/applicationlayer" rmeta "github.com/tigera/operator/pkg/render/common/meta" "github.com/tigera/operator/pkg/render/common/networkpolicy" rtest "github.com/tigera/operator/pkg/render/common/test" @@ -244,7 +246,14 @@ var _ = Describe("kube-controllers rendering tests", func() { } instance.Variant = operatorv1.CalicoEnterprise + // Pull secret on the Installation propagates through the Deployment's + // imagePullSecrets and is also surfaced via WASM_PULL_SECRET so the + // applicationlayer reconciler can reference it from rendered + // EnvoyExtensionPolicies in WAFPolicy namespaces. + instance.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "tigera-pull-secret"}} cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -262,16 +271,95 @@ var _ = Describe("kube-controllers rendering tests", func() { dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) + Expect(dp.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(corev1.LocalObjectReference{Name: "tigera-pull-secret"})) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + })) + // Application-layer reconcilers consume these env vars to program WAF + // EnvoyExtensionPolicy attachments. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_IMAGE", Value: "test-reg/tigera/coraza-wasm:" + components.ComponentCorazaWASM.Version, + })) + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_PULL_SECRET", Value: "tigera-pull-secret", + })) + // TrustedBundle is set on the configuration above, so WASM_CA_CERT + // names the standard tigera trusted-bundle ConfigMap. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName, })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) Expect(len(dp.Spec.Template.Spec.Volumes)).To(Equal(1)) clusterRole := rtest.GetResource(resources, kubecontrollers.KubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(28), "cluster role should have 28 rules") + Expect(clusterRole.Rules).To(HaveLen(38), "cluster role should have 38 rules") + + // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + })) + // Gateway API targetRef validation + status patching. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + })) + // Recorder.Eventf emits to both core/events and events.k8s.io/events. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + // Cluster-wide secrets+configmaps CRUD: reconciler replicates pull + // secrets and CA bundles from the controller namespace into target + // WAFPolicy namespaces. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + // EnvoyExtensionPolicy CRUD: reconciler renders one EEP per WAF targetRef. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) ms := rtest.GetResource(resources, kubecontrollers.KubeControllerMetrics, common.CalicoNamespace, "", "v1", "Service").(*corev1.Service) Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless") @@ -326,6 +414,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.LogStorageExists = true cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -358,7 +448,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Volumes[0].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(26), "cluster role should have 26 rules") + Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, @@ -393,6 +483,8 @@ var _ = Describe("kube-controllers rendering tests", func() { instance.Variant = operatorv1.CalicoEnterprise cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -412,7 +504,7 @@ var _ = Describe("kube-controllers rendering tests", func() { envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "ENABLED_CONTROLLERS", - Value: "node,loadbalancer,service,federatedservices,usage", + Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) @@ -512,6 +604,50 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) }) + It("should mount the WAF admission webhook serving cert and expose its port when WAF is enabled", func() { + certificateManager, err := certificatemanager.Create(cli, nil, dns.DefaultClusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + wafTLS, err := certificateManager.GetOrCreateKeyPair(cli, + applicationlayer.WAFWebhookServerTLSSecretName, + common.OperatorNamespace(), + dns.GetServiceDNSNames(applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, dns.DefaultClusterDomain)) + Expect(err).NotTo(HaveOccurred()) + + instance.Variant = operatorv1.CalicoEnterprise + cfg.WAFGatewayExtensionEnabled = true + cfg.WAFWebhookServerTLS = wafTLS + + component := kubecontrollers.NewCalicoKubeControllers(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) + c := dp.Spec.Template.Spec.Containers[0] + + // Serving cert is mounted and advertised to the in-process webhook server. + Expect(dp.Spec.Template.Spec.Volumes).To(ContainElement(wafTLS.Volume())) + Expect(c.VolumeMounts).To(ContainElement(wafTLS.VolumeMount(rmeta.OSTypeLinux))) + Expect(c.Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_WEBHOOK_CERT_DIR", + Value: filepath.Dir(wafTLS.VolumeMountCertificateFilePath()), + })) + + // In-process webhook port exposed for the tigera-waf-webhook Service. + Expect(c.Ports).To(ContainElement(corev1.ContainerPort{ + Name: "waf-webhook", + ContainerPort: int32(9443), + Protocol: corev1.ProtocolTCP, + })) + + // namespaces patch/update RBAC for the waf-id-range annotation. + clusterRole := rtest.GetResource(resources, "calico-kube-controllers", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "patch", "update"}, + })) + }) + It("should render all es-calico-kube-controllers resources for a default configuration using CalicoEnterprise and ClusterType is Management", func() { expectedResources := []struct { name string @@ -536,6 +672,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -569,7 +707,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(26), "cluster role should have 26 rules") + Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, From cd949d8b662a0f0f7bfecb4b80c0a844352bee53 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 29 May 2026 15:49:44 +0100 Subject: [PATCH 03/12] feat(applicationlayer): wire WAF v3 render + webhook into installation controller Gate on GatewayAPI.spec.extensions.waf.state, issue the webhook serving cert for the tigera-waf-webhook Service DNS (materialized into calico-system via the existing CertificateManagement render), thread it into the kube-controllers config, and render the webhook Service + ValidatingWebhookConfiguration. Refs EV-6657 --- .../installation/core_controller.go | 72 ++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 71ed33394e..13160d5184 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -78,6 +78,7 @@ import ( "github.com/tigera/operator/pkg/imports/admission" "github.com/tigera/operator/pkg/imports/crds" "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/applicationlayer" rcertificatemanagement "github.com/tigera/operator/pkg/render/certificatemanagement" relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/networkpolicy" @@ -213,6 +214,13 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } // Watch for changes to KubeControllersConfiguration. + // Watch GatewayAPI: spec.extensions.waf.state gates the WAF v3 surface on + // calico-kube-controllers. See design tigera/designs#25 (PMREQ-384) §Gating. + if err := c.WatchObject(&operatorv1.GatewayAPI{}, &handler.EnqueueRequestForObject{}); err != nil { + log.V(5).Info("Failed to create GatewayAPI watch", "err", err) + return fmt.Errorf("core-controller failed to watch operator GatewayAPI resource: %w", err) + } + err = c.WatchObject(&v3.KubeControllersConfiguration{}, &handler.EnqueueRequestForObject{}) if err != nil { return fmt.Errorf("tigera-installation-controller failed to watch KubeControllersConfiguration resource: %w", err) @@ -1361,18 +1369,56 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile } + // Read the GatewayAPI CR (if present) to decide whether to render the WAF + // v3 (Gateway API add-on) surface — env vars, RBAC, applicationlayer + // reconciler, and the in-process admission webhook — on + // calico-kube-controllers. Default-off: if no GatewayAPI CR exists or + // spec.extensions.waf.state != Enabled, the WAF surface is not rendered. + // See design tigera/designs#25 (PMREQ-384) §Gating. + wafGatewayExtensionEnabled := false + gatewayAPI := &operatorv1.GatewayAPI{} + if err := r.client.Get(ctx, utils.DefaultInstanceKey, gatewayAPI); err == nil { + wafGatewayExtensionEnabled = gatewayAPI.Spec.IsWAFGatewayExtensionEnabled() + } else if !apierrors.IsNotFound(err) { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading GatewayAPI", err, reqLogger) + return reconcile.Result{}, err + } + + // When the WAF v3 surface is enabled, issue the serving cert for the + // in-process WAF admission webhook (hosted by calico-kube-controllers, + // fronted by the tigera-waf-webhook Service). It is materialized into + // calico-system alongside the other kube-controllers certs below and mounted + // into the Pod by the kube-controllers render. + var wafWebhookTLS certificatemanagement.KeyPairInterface + if wafGatewayExtensionEnabled { + wafWebhookTLS, err = certificateManager.GetOrCreateKeyPair( + r.client, + applicationlayer.WAFWebhookServerTLSSecretName, + common.OperatorNamespace(), + dns.GetServiceDNSNames(applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, r.clusterDomain)) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating WAF admission webhook TLS certificate", err, reqLogger) + return reconcile.Result{}, err + } + } + + keyPairOptions := []rcertificatemanagement.KeyPairOption{ + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.NodeSecret, true, true), + rcertificatemanagement.NewKeyPairOption(nodePrometheusTLS, true, true), + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecret, true, true), + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecretNonClusterHost, true, true), + rcertificatemanagement.NewKeyPairOption(kubeControllerTLS, true, true), + } + if wafWebhookTLS != nil { + keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(wafWebhookTLS, true, true)) + } + components = append(components, rcertificatemanagement.CertificateManagement(&rcertificatemanagement.Config{ Namespace: common.CalicoNamespace, ServiceAccounts: []string{render.CalicoNodeObjectName, render.TyphaServiceAccountName, kubecontrollers.KubeControllerServiceAccount}, - KeyPairOptions: []rcertificatemanagement.KeyPairOption{ - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.NodeSecret, true, true), - rcertificatemanagement.NewKeyPairOption(nodePrometheusTLS, true, true), - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecret, true, true), - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecretNonClusterHost, true, true), - rcertificatemanagement.NewKeyPairOption(kubeControllerTLS, true, true), - }, - TrustedBundle: typhaNodeTLS.TrustedBundle, + KeyPairOptions: keyPairOptions, + TrustedBundle: typhaNodeTLS.TrustedBundle, })) // Check if non-cluster host feature is enabled. @@ -1619,9 +1665,19 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile TrustedBundle: typhaNodeTLS.TrustedBundle, Namespace: common.CalicoNamespace, BindingNamespaces: []string{common.CalicoNamespace}, + WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, + WAFWebhookServerTLS: wafWebhookTLS, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) + // Render the in-process WAF admission webhook Service + ValidatingWebhookConfiguration. + // The webhook is served by calico-kube-controllers; the caBundle is the + // operator CA that issued the serving cert above. + if wafGatewayExtensionEnabled { + components = append(components, render.NewPassthrough( + applicationlayer.WAFAdmissionWebhookComponents(certificateManager.KeyPair().GetCertificatePEM()), nil)) + } + // v3 NetworkPolicy will fail to reconcile if the API server deployment is unhealthy. In case the API Server // deployment becomes unhealthy and reconciliation of non-NetworkPolicy resources in the core controller // would resolve it, we render the network policies of components last to prevent a chicken-and-egg scenario. From 318e8d71cd9292b8c78dba7eb7f75fac015d41b9 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sun, 31 May 2026 00:58:39 +0100 Subject: [PATCH 04/12] fix(kubecontrollers): allow apiserver ingress to WAF admission webhook :9443 (EV-6386) The WAF admission webhook serves on :9443 in kube-controllers, but the calico-system kube-controllers NetworkPolicy only allowed ingress on :9094, so default-deny dropped the apiserver -> webhook request and the ValidatingWebhook timed out (failurePolicy=Fail) -- blocking all WAFPolicy/WAFPlugin writes. Add a :9443 ingress rule, gated on the GatewayAPI WAF extension being enabled. --- pkg/render/kubecontrollers/kube-controllers.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index c2f535adcd..4a36456c66 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -1035,6 +1035,20 @@ func kubeControllersCalicoSystemPolicy(cfg *KubeControllersConfiguration) *v3.Ne }) } + // Allow the kube-apiserver to reach the in-process WAF admission webhook on + // :9443 (EV-6386). render-v3 wires the webhook Service/config/cert + the + // server, but without this ingress rule the calico-system default-deny drops + // the apiserver→:9443 call and every WAFPolicy/WAFPlugin admission times out. + if cfg.WAFGatewayExtensionEnabled { + ingressRules = append(ingressRules, v3.Rule{ + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(9443), + }, + }) + } + if r, err := cfg.K8sServiceEp.DestinationEntityRule(); r != nil && err == nil { egressRules = append(egressRules, v3.Rule{ Action: v3.Allow, From d38dd5863f45cb69ee97c6a7a967c1212280077d Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sun, 31 May 2026 01:17:10 +0100 Subject: [PATCH 05/12] fix(kubecontrollers): dedicated tigera-waf-pull-secret for WAF wasm pull (EV-6386) The WAF reconciler replicated WASM_PULL_SECRET (the install pull secret, tigera-pull-secret) into tenant namespaces, but the GatewayAPI render also copies tigera-pull-secret there (operator-managed) so the replica conflicts (ReplicaUnmanaged) and WAFPolicies are blocked. Provision + replicate a dedicated tigera-waf-pull-secret (renamed copy of the install pull secret) instead, avoiding the clash. --- pkg/controller/installation/core_controller.go | 13 +++++++++++++ pkg/render/kubecontrollers/kube-controllers.go | 16 ++++++++++++++-- .../kubecontrollers/kube-controllers_test.go | 12 +++++++----- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 13160d5184..f500da8746 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -1652,6 +1652,18 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile components = append(components, render.CSI(&csiCfg)) // Build a configuration for rendering calico/kube-controllers. + // Provision a dedicated WAF wasm pull secret (a renamed copy of the install + // pull secret) so the WAF reconciler replicates it into tenant namespaces + // without clashing with the operator-managed tigera-pull-secret the + // GatewayAPI render also copies there (EV-6386). + var wasmPullSecret *corev1.Secret + if wafGatewayExtensionEnabled && len(pullSecrets) > 0 { + wasmPullSecret = pullSecrets[0].DeepCopy() + wasmPullSecret.Name = kubecontrollers.WASMPullSecretName + wasmPullSecret.Namespace = common.CalicoNamespace + wasmPullSecret.ResourceVersion = "" + wasmPullSecret.UID = "" + } kubeControllersCfg := kubecontrollers.KubeControllersConfiguration{ K8sServiceEp: k8sapi.Endpoint, K8sServiceEpPodNetwork: k8sapi.PodNetworkEndpoint, @@ -1667,6 +1679,7 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile BindingNamespaces: []string{common.CalicoNamespace}, WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, WAFWebhookServerTLS: wafWebhookTLS, + WASMPullSecret: wasmPullSecret, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 4a36456c66..6f8c53eea5 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -56,6 +56,13 @@ const ( KubeControllerMetrics = "calico-kube-controllers-metrics" KubeControllerNetworkPolicyName = networkpolicy.CalicoComponentPolicyPrefix + "kube-controller-access" + // WASMPullSecretName is the dedicated image-pull Secret (a renamed copy of + // the install pull secret) that the WAF reconciler replicates into tenant + // namespaces for the Coraza wasm OCI pull. A dedicated name avoids clashing + // with the operator-managed tigera-pull-secret the GatewayAPI render also + // copies into those namespaces (EV-6386). + WASMPullSecretName = "tigera-waf-pull-secret" + // wafWebhookContainerPort is the in-process WAF admission-webhook server // port on calico-kube-controllers. Must match the TargetPort of the // tigera-waf-webhook Service (see pkg/render/applicationlayer) and the port @@ -102,6 +109,7 @@ type KubeControllersConfiguration struct { // namespace to be returned by the rendered. Expected that the calling code // take care to pass the same secret on each reconcile where possible. KubeControllersGatewaySecret *corev1.Secret + WASMPullSecret *corev1.Secret TrustedBundle certificatemanagement.TrustedBundleRO MetricsServerTLS certificatemanagement.KeyPairInterface @@ -325,6 +333,10 @@ func (c *kubeControllersComponent) Objects() ([]client.Object, []client.Object) objectsToCreate = append(objectsToCreate, secret.ToRuntimeObjects( secret.CopyToNamespace(c.cfg.Namespace, c.cfg.KubeControllersGatewaySecret)...)...) } + if c.cfg.WASMPullSecret != nil { + objectsToCreate = append(objectsToCreate, secret.ToRuntimeObjects( + secret.CopyToNamespace(c.cfg.Namespace, c.cfg.WASMPullSecret)...)...) + } if c.cfg.MetricsPort != 0 { objectsToCreate = append(objectsToCreate, c.prometheusService()) @@ -720,8 +732,8 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { // a private Tigera registry. Source the name from the first // Installation.ImagePullSecrets entry so multi-tenant / BYO-registry // installs reuse whatever pull secret operator already attaches here. - if len(c.cfg.Installation.ImagePullSecrets) > 0 { - env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.Installation.ImagePullSecrets[0].Name}) + if c.cfg.WASMPullSecret != nil { + env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.WASMPullSecret.Name}) } // WASM_CA_CERT names the trusted CA bundle ConfigMap (already mounted diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index b511f2af5a..49406ba28e 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -242,18 +242,20 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRole, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRole"}, {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, + {name: kubecontrollers.WASMPullSecretName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Secret"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, } instance.Variant = operatorv1.CalicoEnterprise - // Pull secret on the Installation propagates through the Deployment's - // imagePullSecrets and is also surfaced via WASM_PULL_SECRET so the - // applicationlayer reconciler can reference it from rendered - // EnvoyExtensionPolicies in WAFPolicy namespaces. instance.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "tigera-pull-secret"}} cfg.MetricsPort = 9094 // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. cfg.WAFGatewayExtensionEnabled = true + // core_controller provisions a dedicated WAF wasm pull secret (a renamed + // copy of the install pull secret) so the reconciler can replicate it into + // WAFPolicy namespaces without clashing with the operator-managed + // tigera-pull-secret; surface it here so it renders and WASM_PULL_SECRET is set. + cfg.WASMPullSecret = &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: kubecontrollers.WASMPullSecretName, Namespace: common.CalicoNamespace}} component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -282,7 +284,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Name: "WASM_IMAGE", Value: "test-reg/tigera/coraza-wasm:" + components.ComponentCorazaWASM.Version, })) Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "WASM_PULL_SECRET", Value: "tigera-pull-secret", + Name: "WASM_PULL_SECRET", Value: kubecontrollers.WASMPullSecretName, })) // TrustedBundle is set on the configuration above, so WASM_CA_CERT // names the standard tigera trusted-bundle ConfigMap. From a491101c7d28eec3793fb6cab77631b022c390b0 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sun, 31 May 2026 01:23:04 +0100 Subject: [PATCH 06/12] fix(kubecontrollers): dedicated tigera-waf-ca-bundle for WAF wasm TLS (EV-6386) Symmetric to the tigera-waf-pull-secret fix: WASM_CA_CERT pointed at the operator-managed tigera-ca-bundle, which the GatewayAPI render also copies into tenant namespaces, so the WAF reconciler's replica clashed (ReplicaUnmanaged). Provision + replicate a dedicated tigera-waf-ca-bundle copy instead. --- pkg/render/kubecontrollers/kube-controllers.go | 9 ++++++++- pkg/render/kubecontrollers/kube-controllers_test.go | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 6f8c53eea5..47debd7cff 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -63,6 +63,13 @@ const ( // copies into those namespaces (EV-6386). WASMPullSecretName = "tigera-waf-pull-secret" + // WASMCACertName is the dedicated CA-bundle ConfigMap (in the controller + // namespace) the WAF reconciler replicates into tenant namespaces for the + // Coraza wasm OCI registry TLS check — a dedicated name avoids clashing with + // the operator-managed tigera-ca-bundle ConfigMap (EV-6386). TODO: render the + // source copy here too (needs the full TrustedBundle, not the RO interface). + WASMCACertName = "tigera-waf-ca-bundle" + // wafWebhookContainerPort is the in-process WAF admission-webhook server // port on calico-kube-controllers. Must match the TargetPort of the // tigera-waf-webhook Service (see pkg/render/applicationlayer) and the port @@ -741,7 +748,7 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { // alongside WASM_PULL_SECRET so the EnvoyExtensionPolicy wasm fetcher // trusts the registry's TLS chain. if c.cfg.TrustedBundle != nil { - env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName}) + env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: WASMCACertName}) } } } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 49406ba28e..f6dce4eb05 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -286,10 +286,11 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "WASM_PULL_SECRET", Value: kubecontrollers.WASMPullSecretName, })) - // TrustedBundle is set on the configuration above, so WASM_CA_CERT - // names the standard tigera trusted-bundle ConfigMap. + // WASM_CA_CERT names the dedicated WAF trusted-bundle ConfigMap that the + // reconciler replicates into WAFPolicy namespaces (kept separate from the + // operator-managed tigera-ca-bundle the GatewayAPI render also copies there). Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName, + Name: "WASM_CA_CERT", Value: kubecontrollers.WASMCACertName, })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) From 5063060acf3d10f338c2d58d107ee12e345f1835 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sat, 6 Jun 2026 09:57:03 +0100 Subject: [PATCH 07/12] refactor(applicationlayer): render WAF webhook surface from kube-controllers component Review feedback (rene-dekker, #4821): - Move the webhook Service + ValidatingWebhookConfiguration out of the core controller passthrough into the kube-controllers component, so the objects are emitted as objectsToDelete when the WAF extension is disabled or the GatewayAPI CR is removed. Add deletion test coverage. - Export WAFWebhookContainerPort from pkg/render/applicationlayer and use it for the container port and NetworkPolicy ingress rule instead of duplicated 9443 constants. - Use gatewayapi.GetGatewayAPI for the WAF gate so the legacy tigera-secure CR name is handled (and a default/tigera-secure duplicate degrades). - Drop the nil-guard around the WAF webhook KeyPairOption (the certificate-management render skips nil key pairs). - Log when multiple imagePullSecrets are configured and only the first is used for the WAF wasm OCI pull. --- .../installation/core_controller.go | 34 +++++++++++-------- pkg/render/applicationlayer/gateway_waf.go | 9 ++--- .../kubecontrollers/kube-controllers.go | 30 +++++++++++----- .../kubecontrollers/kube-controllers_test.go | 29 ++++++++++++++++ 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index f500da8746..daabc71961 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -64,6 +64,7 @@ import ( "github.com/tigera/operator/pkg/common/discovery" "github.com/tigera/operator/pkg/components" "github.com/tigera/operator/pkg/controller/certificatemanager" + "github.com/tigera/operator/pkg/controller/gatewayapi" "github.com/tigera/operator/pkg/controller/ippool" "github.com/tigera/operator/pkg/controller/k8sapi" "github.com/tigera/operator/pkg/controller/migration" @@ -1376,11 +1377,12 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // spec.extensions.waf.state != Enabled, the WAF surface is not rendered. // See design tigera/designs#25 (PMREQ-384) §Gating. wafGatewayExtensionEnabled := false - gatewayAPI := &operatorv1.GatewayAPI{} - if err := r.client.Get(ctx, utils.DefaultInstanceKey, gatewayAPI); err == nil { + if gatewayAPI, msg, err := gatewayapi.GetGatewayAPI(ctx, r.client); err == nil { wafGatewayExtensionEnabled = gatewayAPI.Spec.IsWAFGatewayExtensionEnabled() } else if !apierrors.IsNotFound(err) { - r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading GatewayAPI", err, reqLogger) + // Mirrors the GatewayAPI controller's handling: a read error or a + // duplicate default/tigera-secure pair degrades rather than guessing. + r.status.SetDegraded(operatorv1.ResourceReadError, msg, err, reqLogger) return reconcile.Result{}, err } @@ -1408,9 +1410,9 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecret, true, true), rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecretNonClusterHost, true, true), rcertificatemanagement.NewKeyPairOption(kubeControllerTLS, true, true), - } - if wafWebhookTLS != nil { - keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(wafWebhookTLS, true, true)) + // Nil when the WAF v3 surface is disabled; the certificate-management + // render skips nil key pairs. + rcertificatemanagement.NewKeyPairOption(wafWebhookTLS, true, true), } components = append(components, @@ -1658,6 +1660,13 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile // GatewayAPI render also copies there (EV-6386). var wasmPullSecret *corev1.Secret if wafGatewayExtensionEnabled && len(pullSecrets) > 0 { + // The kube-controllers WAF reconciler takes a single pull secret + // (WASM_PULL_SECRET), so only the first Installation pull secret is + // used for the Coraza wasm OCI pull. + if len(pullSecrets) > 1 { + reqLogger.Info("Multiple imagePullSecrets configured; only the first is used for the WAF wasm OCI pull", + "used", pullSecrets[0].Name) + } wasmPullSecret = pullSecrets[0].DeepCopy() wasmPullSecret.Name = kubecontrollers.WASMPullSecretName wasmPullSecret.Namespace = common.CalicoNamespace @@ -1680,17 +1689,14 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, WAFWebhookServerTLS: wafWebhookTLS, WASMPullSecret: wasmPullSecret, + // The webhook Service + ValidatingWebhookConfiguration are rendered by + // the kube-controllers component (and deleted when the WAF extension is + // disabled); the caBundle is the operator CA that issued the serving + // cert above. + WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) - // Render the in-process WAF admission webhook Service + ValidatingWebhookConfiguration. - // The webhook is served by calico-kube-controllers; the caBundle is the - // operator CA that issued the serving cert above. - if wafGatewayExtensionEnabled { - components = append(components, render.NewPassthrough( - applicationlayer.WAFAdmissionWebhookComponents(certificateManager.KeyPair().GetCertificatePEM()), nil)) - } - // v3 NetworkPolicy will fail to reconcile if the API server deployment is unhealthy. In case the API Server // deployment becomes unhealthy and reconciliation of non-NetworkPolicy resources in the core controller // would resolve it, we render the network policies of components last to prevent a chicken-and-egg scenario. diff --git a/pkg/render/applicationlayer/gateway_waf.go b/pkg/render/applicationlayer/gateway_waf.go index 41dcac85c6..5cd17d3776 100644 --- a/pkg/render/applicationlayer/gateway_waf.go +++ b/pkg/render/applicationlayer/gateway_waf.go @@ -39,10 +39,11 @@ const ( // DNS name and mounted into kube-controllers (see pkg/render/kubecontrollers). WAFWebhookServiceName = "tigera-waf-webhook" - // wafWebhookContainerPort is the in-process webhook server port on the + // WAFWebhookContainerPort is the in-process webhook server port on the // calico-kube-controllers Pod (controller-runtime webhook server). Must match - // the port the kube-controllers applicationlayer manager listens on. - wafWebhookContainerPort = int32(9443) + // the port the kube-controllers applicationlayer manager listens on. Shared + // with pkg/render/kubecontrollers (container port + NetworkPolicy ingress). + WAFWebhookContainerPort = int32(9443) // wafWebhookPath is the admission path the kube-controllers webhook server // registers. Must match WAFWebhookPath in the calico-private applicationlayer @@ -92,7 +93,7 @@ func wafWebhookService() *corev1.Service { Name: "https", Port: 443, Protocol: corev1.ProtocolTCP, - TargetPort: intstr.FromInt32(wafWebhookContainerPort), + TargetPort: intstr.FromInt32(WAFWebhookContainerPort), }, }, Type: corev1.ServiceTypeClusterIP, diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 47debd7cff..02e41ba7a8 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -36,6 +36,7 @@ import ( "github.com/tigera/operator/pkg/components" "github.com/tigera/operator/pkg/controller/k8sapi" "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/applicationlayer" rcomp "github.com/tigera/operator/pkg/render/common/components" relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" rmeta "github.com/tigera/operator/pkg/render/common/meta" @@ -70,12 +71,6 @@ const ( // source copy here too (needs the full TrustedBundle, not the RO interface). WASMCACertName = "tigera-waf-ca-bundle" - // wafWebhookContainerPort is the in-process WAF admission-webhook server - // port on calico-kube-controllers. Must match the TargetPort of the - // tigera-waf-webhook Service (see pkg/render/applicationlayer) and the port - // the calico-private applicationlayer manager's webhook server listens on. - wafWebhookContainerPort = int32(9443) - EsKubeController = "es-calico-kube-controllers" EsKubeControllerRole = "es-calico-kube-controllers" EsKubeControllerRoleBinding = "es-calico-kube-controllers" @@ -147,6 +142,12 @@ type KubeControllersConfiguration struct { // Service DNS name. Nil leaves the Deployment untouched (and the in-process // server self-disables when the cert is absent). WAFWebhookServerTLS certificatemanagement.KeyPairInterface + + // WAFWebhookCABundle is the PEM of the CA that issued WAFWebhookServerTLS + // (the operator CA), stamped into the ValidatingWebhookConfiguration's + // caBundle so the apiserver can verify the in-process webhook endpoint. + // Only consulted when WAFGatewayExtensionEnabled is true. + WAFWebhookCABundle []byte } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { @@ -345,6 +346,19 @@ func (c *kubeControllersComponent) Objects() ([]client.Object, []client.Object) secret.CopyToNamespace(c.cfg.Namespace, c.cfg.WASMPullSecret)...)...) } + // The in-process WAF admission webhook surface (Service fronting this Pod + + // ValidatingWebhookConfiguration). Rendered here, rather than as a + // passthrough in the core controller, so the objects are cleaned up when the + // WAF extension is disabled or the GatewayAPI CR is removed. + if c.kubeControllerName == KubeController { + webhookObjs := applicationlayer.WAFAdmissionWebhookComponents(c.cfg.WAFWebhookCABundle) + if c.cfg.WAFGatewayExtensionEnabled { + objectsToCreate = append(objectsToCreate, webhookObjs...) + } else { + objectsToDelete = append(objectsToDelete, webhookObjs...) + } + } + if c.cfg.MetricsPort != 0 { objectsToCreate = append(objectsToCreate, c.prometheusService()) } else { @@ -822,7 +836,7 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { // tigera-waf-webhook Service forwards to. container.Ports = append(container.Ports, corev1.ContainerPort{ Name: "waf-webhook", - ContainerPort: wafWebhookContainerPort, + ContainerPort: applicationlayer.WAFWebhookContainerPort, Protocol: corev1.ProtocolTCP, }) } @@ -1063,7 +1077,7 @@ func kubeControllersCalicoSystemPolicy(cfg *KubeControllersConfiguration) *v3.Ne Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, Destination: v3.EntityRule{ - Ports: networkpolicy.Ports(9443), + Ports: networkpolicy.Ports(uint16(applicationlayer.WAFWebhookContainerPort)), }, }) } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index f6dce4eb05..437c429899 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -21,6 +21,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -243,6 +244,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.WASMPullSecretName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Secret"}, + {name: applicationlayer.WAFWebhookServiceName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, + {name: "tigera-waf.applicationlayer.projectcalico.org", ns: "", group: "admissionregistration.k8s.io", version: "v1", kind: "ValidatingWebhookConfiguration"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, } @@ -251,6 +254,7 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.MetricsPort = 9094 // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. cfg.WAFGatewayExtensionEnabled = true + cfg.WAFWebhookCABundle = []byte("fake-ca-bundle") // core_controller provisions a dedicated WAF wasm pull secret (a renamed // copy of the install pull secret) so the reconciler can replicate it into // WAFPolicy namespaces without clashing with the operator-managed @@ -366,6 +370,29 @@ var _ = Describe("kube-controllers rendering tests", func() { ms := rtest.GetResource(resources, kubecontrollers.KubeControllerMetrics, common.CalicoNamespace, "", "v1", "Service").(*corev1.Service) Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless") + + // The webhook surface is rendered with the operator CA stamped into the + // ValidatingWebhookConfiguration caBundle. + vwc := rtest.GetResource(resources, "tigera-waf.applicationlayer.projectcalico.org", "", "admissionregistration.k8s.io", "v1", "ValidatingWebhookConfiguration").(*admissionregistrationv1.ValidatingWebhookConfiguration) + Expect(vwc.Webhooks).To(HaveLen(1)) + Expect(vwc.Webhooks[0].ClientConfig.CABundle).To(Equal([]byte("fake-ca-bundle"))) + }) + + It("should delete the WAF admission webhook surface when the WAF Gateway API add-on is disabled", func() { + instance.Variant = operatorv1.CalicoEnterprise + cfg.WAFGatewayExtensionEnabled = false + + component := kubecontrollers.NewCalicoKubeControllers(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + toCreate, toDelete := component.Objects() + + // Neither webhook object is created... + Expect(rtest.GetResource(toCreate, applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, "", "v1", "Service")).To(BeNil()) + Expect(rtest.GetResource(toCreate, "tigera-waf.applicationlayer.projectcalico.org", "", "admissionregistration.k8s.io", "v1", "ValidatingWebhookConfiguration")).To(BeNil()) + // ...and both are queued for deletion, so disabling the feature (or + // removing the GatewayAPI CR) cleans up an earlier enabled render. + Expect(rtest.GetResource(toDelete, applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, "", "v1", "Service")).NotTo(BeNil()) + Expect(rtest.GetResource(toDelete, "tigera-waf.applicationlayer.projectcalico.org", "", "admissionregistration.k8s.io", "v1", "ValidatingWebhookConfiguration")).NotTo(BeNil()) }) It("should render all calico kube-controllers resources using CalicoEnterprise on Openshift", func() { @@ -479,6 +506,8 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.ManagedClustersWatchRoleBindingName, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, + {name: applicationlayer.WAFWebhookServiceName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, + {name: "tigera-waf.applicationlayer.projectcalico.org", ns: "", group: "admissionregistration.k8s.io", version: "v1", kind: "ValidatingWebhookConfiguration"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, } From 37fe7c1a32eb826cb7298eb231e4df9ff14b3a43 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sat, 6 Jun 2026 09:57:12 +0100 Subject: [PATCH 08/12] chore: drop unused GatewayAddonsFeature constant; comment wording Review feedback (rene-dekker, #4821): nothing consumes the constant, and the calico-private side has since moved from the ingress-gateway-addons feature gate to a binary license-validity check (kube-controllers applicationlayer LicenseGate), so the feature string has no remaining consumer. Also take the iff->if comment suggestion. --- api/v1/gatewayapi_types.go | 2 +- pkg/common/common.go | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/api/v1/gatewayapi_types.go b/api/v1/gatewayapi_types.go index 857ccf3817..82c68448c0 100644 --- a/api/v1/gatewayapi_types.go +++ b/api/v1/gatewayapi_types.go @@ -117,7 +117,7 @@ const ( WAFExtensionStateDisabled WAFExtensionState = "Disabled" ) -// IsWAFGatewayExtensionEnabled returns true iff spec.extensions.waf.state == Enabled. +// IsWAFGatewayExtensionEnabled returns true if spec.extensions.waf.state == Enabled. // Unset Extensions, unset WAF, unset State, and explicit Disabled all return false. func (s *GatewayAPISpec) IsWAFGatewayExtensionEnabled() bool { if s == nil || s.Extensions == nil || s.Extensions.WAF == nil || s.Extensions.WAF.State == nil { diff --git a/pkg/common/common.go b/pkg/common/common.go index 45260b0952..485046c415 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -36,10 +36,6 @@ const ( EgressAccessControlFeature = "egress-access-control" // PolicyRecommendation feature name PolicyRecommendationFeature = "policy-recommendation" - // GatewayAddonsFeature gates Tigera-built add-ons that layer on top of an - // ingress gateway (currently the WAF v2/v3 admission webhook). The bare - // ingress gateway data path is NOT licensed by this feature. - GatewayAddonsFeature = "ingress-gateway-addons" // MultipleOwnersLabel used to indicate multiple owner references. // If the render code places this label on an object, the object mergeState machinery will merge owner // references with any that already exist on the object rather than replace the owner references. Further From 80500bc127a65ec861d27b558c40f03464c1eaea Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Sat, 6 Jun 2026 11:34:23 +0100 Subject: [PATCH 09/12] feat(kubecontrollers): merge all Installation pull secrets into the WAF wasm pull secret Review feedback (rene-dekker, #4821): rather than copying only the first Installation pull secret into tigera-waf-pull-secret, merge the registry auths of every Installation pull secret into it. The EnvoyExtensionPolicy image source takes a single pullSecretRef, so a merged secret is the only way to honor multiple pull secrets for the Coraza wasm OCI pull (e.g. the Tigera pull secret plus credentials for a private registry mirror). First secret in Installation order wins on duplicate registry entries; the merged map marshals with sorted keys so the rendered bytes are deterministic across reconciles. Unparseable secrets are skipped and logged rather than failing the reconcile. Legacy dockercfg-type secrets are supported. --- .../installation/core_controller.go | 27 ++-- pkg/render/kubecontrollers/waf_pull_secret.go | 101 ++++++++++++ .../kubecontrollers/waf_pull_secret_test.go | 149 ++++++++++++++++++ 3 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 pkg/render/kubecontrollers/waf_pull_secret.go create mode 100644 pkg/render/kubecontrollers/waf_pull_secret_test.go diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index daabc71961..46ccb299e7 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -1654,24 +1654,19 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile components = append(components, render.CSI(&csiCfg)) // Build a configuration for rendering calico/kube-controllers. - // Provision a dedicated WAF wasm pull secret (a renamed copy of the install - // pull secret) so the WAF reconciler replicates it into tenant namespaces - // without clashing with the operator-managed tigera-pull-secret the - // GatewayAPI render also copies there (EV-6386). + // Provision a dedicated WAF wasm pull secret so the WAF reconciler + // replicates it into tenant namespaces without clashing with the + // operator-managed tigera-pull-secret the GatewayAPI render also copies + // there (EV-6386). The EnvoyExtensionPolicy image source takes a single + // pullSecretRef, so the registry auths of all Installation pull secrets + // are merged into it rather than picking one. var wasmPullSecret *corev1.Secret if wafGatewayExtensionEnabled && len(pullSecrets) > 0 { - // The kube-controllers WAF reconciler takes a single pull secret - // (WASM_PULL_SECRET), so only the first Installation pull secret is - // used for the Coraza wasm OCI pull. - if len(pullSecrets) > 1 { - reqLogger.Info("Multiple imagePullSecrets configured; only the first is used for the WAF wasm OCI pull", - "used", pullSecrets[0].Name) - } - wasmPullSecret = pullSecrets[0].DeepCopy() - wasmPullSecret.Name = kubecontrollers.WASMPullSecretName - wasmPullSecret.Namespace = common.CalicoNamespace - wasmPullSecret.ResourceVersion = "" - wasmPullSecret.UID = "" + var skipped []string + wasmPullSecret, skipped = kubecontrollers.MergeWAFPullSecret(pullSecrets) + if len(skipped) > 0 { + reqLogger.Info("Skipped unparseable imagePullSecrets when building the WAF wasm pull secret", "skipped", skipped) + } } kubeControllersCfg := kubecontrollers.KubeControllersConfiguration{ K8sServiceEp: k8sapi.Endpoint, diff --git a/pkg/render/kubecontrollers/waf_pull_secret.go b/pkg/render/kubecontrollers/waf_pull_secret.go new file mode 100644 index 0000000000..02ada09f8a --- /dev/null +++ b/pkg/render/kubecontrollers/waf_pull_secret.go @@ -0,0 +1,101 @@ +// 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 kubecontrollers + +import ( + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/tigera/operator/pkg/common" +) + +// MergeWAFPullSecret synthesizes the dedicated WAF wasm pull secret +// (tigera-waf-pull-secret) by merging the registry auths of every Installation +// pull secret. The EnvoyExtensionPolicy image source takes a single +// pullSecretRef, so a merged secret is the only way to honor multiple +// Installation pull secrets for the Coraza wasm OCI pull (e.g. the Tigera pull +// secret plus credentials for a private registry mirror). +// +// If the same registry appears in more than one secret, the first secret in +// Installation order wins. Secrets that cannot be parsed are skipped and their +// names returned, so the caller can log them without failing the reconcile. +// Returns a nil Secret when no registry auths could be collected. +func MergeWAFPullSecret(pullSecrets []*corev1.Secret) (*corev1.Secret, []string) { + merged := map[string]json.RawMessage{} + var skipped []string + for _, s := range pullSecrets { + auths, err := registryAuths(s) + if err != nil { + skipped = append(skipped, s.Name) + continue + } + for registry, auth := range auths { + if _, ok := merged[registry]; !ok { + merged[registry] = auth + } + } + } + if len(merged) == 0 { + return nil, skipped + } + + // Marshalling a map sorts its keys, so the rendered bytes are deterministic + // and do not churn the object on every reconcile. + data, err := json.Marshal(map[string]map[string]json.RawMessage{"auths": merged}) + if err != nil { + // Each auth entry round-trips from a successful Unmarshal above, so + // this cannot fail in practice; treat it as nothing to render. + return nil, skipped + } + + return &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: WASMPullSecretName, Namespace: common.CalicoNamespace}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: data}, + }, skipped +} + +// registryAuths extracts the per-registry auth entries from a pull secret of +// either the dockerconfigjson type (auths nested under an "auths" key) or the +// legacy dockercfg type (a bare registry -> auth map). +func registryAuths(s *corev1.Secret) (map[string]json.RawMessage, error) { + if raw, ok := s.Data[corev1.DockerConfigJsonKey]; ok { + var cfg struct { + Auths map[string]json.RawMessage `json:"auths"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + if len(cfg.Auths) == 0 { + return nil, fmt.Errorf("secret %s has no auths entries", s.Name) + } + return cfg.Auths, nil + } + if raw, ok := s.Data[corev1.DockerConfigKey]; ok { + var auths map[string]json.RawMessage + if err := json.Unmarshal(raw, &auths); err != nil { + return nil, err + } + if len(auths) == 0 { + return nil, fmt.Errorf("secret %s has no auths entries", s.Name) + } + return auths, nil + } + return nil, fmt.Errorf("secret %s has neither a %s nor a %s key", s.Name, corev1.DockerConfigJsonKey, corev1.DockerConfigKey) +} diff --git a/pkg/render/kubecontrollers/waf_pull_secret_test.go b/pkg/render/kubecontrollers/waf_pull_secret_test.go new file mode 100644 index 0000000000..793374f169 --- /dev/null +++ b/pkg/render/kubecontrollers/waf_pull_secret_test.go @@ -0,0 +1,149 @@ +// 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 kubecontrollers_test + +import ( + "encoding/json" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/render/kubecontrollers" +) + +func dockerConfigJSONSecret(name string, auths map[string]any) *corev1.Secret { + cfg, err := json.Marshal(map[string]any{"auths": auths}) + if err != nil { + panic(err) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: common.OperatorNamespace()}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: cfg}, + } +} + +func mergedAuths(t *testing.T, s *corev1.Secret) map[string]map[string]string { + t.Helper() + var cfg struct { + Auths map[string]map[string]string `json:"auths"` + } + if err := json.Unmarshal(s.Data[corev1.DockerConfigJsonKey], &cfg); err != nil { + t.Fatalf("merged secret is not valid dockerconfigjson: %v", err) + } + return cfg.Auths +} + +func TestMergeWAFPullSecret_MergesDisjointRegistries(t *testing.T) { + merged, skipped := kubecontrollers.MergeWAFPullSecret([]*corev1.Secret{ + dockerConfigJSONSecret("tigera-pull-secret", map[string]any{"quay.io": map[string]string{"auth": "dGlnZXJh"}}), + dockerConfigJSONSecret("mirror-pull-secret", map[string]any{"registry.example.com": map[string]string{"auth": "bWlycm9y"}}), + }) + if len(skipped) != 0 { + t.Fatalf("expected no skipped secrets, got %v", skipped) + } + if merged == nil { + t.Fatal("expected a merged secret") + } + if merged.Name != kubecontrollers.WASMPullSecretName || merged.Namespace != common.CalicoNamespace { + t.Fatalf("unexpected name/namespace: %s/%s", merged.Namespace, merged.Name) + } + if merged.Type != corev1.SecretTypeDockerConfigJson { + t.Fatalf("unexpected secret type: %s", merged.Type) + } + auths := mergedAuths(t, merged) + if auths["quay.io"]["auth"] != "dGlnZXJh" || auths["registry.example.com"]["auth"] != "bWlycm9y" { + t.Fatalf("expected auths from both secrets, got %v", auths) + } +} + +func TestMergeWAFPullSecret_FirstSecretWinsOnDuplicateRegistry(t *testing.T) { + merged, _ := kubecontrollers.MergeWAFPullSecret([]*corev1.Secret{ + dockerConfigJSONSecret("first", map[string]any{"quay.io": map[string]string{"auth": "Zmlyc3Q="}}), + dockerConfigJSONSecret("second", map[string]any{"quay.io": map[string]string{"auth": "c2Vjb25k"}}), + }) + auths := mergedAuths(t, merged) + if auths["quay.io"]["auth"] != "Zmlyc3Q=" { + t.Fatalf("expected the first secret's auth to win, got %v", auths) + } +} + +func TestMergeWAFPullSecret_SkipsUnparseableSecrets(t *testing.T) { + bad := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: common.OperatorNamespace()}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte("not-json")}, + } + merged, skipped := kubecontrollers.MergeWAFPullSecret([]*corev1.Secret{ + bad, + dockerConfigJSONSecret("good", map[string]any{"quay.io": map[string]string{"auth": "Z29vZA=="}}), + }) + if len(skipped) != 1 || skipped[0] != "bad" { + t.Fatalf("expected [bad] skipped, got %v", skipped) + } + auths := mergedAuths(t, merged) + if auths["quay.io"]["auth"] != "Z29vZA==" { + t.Fatalf("expected the good secret merged, got %v", auths) + } +} + +func TestMergeWAFPullSecret_LegacyDockercfg(t *testing.T) { + cfg, err := json.Marshal(map[string]any{"registry.example.com": map[string]string{"auth": "bGVnYWN5"}}) + if err != nil { + t.Fatal(err) + } + legacy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "legacy", Namespace: common.OperatorNamespace()}, + Type: corev1.SecretTypeDockercfg, + Data: map[string][]byte{corev1.DockerConfigKey: cfg}, + } + merged, skipped := kubecontrollers.MergeWAFPullSecret([]*corev1.Secret{legacy}) + if len(skipped) != 0 { + t.Fatalf("expected no skipped secrets, got %v", skipped) + } + auths := mergedAuths(t, merged) + if auths["registry.example.com"]["auth"] != "bGVnYWN5" { + t.Fatalf("expected the legacy dockercfg auth merged, got %v", auths) + } +} + +func TestMergeWAFPullSecret_NothingUsableReturnsNil(t *testing.T) { + bad := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "bad", Namespace: common.OperatorNamespace()}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte("not-json")}, + } + merged, skipped := kubecontrollers.MergeWAFPullSecret([]*corev1.Secret{bad}) + if merged != nil { + t.Fatalf("expected nil secret, got %v", merged) + } + if len(skipped) != 1 || skipped[0] != "bad" { + t.Fatalf("expected [bad] skipped, got %v", skipped) + } +} + +func TestMergeWAFPullSecret_DeterministicOutput(t *testing.T) { + in := []*corev1.Secret{ + dockerConfigJSONSecret("a", map[string]any{"z.example.com": map[string]string{"auth": "eg=="}, "a.example.com": map[string]string{"auth": "YQ=="}}), + dockerConfigJSONSecret("b", map[string]any{"m.example.com": map[string]string{"auth": "bQ=="}}), + } + first, _ := kubecontrollers.MergeWAFPullSecret(in) + second, _ := kubecontrollers.MergeWAFPullSecret(in) + if string(first.Data[corev1.DockerConfigJsonKey]) != string(second.Data[corev1.DockerConfigJsonKey]) { + t.Fatal("merged secret bytes must be deterministic across reconciles") + } +} From 2af9bebc66f1fda8ed6326f59fa6e4a775ca59a1 Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 12 Jun 2026 12:48:27 +0100 Subject: [PATCH 10/12] fix(kubecontrollers): render dedicated tigera-waf-ca-bundle source ConfigMap (EV-6386) The WAF reconciler replicates the WASM_CA_CERT ConfigMap (tigera-waf-ca-bundle) into tenant namespaces for the Coraza wasm registry TLS check, but the source copy was never created (left as a TODO), so reconcile failed with 'source configmap calico-system/tigera-waf-ca-bundle not found'. Provision it in the core controller as a renamed copy of the trusted CA bundle -- the full TrustedBundle is available there, unlike the read-only interface the kube-controllers render sees. Gate WASM_CA_CERT on the provisioned ConfigMap. --- .../installation/core_controller.go | 13 ++++++++++++ .../kubecontrollers/kube-controllers.go | 21 ++++++++++++------- .../kubecontrollers/kube-controllers_test.go | 5 +++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 46ccb299e7..9f928425c9 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -1668,6 +1668,18 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile reqLogger.Info("Skipped unparseable imagePullSecrets when building the WAF wasm pull secret", "skipped", skipped) } } + // Provision the dedicated WAF wasm CA-bundle ConfigMap as a renamed copy of + // the trusted CA bundle, so the WAF reconciler replicates it into tenant + // namespaces for the Coraza wasm OCI registry TLS check without clashing with + // the operator-managed tigera-ca-bundle the GatewayAPI render also copies + // there (EV-6386). The dedicated source was previously a TODO; the full + // TrustedBundle (not the RO interface the kube-controllers render sees) is + // available here, so build it in the core controller. + var wasmCACert *corev1.ConfigMap + if wafGatewayExtensionEnabled { + wasmCACert = typhaNodeTLS.TrustedBundle.ConfigMap(common.CalicoNamespace) + wasmCACert.Name = kubecontrollers.WASMCACertName + } kubeControllersCfg := kubecontrollers.KubeControllersConfiguration{ K8sServiceEp: k8sapi.Endpoint, K8sServiceEpPodNetwork: k8sapi.PodNetworkEndpoint, @@ -1684,6 +1696,7 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, WAFWebhookServerTLS: wafWebhookTLS, WASMPullSecret: wasmPullSecret, + WASMCACert: wasmCACert, // The webhook Service + ValidatingWebhookConfiguration are rendered by // the kube-controllers component (and deleted when the WAF extension is // disabled); the caBundle is the operator CA that issued the serving diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 02e41ba7a8..476f7d9c22 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -67,8 +67,9 @@ const ( // WASMCACertName is the dedicated CA-bundle ConfigMap (in the controller // namespace) the WAF reconciler replicates into tenant namespaces for the // Coraza wasm OCI registry TLS check — a dedicated name avoids clashing with - // the operator-managed tigera-ca-bundle ConfigMap (EV-6386). TODO: render the - // source copy here too (needs the full TrustedBundle, not the RO interface). + // the operator-managed tigera-ca-bundle ConfigMap the GatewayAPI render also + // copies there (EV-6386). The source copy is a renamed copy of the trusted + // bundle, provisioned by the core controller and passed in as WASMCACert. WASMCACertName = "tigera-waf-ca-bundle" EsKubeController = "es-calico-kube-controllers" @@ -112,6 +113,7 @@ type KubeControllersConfiguration struct { // take care to pass the same secret on each reconcile where possible. KubeControllersGatewaySecret *corev1.Secret WASMPullSecret *corev1.Secret + WASMCACert *corev1.ConfigMap TrustedBundle certificatemanagement.TrustedBundleRO MetricsServerTLS certificatemanagement.KeyPairInterface @@ -345,6 +347,9 @@ func (c *kubeControllersComponent) Objects() ([]client.Object, []client.Object) objectsToCreate = append(objectsToCreate, secret.ToRuntimeObjects( secret.CopyToNamespace(c.cfg.Namespace, c.cfg.WASMPullSecret)...)...) } + if c.cfg.WASMCACert != nil { + objectsToCreate = append(objectsToCreate, c.cfg.WASMCACert) + } // The in-process WAF admission webhook surface (Service fronting this Pod + // ValidatingWebhookConfiguration). Rendered here, rather than as a @@ -757,12 +762,12 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.WASMPullSecret.Name}) } - // WASM_CA_CERT names the trusted CA bundle ConfigMap (already mounted - // on this Deployment via TrustedBundle) that the reconciler replicates - // alongside WASM_PULL_SECRET so the EnvoyExtensionPolicy wasm fetcher - // trusts the registry's TLS chain. - if c.cfg.TrustedBundle != nil { - env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: WASMCACertName}) + // WASM_CA_CERT names the dedicated CA bundle ConfigMap (provisioned as + // WASMCACert) that the reconciler replicates alongside WASM_PULL_SECRET + // so the EnvoyExtensionPolicy wasm fetcher trusts the registry's TLS + // chain. Only set when the source ConfigMap is actually rendered. + if c.cfg.WASMCACert != nil { + env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: c.cfg.WASMCACert.Name}) } } } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 437c429899..8006857d51 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -244,6 +244,7 @@ var _ = Describe("kube-controllers rendering tests", func() { {name: kubecontrollers.KubeControllerRoleBinding, ns: "", group: "rbac.authorization.k8s.io", version: "v1", kind: "ClusterRoleBinding"}, {name: kubecontrollers.KubeController, ns: common.CalicoNamespace, group: "apps", version: "v1", kind: "Deployment"}, {name: kubecontrollers.WASMPullSecretName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Secret"}, + {name: kubecontrollers.WASMCACertName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "ConfigMap"}, {name: applicationlayer.WAFWebhookServiceName, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, {name: "tigera-waf.applicationlayer.projectcalico.org", ns: "", group: "admissionregistration.k8s.io", version: "v1", kind: "ValidatingWebhookConfiguration"}, {name: kubecontrollers.KubeControllerMetrics, ns: common.CalicoNamespace, group: "", version: "v1", kind: "Service"}, @@ -260,6 +261,10 @@ var _ = Describe("kube-controllers rendering tests", func() { // WAFPolicy namespaces without clashing with the operator-managed // tigera-pull-secret; surface it here so it renders and WASM_PULL_SECRET is set. cfg.WASMPullSecret = &corev1.Secret{TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: kubecontrollers.WASMPullSecretName, Namespace: common.CalicoNamespace}} + // Likewise core_controller provisions the dedicated WAF wasm CA-bundle + // ConfigMap (a renamed copy of the trusted bundle); surface it here so it + // renders and WASM_CA_CERT is set. + cfg.WASMCACert = &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}, ObjectMeta: metav1.ObjectMeta{Name: kubecontrollers.WASMCACertName, Namespace: common.CalicoNamespace}} component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) From b4a251f10e4bc005c6e2d116bc58cf07cd1bea8b Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 12 Jun 2026 15:10:26 +0100 Subject: [PATCH 11/12] refactor(kubecontrollers): resolve WASM_IMAGE from the gateway envoy-proxy image (EV-6386) The Coraza WAF wasm is baked into the gateway envoy-proxy image (its final layer), so there is no separate coraza-wasm image to ship. Resolve WASM_IMAGE from ComponentGatewayAPIEnvoyProxy -- the same image the gateway data plane already runs -- and drop the standalone ComponentCorazaWASM component and its enterprise_versions.yml pin. Addresses review feedback on op#4821. --- config/enterprise_versions.yml | 3 --- pkg/components/enterprise.go | 9 --------- pkg/render/kubecontrollers/kube-controllers.go | 7 +++++-- pkg/render/kubecontrollers/kube-controllers_test.go | 2 +- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/config/enterprise_versions.yml b/config/enterprise_versions.yml index 0027779df2..e5761a627a 100644 --- a/config/enterprise_versions.yml +++ b/config/enterprise_versions.yml @@ -84,9 +84,6 @@ components: dikastes: image: dikastes version: master - coraza-wasm: - image: coraza-wasm - version: master egress-gateway: image: egress-gateway version: master diff --git a/pkg/components/enterprise.go b/pkg/components/enterprise.go index 009d6581d6..3753ca105d 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -162,14 +162,6 @@ var ( variant: enterpriseVariant, } - ComponentCorazaWASM = Component{ - Version: "master", - Image: "coraza-wasm", - Registry: "", - imagePath: "", - variant: enterpriseVariant, - } - ComponentCoreOSPrometheus = Component{ Version: "v3.9.1", variant: enterpriseVariant, @@ -291,7 +283,6 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, - ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 476f7d9c22..1f592f8358 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -132,7 +132,7 @@ type KubeControllersConfiguration struct { // on calico-kube-controllers: the applicationlayer controller enablement, // the WAF / Gateway-API / EnvoyExtensionPolicy / event / secret-replication // RBAC, the WASM_IMAGE / WASM_PULL_SECRET / WASM_CA_CERT env vars, and the - // coraza-wasm image resolution. Sourced from + // gateway envoy-proxy wasm image resolution. Sourced from // `GatewayAPI.spec.extensions.waf.state == Enabled` (default off). // See design `tigera/designs#25` (PMREQ-384). WAFGatewayExtensionEnabled bool @@ -297,7 +297,10 @@ func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error return err } if c.cfg.Installation.Variant.IsEnterprise() && c.cfg.WAFGatewayExtensionEnabled { - c.wasmImage, err = components.GetReference(components.ComponentCorazaWASM, reg, path, prefix, is) + // The Coraza WAF wasm is baked into the gateway envoy-proxy image as its + // final layer; Envoy Gateway extracts it from there. Point WASM_IMAGE at + // that same image (no standalone coraza-wasm image needed). + c.wasmImage, err = components.GetReference(components.ComponentGatewayAPIEnvoyProxy, reg, path, prefix, is) if err != nil { return err } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 8006857d51..9f30802d99 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -290,7 +290,7 @@ var _ = Describe("kube-controllers rendering tests", func() { // Application-layer reconcilers consume these env vars to program WAF // EnvoyExtensionPolicy attachments. Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "WASM_IMAGE", Value: "test-reg/tigera/coraza-wasm:" + components.ComponentCorazaWASM.Version, + Name: "WASM_IMAGE", Value: "test-reg/tigera/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version, })) Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "WASM_PULL_SECRET", Value: kubecontrollers.WASMPullSecretName, From 1ab87cc6e8d6be2c3b6240ef8bb65de267ef5f9c Mon Sep 17 00:00:00 2001 From: Seth Malaki Date: Fri, 12 Jun 2026 16:59:43 +0100 Subject: [PATCH 12/12] fix(gen-versions): drop ComponentCorazaWASM from enterprise template (EV-6386) Completes the standalone coraza-wasm removal: the gen-versions template still defined ComponentCorazaWASM, so gen-versions regenerated it into enterprise.go and validate-gen-versions/dirty-check failed. The wasm now ships baked into the envoy-proxy image, resolved via ComponentGatewayAPIEnvoyProxy. --- hack/gen-versions/enterprise.go.tpl | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/hack/gen-versions/enterprise.go.tpl b/hack/gen-versions/enterprise.go.tpl index 264b34fb5f..7ed9089073 100644 --- a/hack/gen-versions/enterprise.go.tpl +++ b/hack/gen-versions/enterprise.go.tpl @@ -180,15 +180,6 @@ var ( variant: enterpriseVariant, } {{- end }} -{{ with index .Components "coraza-wasm" }} - ComponentCorazaWASM = Component{ - Version: "{{ .Version }}", - Image: "{{ .Image }}", - Registry: "{{ .Registry }}", - imagePath: "{{ .ImagePath }}", - variant: enterpriseVariant, - } -{{- end }} {{ with index .Components "coreos-prometheus" }} ComponentCoreOSPrometheus = Component{ Version: "{{ .Version }}", @@ -325,7 +316,6 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, - ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode,