diff --git a/api/v1/gatewayapi_types.go b/api/v1/gatewayapi_types.go index 7e56ae8ede..82c68448c0 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 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 { + 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/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index 71ed33394e..9f928425c9 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" @@ -78,6 +79,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 +215,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 +1370,57 @@ 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 + if gatewayAPI, msg, err := gatewayapi.GetGatewayAPI(ctx, r.client); err == nil { + wafGatewayExtensionEnabled = gatewayAPI.Spec.IsWAFGatewayExtensionEnabled() + } else if !apierrors.IsNotFound(err) { + // 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 + } + + // 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), + // Nil when the WAF v3 surface is disabled; the certificate-management + // render skips nil key pairs. + 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. @@ -1606,6 +1654,32 @@ 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 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 { + 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) + } + } + // 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, @@ -1619,6 +1693,15 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile TrustedBundle: typhaNodeTLS.TrustedBundle, Namespace: common.CalicoNamespace, BindingNamespaces: []string{common.CalicoNamespace}, + 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 + // cert above. + WAFWebhookCABundle: certificateManager.KeyPair().GetCertificatePEM(), } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) 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: diff --git a/pkg/render/applicationlayer/gateway_waf.go b/pkg/render/applicationlayer/gateway_waf.go new file mode 100644 index 0000000000..5cd17d3776 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf.go @@ -0,0 +1,156 @@ +// 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. 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 + // 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..1f592f8358 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" @@ -35,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" @@ -55,6 +57,21 @@ 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" + + // 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 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" EsKubeControllerRole = "es-calico-kube-controllers" EsKubeControllerRoleBinding = "es-calico-kube-controllers" @@ -95,6 +112,8 @@ 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 + WASMCACert *corev1.ConfigMap TrustedBundle certificatemanagement.TrustedBundleRO MetricsServerTLS certificatemanagement.KeyPairInterface @@ -108,6 +127,29 @@ 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 + // 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 + + // 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 + + // 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 { @@ -155,6 +197,9 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController }, ) enabledControllers = append(enabledControllers, "service", "federatedservices", "usage") + if cfg.WAFGatewayExtensionEnabled { + enabledControllers = append(enabledControllers, "applicationlayer") + } } return &kubeControllersComponent{ @@ -234,6 +279,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 +293,19 @@ 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 { + // 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 + } + } + return nil } func (c *kubeControllersComponent) SupportedOSType() rmeta.OSType { @@ -283,6 +346,26 @@ 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.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 + // 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()) @@ -476,6 +559,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 +740,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 c.cfg.WASMPullSecret != nil { + env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.WASMPullSecret.Name}) + } + + // 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}) + } + } } if c.cfg.MetricsServerTLS != nil { @@ -585,6 +787,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 +839,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: applicationlayer.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 +864,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 +1017,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 +1031,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 } @@ -846,6 +1076,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(uint16(applicationlayer.WAFWebhookContainerPort)), + }, + }) + } + if r, err := cfg.K8sServiceEp.DestinationEntityRule(); r != nil && err == nil { egressRules = append(egressRules, v3.Rule{ Action: v3.Allow, diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index e56170908f..9f30802d99 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -16,10 +16,12 @@ package kubecontrollers_test import ( "fmt" + "path/filepath" . "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" @@ -40,6 +42,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" @@ -240,11 +243,28 @@ 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.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"}, } instance.Variant = operatorv1.CalicoEnterprise + 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 + 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 + // 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()) @@ -262,19 +282,122 @@ 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/envoy-proxy:" + components.ComponentGatewayAPIEnvoyProxy.Version, + })) + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_PULL_SECRET", Value: kubecontrollers.WASMPullSecretName, + })) + // 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: kubecontrollers.WASMCACertName, })) 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") + + // 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() { @@ -326,6 +449,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 +483,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{""}, @@ -386,6 +511,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"}, } @@ -393,6 +520,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 +541,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 +641,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 +709,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 +744,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{""}, 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") + } +}