Internals: decision records

ADR-0052: Gateway API as a second input of the ingress sidecar

Status: accepted, 2026-10-15

Context

Kubernetes phase 2b (ADR-0051) serves networking.k8s.io/v1 Ingress through the scalws-ingress sidecar. New installations and most guides for leaving ingress-nginx point at the Gateway API instead, and the Gateway API implementations page lists only implementations with a conformance report. Phase 2c adds GatewayClass, Gateway, HTTPRoute and ReferenceGrant at the level of the GATEWAY-HTTP profile of Gateway API v1.6.3. The data plane already had most of what HTTPRoute needs (path types, method, header and query matching, header changes, rewrites, Gateway-style redirects, weighted pools, per-route timeouts, ADR-0049 and ADR-0050), but two things did not fit: every application was served on every listener, while Gateway listeners are per port and the usual HTTP-to-HTTPS pattern needs different routes for one host on the two ports; and a wildcard domain covered one label, while a Gateway API wildcard covers one or more.

The controller must keep the properties of 2b: one bad resource never breaks the others, scalwsd never receives an invalid configuration, nothing from a resource is formatted into configuration text, and access-related settings ScalWS cannot honour fail closed.

Decision

  • One controller, one translation for both inputs. scalws-ingress run --gateway-api also watches GatewayClass, Gateway, HTTPRoute, ReferenceGrant and Namespace; --ingress=false turns the Ingress input off. Both inputs are translated in one pass into one scalws_config::Config, because they compete for the same listeners and hosts. HTTPRoute applications go to the reserved tenant gateway; pools are shared with Ingresses under one claims table. The file and member channels, the debounce, the isolation by incremental validation (an HTTPRoute is a unit like an Ingress) and the leader Lease are those of ADR-0051.
  • A shared data plane with fixed listeners mapped by port. scalwsd cannot add listeners without a restart, so the pod’s listeners stay what the chart declares (http, https, and one per extraPorts entry) and --gateway-listener PORT=NAME maps Gateway listener ports onto them. A Gateway listener on another port is Accepted: False, PortUnavailable. Every Gateway of the class shares the pods and the Service address.
  • Ownership of (listener, host) across inputs and Gateways. One rule for both inputs: the oldest claim wins (Ingress namespace by its oldest Ingress for the host, Gateway by an explicit listener hostname, HTTPRoute namespace through a Gateway by the route’s age; ties Ingress, Gateway, HTTPRoute, then namespace/name). A host covered by an older wildcard of another namespace is refused. Ingress and HTTPRoute never share a host. Inside a Gateway the host belongs to the namespace of its oldest route; Gateways of one namespace are one owner and share their hosts; Gateways of different namespaces conflict (Conflicted, HostnameConflict). A Gateway’s own explicit listener hostname delegates the host to the routes its allowedRoutes admit. --cross-namespace-paths lifts only the namespace rules inside one input. An Ingress spec.defaultBackend claims the catch-all *. Unserved sources keep their claims, so refusing one never hands its users to a newer one.
  • Listener-scoped applications and **. domains in the data plane. Applications gain an optional listeners list (one host router per listener when any application sets it), domains gain **.example.com (one or more labels; lookup exact, *., longest **., catch-all), weighted backends gain {status, weight} entries (a share that answers 500 to 599, for invalid backendRefs), the route tie-break among equal paths follows the Gateway API (method, then header count, then query count; ADR-0050 amended), and a listener’s default certificate may have server_names: []. All are optional; configurations without them take the existing code paths.
  • Hand-written serde types with gateway_resource!. The gateway-api crate needs kube 3.x and k8s-openapi 0.27; the workspace is on kube 1.1 and k8s-openapi 0.25. The controller defines serde structs for the fields it reads and writes, and a small macro implements kube::Resource with a static group, version, kind and plural, so the generic watch, store and API code of 2b is unchanged and access stays typed. Enums have an Unknown variant; unknown fields inside matches and filters make the rule unsupported, because ignoring a narrowing field widens what the rule matches.
  • Status drafted by the translation, finished after the reload. The translation computes the conditions of every GatewayClass, Gateway and HTTPRoute; Programmed depends on the reload, so the leader finishes and writes the status after it, only when it changed, keeping lastTransitionTime of unchanged conditions. GatewayClass and Gateway status are merge patches of the fields the controller owns; HTTPRoute.status.parents, shared with other controllers, is read, the controller’s own entries replaced, and written with the resourceVersion it read.
  • Fail closed. Client certificate validation, listener tls.options, the filters RequestMirror, CORS, ExternalAuth and ExtensionRef, unknown filters, filters on backendRefs, and header changes the data plane forbids are not served: the listener is Accepted: False (UnsupportedValue), or the rule’s matches answer 500 with their own keys and the parent gets PartiallyInvalid (Dropped Rule ...). ExternalName Services and appProtocol values other than empty, http and kubernetes.io/ws are refused.
  • CRDs are never installed by the chart. They are shared by every implementation, and Helm deletes the CRDs it owns on uninstall. The controller does not require them: it discovers the API group at start and every 60 s and serves Ingresses alone while they are missing.

Alternatives

  • A data plane per Gateway (a Deployment and a Service for each Gateway). This is what HTTPRouteMultipleGateways expects. Rejected for this phase: it needs the controller to create workloads, which belongs to the operator of phase 3. Per-Gateway addresses on a shared Deployment (fixed listener slots in scalwsd and one Service per Gateway created by the leader) are the next phase, 2d.
  • Listeners generated from Gateways. Every new port would restart scalwsd and drop connections, or need the multi-instance scalws-controller (phase 3).
  • DynamicObject for the Gateway API kinds. It turns every field read into untyped JSON access. kube::core::Object was also rejected: its dynamic type is an ApiResource, which the generic watch code cannot default.
  • A kube major upgrade to use the gateway-api crate. It touches the whole workspace and is not needed for the fields this phase reads.
  • Merging Ingress and HTTPRoute rules on one host. Their precedence, regex mode, HTTPS redirect defaults and policies differ, and a merge would let one API add paths to a host owned through the other.

Consequences

  • The conformance report is partial: GATEWAY-HTTP core 35 passed with one skip, HTTPRouteMultipleGateways, which needs different routes for one host through two Gateways’ own addresses; every claimed extended feature (17 tests) passes. Phase 2d, per-Gateway addresses, is what makes it success.
  • Gateways are separated by host, not by address. The trust boundary is RBAC on gateways: whoever can create a Gateway of the class can claim a host first and keep it, as an Ingress namespace can. The guide says so.
  • The wildcard rule changes 2b behaviour (2b and this ADR were released together in 1.0.0): an Ingress host under an older wildcard Ingress of another namespace is refused (PathConflict), where 2b served it as a separate host; --cross-namespace-paths restores it among Ingresses.
  • The route tie-break change reorders hand-written configurations whose same-path routes differ in the kind of matcher; generated Ingress configurations keep their order.
  • The controller’s RBAC grows by the Gateway API kinds, their status, Namespaces (for allowedRoutes selectors) and get on one CRD.
  • A newer CRD bundle can add fields inside matches and filters that fail closed until the controller is upgraded.