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

URL: https://scalws.com/docs/adr/0052-gateway-api/
Source: `docs/adr/0052-gateway-api.md` in the ScalWS repository

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.
