Skip to content

fix(webhook): scope domain uniqueness to the listener - #235

Open
H34rtUnd3rB14d3 wants to merge 2 commits into
kaasops:mainfrom
H34rtUnd3rB14d3:fix/domain-uniqueness-per-listener
Open

H34rtUnd3rB14d3 wants to merge 2 commits into
kaasops:mainfrom
H34rtUnd3rB14d3:fix/domain-uniqueness-per-listener

Conversation

@H34rtUnd3rB14d3

@H34rtUnd3rB14d3 H34rtUnd3rB14d3 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The validating webhook rejects a VirtualService when another VirtualService on the
same node already uses one of its domains — even when the two are attached to different
listeners:

duplicate domain 'example.com' for node <node>

Envoy resolves an incoming connection to a listener first and only then picks a filter
chain inside it, so domains only have to be unique within a single listener. Two
listeners are independent even when they share a port and differ only by address.

* is hit hardest, because it is the usual way to say "accept anything on this port":
two listeners could not both have a catch-all.

Cause

Collisions were keyed by (nodeID, domain) — nodeIDDomain() in
internal/xds/updater/updater.go. The listener never entered the key, so the check was
strictly broader than the constraint it was meant to enforce.

Change

Key collisions by (nodeID, listener, domain). The listener's namespaced name is the
scope, not its address:port — that keeps two listeners independent when they share a
port. Duplicates within one listener are still rejected, and the error now names the
listener:

duplicate domain example.com for node test on listener envoy-xds-controller/http2

Three chart CRDs — endpoints, secrets, virtualhosts — are also synced with
config/crd/bases, from which they had drifted. They have no Go types left since #178,
so controller-gen does not regenerate them; without the sync the chart cannot be
installed over CRDs already applied from config/crd, which breaks the e2e suite.

Tests

Three e2e specs matching the acceptance criteria, in
test/e2e/domain_per_listener_test.go:

  • the same domain served from two listeners on different ports, each port answering with
    its own VirtualService;
  • the same for *;
  • a duplicate within one listener still rejected by the webhook.

They were verified to fail without this change, with
duplicate domain dup.kaasops.io for node test.

Every VirtualService in them gets its own listener on purpose: a non-TLS listener puts
no match criteria on its filter chain, so two VirtualServices on one such listener
collide in Envoy whatever their domains are — the specs would otherwise fail for an
unrelated reason.

Unit tests in internal/xds/updater/light_validator_test.go cover the light path,
including two listeners that share a port and differ only by address.

Verification

  • make lint — no issues
  • make test — no failures
  • make test-e2e — 52/52 specs pass on a clean kind cluster
  • reproduced and re-verified by hand in a local kind cluster with Envoy 1.39.1

Domain collisions were keyed by (nodeID, domain), so the same domain on two
VirtualServices attached to different listeners was rejected as a duplicate.
Envoy resolves an incoming connection to a listener first and only then picks
a filter chain inside it, so domains only have to be unique within a single
listener. '*' hit this hardest, being the usual way to say "accept anything on
this port": two listeners could not both have a catch-all.

Key collisions by (nodeID, listener, domain) instead. The listener's namespaced
name is the scope - two listeners stay independent even when they share a port
but differ by address. Duplicates within one listener are still rejected, and
the error now names the listener.

The store index keeps its map shape and holds the composite key, since nothing
else reads it. In the light validation path domains come from route
configurations, which do not carry a listener; filter chains and route
configurations are both named after their VirtualService, so the listener is
recovered by matching those names, falling back to the heavy dry-run when a
route configuration cannot be attributed.

Three e2e specs cover the acceptance criteria: the same domain served from two
listeners on different ports, the same for '*', and a duplicate within one
listener still being rejected. They were verified to fail without this change
with "duplicate domain dup.kaasops.io for node test". Every VirtualService in
them gets its own listener on purpose - a non-TLS listener puts no match
criteria on its filter chain, so two VirtualServices on one such listener
collide in Envoy whatever their domains are.

Running the e2e suite also surfaced three chart CRDs - endpoints, secrets and
virtualhosts - that had drifted from config/crd/bases. They have no Go types
left, so controller-gen does not regenerate them, and only the config/ copies
were refreshed during an earlier refactoring. The stale copies made the chart
install fail against CRDs already applied from config/crd, which is what broke
the suite locally. Copy the current versions over so both sources agree again.
unparam flags it now that the helper has four callers, all passing "ns".
The parameter never carried any variation, so replace it with a constant
shared with the listener fixtures.
}
}

func TestLightValidator_UpdatePrevVSExcluded(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test wasn't moved to the listener-scoped key: the index is seeded with a bare b.com, and the stub returns Resources without Listener, so the candidate looks up default/|b.com and never finds anything. It stays green even with the exclusion in excludePreviousVSDomains turned off (I tried: this one passes, the multi-node test next to it fails). Could you seed ldk(testListenerA, "b.com") and set Listener: testListenerA in both stub branches, like the neighbouring tests do?

nodeDom := nodeIDDomain(nodeID, ldKey)
if _, ok := nodeIDDomainsSet[nodeDom]; ok {
return fmt.Errorf("duplicate domain %s for node %s", domain, nodeID), nil, vsStatuses, metrics
return fmt.Errorf("duplicate domain %s for node %s on listener %s",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the domain is unquoted, while the light path (L1096) and validateDomainsWithinVS quote it. The e2e check for duplicate domain dup.kaasops.io matches only this form, so it passes only while webhook.lightDryRun is off. Maybe switch both heavy-path messages (here and L475) to '%s' and update the e2e string?

// they come from, so the listener owning a filter chain also owns the route
// configuration with the same name. That gives us the listener scope domains
// have to be unique within.
rcListener, err := c.routeConfigToListener(nodeID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Route configurations (L980) and listeners (here) come from two separate cache reads, each under its own read lock, and SetSnapshot can land in between. Then route configs from one snapshot get attributed using listeners from the next one. Would it make sense to take the snapshot once and read both resource types from it?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants