diff --git a/test/e2e/gateway-listener-hostname-collision/chainsaw-test.yaml b/test/e2e/gateway-listener-hostname-collision/chainsaw-test.yaml new file mode 100644 index 0000000..2ef818e --- /dev/null +++ b/test/e2e/gateway-listener-hostname-collision/chainsaw-test.yaml @@ -0,0 +1,183 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json +# +# Issue #219 — a tenant Gateway whose listeners use the default ports/protocols +# (80/HTTP, 443/HTTPS) WITHOUT a hostname is rejected at admission with the +# misleading upstream message: +# +# Combination of port, protocol and hostname must be unique for each listener +# +# The spec is valid per upstream Gateway API (listener hostname is optional). +# The rejection comes from NSO's default-listener injection: the mutating +# webhook unconditionally appends hostname-less default-http (80/HTTP) and +# default-https (443/HTTPS) listeners. When a tenant defines their own 80/HTTP +# or 443/HTTPS listener and omits the hostname, defaulting produces duplicate +# (port, protocol, hostname=empty) tuples, and the upstream CRD CEL rejects the +# whole object before NSO's own validating webhook can surface a clear reason. +# +# This is a pure admission-time reproduction: the rejection happens on the +# control-plane (nso-standard) cluster, so no downstream/edge wiring is needed. +# We drive it exactly the way the issue did — kubectl apply --dry-run=server — +# which runs the mutating webhook + CRD CEL without persisting anything. +# +# The probes mirror the issue's investigation matrix: +# B (decisive) — 1 listener, 80/HTTP, no hostname -> rejected +# A — 2 listeners, 80/HTTP + 443/HTTPS, no hostname -> rejected +# D (control) — 1 listener, 80/HTTP, WITH a hostname -> accepted +# +# Probe B is decisive: a single tenant listener cannot collide with itself, yet +# it is rejected — proving the failure is the injected-default collision, not +# tenant-side duplication. Probe D is the positive control: adding a hostname +# makes the tuple distinct from the injected defaults and the object is admitted. +# +# Run against unfixed code (main / this branch), all three probes pass: the bug +# reproduces. Once datum-cloud/network-services-operator#220 merges, probes A/B +# are rejected by NSO's defaulting webhook FIRST, with a clear hostname-required +# message instead of "must be unique" — so this test's expected message will +# change with the fix (see the grep in each probe). +# +# concurrent: false — creating the shared managed GatewayClass coexists with the +# other gateway e2e tests via apply (not create), but keep it serialized to +# avoid GatewayClass churn racing their admission paths. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: gateway-listener-hostname-collision +spec: + concurrent: false + bindings: + - name: gatewayClassName + value: (join('-', ['e2e', $namespace])) + cluster: nso-standard + steps: + - name: Create a managed GatewayClass + description: | + The Gateway webhooks only fire for a GatewayClass whose controllerName + matches the operator's managed controller. Without this, shouldProcess() + short-circuits and neither defaulting nor validation runs. + try: + - apply: + cluster: nso-standard + resource: + apiVersion: gateway.networking.k8s.io/v1 + kind: GatewayClass + metadata: + name: ($gatewayClassName) + spec: + controllerName: gateway.networking.datumapis.com/external-global-proxy-controller + + - name: Probe B (decisive) — single hostname-less 80/HTTP listener is rejected + description: | + A single tenant listener cannot collide with itself. Its rejection with + "must be unique" proves the injected default-http (80/HTTP, no hostname) + is what duplicates the tuple. + try: + - script: + cluster: nso-standard + env: + - name: GATEWAY_CLASS + value: ($gatewayClassName) + content: | + set -u + out=$(kubectl apply --dry-run=server -f - 2>&1 <&1 <&1 <