Summary
A tenant Gateway (class datum-external-global-proxy) whose listeners use the default ports/protocols (80/HTTP, 443/HTTPS) without a hostname is rejected at admission with a misleading upstream message:
Gateway.gateway.networking.k8s.io "http-gateway" is invalid: spec.listeners:
Invalid value: 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 colliding with the tenant's listeners.
Reproduction (live, kubectl apply --dry-run=server)
| # |
Listeners |
Hostname(s) |
Result |
| A |
2 — 80/HTTP, 443/HTTPS |
none |
❌ "must be unique" |
| B |
1 — 80/HTTP |
none |
❌ "must be unique" |
| C |
2 — 80/HTTP, 443/HTTPS |
distinct |
✅ |
| D |
1 — 80/HTTP |
set |
✅ |
| E |
2 — 80/HTTP, 443/HTTPS |
same hostname |
✅ |
Probe B is decisive: a single tenant listener cannot collide with itself, yet it is rejected — so the failure is not tenant-side tuple duplication.
Root cause
SetDefaultListeners (internal/util/gateway/listeners.go) unconditionally appends two listeners, both without a hostname at creation:
default-http → 80 / HTTP
default-https → 443 / HTTPS
IsDefaultListener keys off the listener name (default-http / default-https) only. When a tenant defines their own 80/HTTP and/or 443/HTTPS listeners under different names and omits hostname, defaulting produces duplicate (port, protocol, hostname=∅) tuples — e.g. tenant http (80,HTTP,∅) + injected default-http (80,HTTP,∅). The upstream Gateway API CRD CEL then rejects the whole object with Combination of port, protocol and hostname must be unique for each listener.
Probes C/D/E pass because giving the tenant listeners a hostname makes their tuple distinct from the hostname-less injected defaults.
Separately, validateListeners (internal/validation/gateway_validation.go:72) already requires a hostname on non-default listeners (must be set to "…" or a custom hostname). So hostname-less tenant listeners are unsupported by design — but the tenant never sees that clear message; the injected-default collision trips the CEL first and surfaces the confusing "must be unique" error instead.
Impact
- Misleading error: tenants see a uniqueness complaint about listeners they didn't duplicate, with no indication that defaults were injected.
- Blocks
datum-cloud/infra integration test tests/networking/http-gateway (datum-cloud/infra#2804), whose Gateway defines plain http/https listeners with no hostname.
Suggested fixes (pick per design intent)
- Make defaulting idempotent against tenant listeners — skip injecting
default-http/default-https when a tenant listener already covers that port/protocol (dedupe by (port, protocol), not just name).
- Set the expected DNS hostname on the injected default listeners at creation so their tuples are always distinct.
- If hostname-less listeners are simply unsupported, fail fast in
validateListeners with the clear hostname is required message before defaulting can produce a colliding tuple — so the surfaced error is actionable.
Refs
- datum-cloud/infra#2804 (test failure + full investigation matrix)
Summary
A tenant
Gateway(classdatum-external-global-proxy) whose listeners use the default ports/protocols (80/HTTP, 443/HTTPS) without ahostnameis rejected at admission with a misleading upstream message:The spec is valid per upstream Gateway API (listener
hostnameis optional). The rejection comes from NSO's default-listener injection colliding with the tenant's listeners.Reproduction (live,
kubectl apply --dry-run=server)80/HTTP,443/HTTPS80/HTTP80/HTTP,443/HTTPS80/HTTP80/HTTP,443/HTTPSProbe B is decisive: a single tenant listener cannot collide with itself, yet it is rejected — so the failure is not tenant-side tuple duplication.
Root cause
SetDefaultListeners(internal/util/gateway/listeners.go) unconditionally appends two listeners, both without ahostnameat creation:default-http→80 / HTTPdefault-https→443 / HTTPSIsDefaultListenerkeys off the listener name (default-http/default-https) only. When a tenant defines their own80/HTTPand/or443/HTTPSlisteners under different names and omitshostname, defaulting produces duplicate(port, protocol, hostname=∅)tuples — e.g. tenanthttp(80,HTTP,∅) + injecteddefault-http(80,HTTP,∅). The upstream Gateway API CRD CEL then rejects the whole object withCombination of port, protocol and hostname must be unique for each listener.Probes C/D/E pass because giving the tenant listeners a
hostnamemakes their tuple distinct from the hostname-less injected defaults.Separately,
validateListeners(internal/validation/gateway_validation.go:72) already requires ahostnameon non-default listeners (must be set to "…" or a custom hostname). So hostname-less tenant listeners are unsupported by design — but the tenant never sees that clear message; the injected-default collision trips the CEL first and surfaces the confusing "must be unique" error instead.Impact
datum-cloud/infraintegration testtests/networking/http-gateway(datum-cloud/infra#2804), whose Gateway defines plainhttp/httpslisteners with no hostname.Suggested fixes (pick per design intent)
default-http/default-httpswhen a tenant listener already covers that port/protocol (dedupe by(port, protocol), not just name).validateListenerswith the clearhostname is requiredmessage before defaulting can produce a colliding tuple — so the surfaced error is actionable.Refs