The TenantGateway controller writes an Accepted condition onto every HTTPRoute and TLSRoute that names its Gateway, and decides that condition from one input: which route won the race for the hostname. Route kind, listener protocol, and the listener's own allowedRoutes never enter the decision. A route can therefore carry Accepted=True from this controller while the GatewayClass controller refuses to attach it, and a route with no spec.hostnames gets no condition at all. Separately, a failed status write is logged and discarded, so a reconcile reports success having left a stale condition on the cluster.
Mechanism
resolveHostnameOwners in internal/controller/tenantgateway/conflict.go:71-104 sorts the routes claiming a hostname by cozy- namespace prefix, then namespace, then name, and declares refs[0] the winner. Nothing else is consulted. The routeRef it sorts (conflict.go:55-60) carries kind, namespace, name and the raw parentRef, and the sort function at conflict.go:79-89 reads only namespace and name.
updateRouteStatuses (conflict.go:117-157) then stamps Accepted=True for every ref not in losers, and Accepted=False with Reason=HostnameConflict for the rest. "Not a loser" is the entire happy-path test.
Three concrete divergences from what the GatewayClass controller will do:
- Kind versus listener protocol. A TLSRoute whose hostname is served only by an HTTPS-terminate listener wins the race and is told
Accepted=True. Gateway API will not attach a TLSRoute to a Terminate listener.
allowedRoutes on the winning listener. Eligibility is decided before any listener is picked; the listener that ends up carrying the hostname may reject the route's namespace or kind. The port-80 listener in particular carries a strictly narrower namespace selector (internal/controller/tenantgateway/renderers.go:135-141) than the port-443 listeners (renderers.go:106-118).
- Routes with no hostnames.
collectHostnameClaims accumulates refs only inside for _, h := range route.Spec.Hostnames (internal/controller/tenantgateway/reconciler.go:336-338 for HTTPRoute, :362-364 for TLSRoute). A route with an empty or absent spec.hostnames contributes no map entry, so it never reaches allRefs (reconciler.go:148-153) and never receives a condition — even though such a route attaches to every listener whose selector admits it.
The same collector returns early for two cert modes: reconciler.go:285-287 returns nil, nil when certMode is dns01 or existingSecret. In those modes claims is empty, so no route on the Gateway receives any condition from this controller at all.
The swallowed writes are at conflict.go:141 and conflict.go:153: both call sites log the error from updateRouteParentStatus and fall through, and the function returns nil at conflict.go:156. runReconcileSteps (reconciler.go:177-179) therefore continues to the redirect step and the final reconcileStatus, and the TenantGateway goes Ready with route conditions the controller failed to write. Because updateRouteParentStatus short-circuits when the merge is a no-op (conflict.go:178-180, :189-191), a write that failed once is retried on the next reconcile — but nothing surfaces that it failed, and a conflict-write failure means a route keeps advertising Accepted=True for a hostname it has lost.
Observing it
On a cluster with a TenantGateway in HTTP-01 mode, create a TLSRoute in the publishing tenant namespace whose spec.hostnames names a host that has only an HTTPS-terminate listener, and with a parentRef naming the Gateway without sectionName. kubectl get tlsroute <name> -o jsonpath='{.status.parents}' shows two entries: this controller's, with Accepted=True, and the GatewayClass controller's, with a refusal. Separately, create an HTTPRoute with no spec.hostnames and a parentRef naming the same Gateway; its .status.parents carries no entry with controllerName: gateway.cozystack.io/tenantgateway-controller.
Impact
Status-only. The data plane is decided by the GatewayClass controller, which writes its own RouteParentStatus entry under a different controller name, so no traffic is misrouted by this. The cost is diagnostic: an owner reading Accepted=True on a route that is not serving has been told the opposite of the truth by the platform's own controller, and the second entry that carries the real answer is easy to miss. The hostname-less case is the worst of the three, because such a route is the one most likely to be attaching somewhere unintended and it is the one the controller says nothing about.
Fix shape
Two separable changes.
For the decision: make eligibility consult the listener that would carry the hostname, not just the hostname. The renderer already knows which listener each hostname maps to; the ownership pass should ask that listener whether it admits the route's kind and namespace, and refuse with a distinct reason when it does not. Gateway API has reasons for these outcomes — NotAllowedByListeners for the namespace and kind refusals, NoMatchingListenerHostname for a hostname no listener answers — so each is a new withdrawal cause with its own message rather than a change to HostnameConflict. Hostname-less routes need a decision of their own: either a condition stating that the controller does not arbitrate them, or inclusion in the race against every listener hostname they would match.
For the writes: propagate the error from updateRouteParentStatus out of updateRouteStatuses so runReconcileSteps requeues. Keep the loop going over the remaining refs first, then return an aggregate, so one unreachable route does not hide the rest.
#3764 tracks a fourth divergence in the same function — a parentRef whose sectionName names no rendered listener is also told Accepted=True — and proposes NoMatchingParent for it. The three above are separate causes but the same fix site; whichever lands first should leave room for the others.
The TenantGateway controller writes an
Acceptedcondition onto every HTTPRoute and TLSRoute that names its Gateway, and decides that condition from one input: which route won the race for the hostname. Route kind, listener protocol, and the listener's ownallowedRoutesnever enter the decision. A route can therefore carryAccepted=Truefrom this controller while the GatewayClass controller refuses to attach it, and a route with nospec.hostnamesgets no condition at all. Separately, a failed status write is logged and discarded, so a reconcile reports success having left a stale condition on the cluster.Mechanism
resolveHostnameOwnersininternal/controller/tenantgateway/conflict.go:71-104sorts the routes claiming a hostname bycozy-namespace prefix, then namespace, then name, and declaresrefs[0]the winner. Nothing else is consulted. TherouteRefit sorts (conflict.go:55-60) carries kind, namespace, name and the rawparentRef, and the sort function atconflict.go:79-89reads onlynamespaceandname.updateRouteStatuses(conflict.go:117-157) then stampsAccepted=Truefor every ref not inlosers, andAccepted=FalsewithReason=HostnameConflictfor the rest. "Not a loser" is the entire happy-path test.Three concrete divergences from what the GatewayClass controller will do:
Accepted=True. Gateway API will not attach a TLSRoute to aTerminatelistener.allowedRouteson the winning listener. Eligibility is decided before any listener is picked; the listener that ends up carrying the hostname may reject the route's namespace or kind. The port-80 listener in particular carries a strictly narrower namespace selector (internal/controller/tenantgateway/renderers.go:135-141) than the port-443 listeners (renderers.go:106-118).collectHostnameClaimsaccumulates refs only insidefor _, h := range route.Spec.Hostnames(internal/controller/tenantgateway/reconciler.go:336-338for HTTPRoute,:362-364for TLSRoute). A route with an empty or absentspec.hostnamescontributes no map entry, so it never reachesallRefs(reconciler.go:148-153) and never receives a condition — even though such a route attaches to every listener whose selector admits it.The same collector returns early for two cert modes:
reconciler.go:285-287returnsnil, nilwhencertModeisdns01orexistingSecret. In those modesclaimsis empty, so no route on the Gateway receives any condition from this controller at all.The swallowed writes are at
conflict.go:141andconflict.go:153: both call sites log the error fromupdateRouteParentStatusand fall through, and the function returnsnilatconflict.go:156.runReconcileSteps(reconciler.go:177-179) therefore continues to the redirect step and the finalreconcileStatus, and the TenantGateway goesReadywith route conditions the controller failed to write. BecauseupdateRouteParentStatusshort-circuits when the merge is a no-op (conflict.go:178-180,:189-191), a write that failed once is retried on the next reconcile — but nothing surfaces that it failed, and a conflict-write failure means a route keeps advertisingAccepted=Truefor a hostname it has lost.Observing it
On a cluster with a TenantGateway in HTTP-01 mode, create a TLSRoute in the publishing tenant namespace whose
spec.hostnamesnames a host that has only an HTTPS-terminate listener, and with aparentRefnaming the Gateway withoutsectionName.kubectl get tlsroute <name> -o jsonpath='{.status.parents}'shows two entries: this controller's, withAccepted=True, and the GatewayClass controller's, with a refusal. Separately, create an HTTPRoute with nospec.hostnamesand aparentRefnaming the same Gateway; its.status.parentscarries no entry withcontrollerName: gateway.cozystack.io/tenantgateway-controller.Impact
Status-only. The data plane is decided by the GatewayClass controller, which writes its own
RouteParentStatusentry under a different controller name, so no traffic is misrouted by this. The cost is diagnostic: an owner readingAccepted=Trueon a route that is not serving has been told the opposite of the truth by the platform's own controller, and the second entry that carries the real answer is easy to miss. The hostname-less case is the worst of the three, because such a route is the one most likely to be attaching somewhere unintended and it is the one the controller says nothing about.Fix shape
Two separable changes.
For the decision: make eligibility consult the listener that would carry the hostname, not just the hostname. The renderer already knows which listener each hostname maps to; the ownership pass should ask that listener whether it admits the route's kind and namespace, and refuse with a distinct reason when it does not. Gateway API has reasons for these outcomes —
NotAllowedByListenersfor the namespace and kind refusals,NoMatchingListenerHostnamefor a hostname no listener answers — so each is a new withdrawal cause with its own message rather than a change toHostnameConflict. Hostname-less routes need a decision of their own: either a condition stating that the controller does not arbitrate them, or inclusion in the race against every listener hostname they would match.For the writes: propagate the error from
updateRouteParentStatusout ofupdateRouteStatusessorunReconcileStepsrequeues. Keep the loop going over the remaining refs first, then return an aggregate, so one unreachable route does not hide the rest.#3764 tracks a fourth divergence in the same function — a
parentRefwhosesectionNamenames no rendered listener is also toldAccepted=True— and proposesNoMatchingParentfor it. The three above are separate causes but the same fix site; whichever lands first should leave room for the others.