Skip to content

gateway: route Accepted status is decided by the hostname race alone, and status write failures are swallowed #3865

Description

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/networkingIssues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn)kind/bugCategorizes issue or PR as related to a bugtriage/needs-triageIndicates an issue needs triage by a maintainer

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions