# Gateway API

> Configure GatewayClass, Gateway and Route objects, know which team owns each, and find why a route receives no traffic.

Canonical: https://www.wiki.jodisand.me/gateway-api/
Reviewed: 2026-09-24
Related: [Kubernetes](https://www.wiki.jodisand.me/kubernetes/index.md), [Cilium](https://www.wiki.jodisand.me/cilium/index.md), [TLS and certificates](https://www.wiki.jodisand.me/tls/index.md), [HTTP and curl](https://www.wiki.jodisand.me/http/index.md), [Traefik](https://www.wiki.jodisand.me/traefik/index.md)


## Cheatsheet

Written against Gateway API v1.6 (standard channel). Every implementation (Envoy Gateway, [Cilium](https://www.wiki.jodisand.me/cilium/), Istio, NGINX Gateway Fabric, [Traefik](https://www.wiki.jodisand.me/traefik/) and others) reads the same objects; controller names and log locations differ.

| Task | Command |
| --- | --- |
| Installed API version | `kubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}'` |
| Which classes exist, and are they accepted | `kubectl get gatewayclass` |
| Is the Gateway programmed, what address | `kubectl get gateway -A` |
| Why not | `kubectl describe gateway web -n infra` |
| Routes and hostnames | `kubectl get httproute -A` |
| Route attachment status | `kubectl get httproute my-app -n my-namespace -o jsonpath='{.status.parents}' \| jq` |
| Listener address | `kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}'` |
| Backends behind a route | `kubectl get endpointslice -n my-namespace -l kubernetes.io/service-name=my-app` |
| Test a host without DNS | `curl -H 'Host: app.example.com' http://<gateway-ip>/` |
| Cross-namespace permissions | `kubectl get referencegrant -A` |
| Controller logs (Envoy Gateway example) | `kubectl logs -n envoy-gateway-system deploy/envoy-gateway` |

## A route that is not receiving traffic

Traffic needs three things to line up. The Gateway has an address and reports `Programmed=True`. The Route reports `Accepted=True` and `ResolvedRefs=True` on the parent it asked for. The backend Service has ready endpoints. Every failure is one of those three.

```sh
kubectl get gateway -A                                           # PROGRAMMED column and ADDRESS
kubectl get httproute -A
kubectl describe httproute my-app -n my-namespace | sed -n '/Status:/,$p'
kubectl get endpointslice -n my-namespace -l kubernetes.io/service-name=my-app
```

| Condition (reason) | Meaning |
| --- | --- |
| GatewayClass `Accepted=False` | No controller claims this `controllerName`, or its parameters are invalid |
| Gateway `Accepted=False` | The controller rejected the spec: unsupported protocol, port or address |
| Gateway `Programmed=False` (`AddressNotAssigned`) | Accepted, but the dataplane or load balancer is not ready yet |
| Listener `Conflicted=True` (`HostnameConflict`, `ProtocolConflict`) | Two listeners on one port clash; the conflicting listener is not served |
| Listener `ResolvedRefs=False` (`InvalidCertificateRef`) | TLS Secret missing, malformed, or in another namespace without a ReferenceGrant |
| Route `Accepted=False` (`NotAllowedByListeners`) | The listener's `allowedRoutes` does not admit this namespace or kind |
| Route `Accepted=False` (`NoMatchingListenerHostname`) | Route `hostnames` do not intersect the listener `hostname` |
| Route `Accepted=False` (`NoMatchingParent`) | `sectionName` or `port` in `parentRefs` matches no listener |
| Route `ResolvedRefs=False` (`BackendNotFound`) | Backend Service missing or wrong port |
| Route `ResolvedRefs=False` (`RefNotPermitted`) | Cross-namespace backend without a ReferenceGrant |
| Route `PartiallyInvalid=True` | Some rules are invalid and dropped, the rest are served |
| All conditions true, still 503 or 500 | No ready endpoints behind the Service, or the backend is refusing connections |
| All conditions true, 404 | No rule matched the request path, method or headers |

A route with no `status.parents` at all has not been seen by any controller. Check that `parentRefs` names an existing Gateway and that its GatewayClass is accepted.

## The resource model

| Resource | Owned by | Purpose |
| --- | --- | --- |
| `GatewayClass` | Infrastructure provider | Names the controller implementation; cluster-scoped |
| `Gateway` | Platform team | Listeners, ports, TLS certificates, which routes may attach |
| `ListenerSet` | Application or platform team | Adds listeners to an existing Gateway from another namespace (standard since v1.5) |
| `HTTPRoute`, `GRPCRoute` | Application team | L7 matching and forwarding, in the application's namespace |
| `TLSRoute`, `TCPRoute`, `UDPRoute` | Application team | SNI or port-based forwarding (TLSRoute standard since v1.5, TCP and UDP since v1.6) |
| `ReferenceGrant` | Owner of the namespace being referenced | Permission for a cross-namespace reference |
| `BackendTLSPolicy` | Service owner | TLS from the gateway to the backend (standard since v1.4) |

The split is the point. The platform team owns certificates and public addresses, application teams own their own routing, and neither needs write access to the other's namespace.

Gateway API is a set of CRDs, not part of core Kubernetes. Install the CRD bundle your implementation supports. From v1.5, a ValidatingAdmissionPolicy named `safe-upgrades.gateway.networking.k8s.io` blocks installing experimental CRDs over standard ones and downgrading below v1.5.

## A Gateway

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: web
  namespace: infra
spec:
  gatewayClassName: my-gateway-class
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - { kind: Secret, name: wildcard-example-com }   # same namespace as the Gateway
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels: { gateway-access: "true" }
    - name: http
      protocol: HTTP
      port: 80
      hostname: "*.example.com"
```

`allowedRoutes.namespaces.from` defaults to `Same`, so routes in other namespaces are rejected with `NotAllowedByListeners` until it is set to `Selector` or `All`. Each listener reports `status.listeners[].attachedRoutes`, the fastest confirmation that attachment worked. For certificate handling, see [TLS](https://www.wiki.jodisand.me/tls/).

## An HTTPRoute

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app
  namespace: my-namespace
spec:
  parentRefs:
    - { name: web, namespace: infra, sectionName: https }
  hostnames: ["app.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /v2 }
      backendRefs:
        - { name: my-app-v2, port: 80, weight: 90 }
        - { name: my-app-canary, port: 80, weight: 10 }
    - matches:
        - path: { type: Exact, value: /healthz }
      backendRefs: [{ name: my-app, port: 80 }]
      timeouts: { request: 2s }                  # extended support; check your implementation
    - matches:
        - headers: [{ name: x-beta, value: "true" }]
      backendRefs: [{ name: my-app-beta, port: 80 }]
```

Across every route attached to a listener, matches take precedence in this order: an `Exact` path, then the longest `PathPrefix`, then a method match, then the most header matches, then the most query parameter matches. Remaining ties go to the oldest route, then alphabetical `namespace/name`, then the first matching rule in list order. A request that matches nothing returns 404. `RegularExpression` path precedence is implementation-specific.

Weights split traffic proportionally across the backends of one rule. That is the whole canary mechanism: no extra CRD, no sidecar. A weight of `0` drains a backend without removing it. If one backend in a weighted rule is invalid, its share of requests gets a 500.

## Cross-namespace references

A Route may reference a Service in another namespace, and a Gateway may reference a certificate Secret in another namespace, only if that namespace contains a ReferenceGrant. The grant lives with the resource being referenced, so the owner of the data decides. Attaching a Route to a Gateway in another namespace is controlled by the listener's `allowedRoutes` instead.

```yaml
apiVersion: gateway.networking.k8s.io/v1     # v1 since Gateway API v1.5; use v1beta1 on older CRDs
kind: ReferenceGrant
metadata:
  name: allow-my-namespace-routes
  namespace: shared
spec:
  from:
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, namespace: my-namespace }
  to:
    - { group: "", kind: Service, name: shared-api }
```

A missing grant shows as `ResolvedRefs=False` with reason `RefNotPermitted`, not as a 404.

## Filters

Filters modify requests or responses for the rule they sit in. Implementations should apply them in the order listed. `URLRewrite` and `RequestRedirect` cannot share a rule, `RequestRedirect` cannot be combined with `backendRefs`, and most filter types may appear only once per rule.

```yaml
  rules:
    - matches: [{ path: { type: PathPrefix, value: /api } }]
      filters:
        - type: RequestHeaderModifier
          requestHeaderModifier:
            set: [{ name: x-env, value: prod }]
            remove: ["x-internal"]
        - type: URLRewrite
          urlRewrite:
            path: { type: ReplacePrefixMatch, replacePrefixMatch: / }   # /api/users -> /users
        - type: RequestMirror
          requestMirror:
            backendRef: { name: my-app-shadow, port: 80 }
            percent: 10                                                  # mirror 10% of requests
      backendRefs: [{ name: my-app, port: 80 }]
---
# HTTP to HTTPS redirect: a separate route attached to the port 80 listener, with no backendRefs
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: https-redirect, namespace: infra }
spec:
  parentRefs: [{ name: web, sectionName: http }]
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect: { scheme: https, statusCode: 301 }
```

`RequestMirror` sends a copy of each request and discards the response, which suits shadow-testing a new version under real load. Mirrored writes still happen, so point it only at backends that are safe to receive duplicate traffic. The `CORS` filter is standard since v1.5.

| Filter type | Level | What it does |
| --- | --- | --- |
| `RequestHeaderModifier` | Core | `set`, `add`, `remove` request headers before forwarding |
| `ResponseHeaderModifier` | Core | Same operations on the response |
| `RequestRedirect` | Core | Return a 301/302 with a new `scheme`, `hostname`, `port` or `path`; no backend is contacted |
| `URLRewrite` | Extended | Change `hostname` or `path` (`ReplaceFullPath`, `ReplacePrefixMatch`) before forwarding |
| `RequestMirror` | Extended | Copy requests to another backend; `percent` or `fraction` limits the share |
| `CORS` | Extended | Preflight handling and `Access-Control-*` headers at the gateway |
| `ExtensionRef` | Implementation-specific | Reference a controller's own CRD, for example a Traefik `Middleware` or Envoy Gateway policy |

A filter can also sit inside a single `backendRefs[]` entry, in which case it applies only to requests sent to that backend. That is how you add an `x-canary: true` header for the canary half of a weighted split without touching the stable half.

## Matching in detail

A rule's `matches` list is a logical OR; the fields inside one match are ANDed. A rule with no `matches` matches every request on the hostname.

```yaml
  rules:
    - matches:
        - path: { type: PathPrefix, value: /api }
          method: GET                                          # standard HTTP method names, upper case
          queryParams: [{ name: version, value: "2" }]         # Exact by default
        - path: { type: RegularExpression, value: "^/v[0-9]+/users/[0-9]+$" }   # implementation-specific dialect
          headers:
            - { type: RegularExpression, name: user-agent, value: "^Mozilla.*" }
      backendRefs: [{ name: my-app, port: 80 }]
```

`PathPrefix` matches on path element boundaries, so `/api` matches `/api` and `/api/users` but not `/apiary`. Path values must start with `/` and are compared before any `URLRewrite` runs. Header names are case-insensitive and only the first value of a repeated header is considered. Hostnames on the route may be a precise name or a wildcard starting with `*.`; a wildcard matches exactly one or more labels, so `*.example.com` covers `a.example.com` and `a.b.example.com` but not `example.com`. When both the listener and the route set a hostname, the route is served only for the intersection, and a more specific route hostname beats a wildcard when two routes overlap.

`kubectl explain` is the quickest way to see what your installed CRD version actually accepts:

```sh
kubectl explain httproute.spec.rules.matches.path        # allowed path types
kubectl explain httproute.spec.rules.filters --recursive | head -60
kubectl explain httproute.spec.rules.retry 2>/dev/null || echo "retry not in this CRD bundle"
```

## TLS modes

A listener has two TLS modes. `Terminate` decrypts at the gateway using `certificateRefs`, so HTTPRoutes can inspect paths and headers; this is the default and the only mode that works with `HTTPRoute`. `Passthrough` forwards the encrypted stream by SNI to a backend that holds its own certificate, and only `TLSRoute` can attach to such a listener.

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: edge, namespace: infra }
spec:
  gatewayClassName: my-gateway-class
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - { kind: Secret, name: wildcard-example-com, namespace: certs }   # needs a ReferenceGrant in certs
        options:
          example.com/min-tls-version: "1.2"        # options are implementation-defined key/value pairs
    - name: tls-passthrough
      protocol: TLS
      port: 8443
      hostname: db.example.com
      tls: { mode: Passthrough }                    # no certificateRefs; the backend terminates
      allowedRoutes: { kinds: [{ kind: TLSRoute }] }
---
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata: { name: db, namespace: my-namespace }
spec:
  parentRefs: [{ name: edge, namespace: infra, sectionName: tls-passthrough }]
  hostnames: [db.example.com]
  rules:
    - backendRefs: [{ name: my-db, port: 5432 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata: { name: allow-infra-gateways, namespace: certs }
spec:
  from: [{ group: gateway.networking.k8s.io, kind: Gateway, namespace: infra }]
  to: [{ group: "", kind: Secret }]                # omit name to allow every Secret in the namespace
```

The certificate Secret must be `kubernetes.io/tls` with `tls.crt` and `tls.key`. A `Terminate` listener with several `certificateRefs` selects by SNI; a client that sends no SNI gets the implementation's default, usually the first. Re-encrypting to the backend is a separate object, `BackendTLSPolicy`, attached to the Service rather than the route so the service owner controls how their backend is verified:

```yaml
apiVersion: gateway.networking.k8s.io/v1          # v1 since Gateway API v1.4
kind: BackendTLSPolicy
metadata: { name: my-app-tls, namespace: my-namespace }
spec:
  targetRefs: [{ group: "", kind: Service, name: my-app }]
  validation:
    hostname: my-app.my-namespace.svc.cluster.local   # SNI sent and name verified on the backend certificate
    caCertificateRefs: [{ group: "", kind: ConfigMap, name: internal-ca }]   # ConfigMap with a ca.crt key
    # wellKnownCACertificates: System                 # alternative: trust the gateway's system CA bundle
```

Verify what the gateway presents with `openssl s_client -connect <ip>:443 -servername app.example.com </dev/null | openssl x509 -noout -subject -dates`; a mismatch between `-servername` and the listener hostname is the usual reason for "wrong certificate".

## Traffic splitting and progressive delivery

Weights alone give a blind percentage canary. Combining a header match with weights gives testers a deterministic path to the new version while the general population is split:

```yaml
  rules:
    - matches: [{ headers: [{ name: x-canary, value: always }] }]   # more header matches wins precedence
      backendRefs: [{ name: my-app-v2, port: 80 }]
    - matches: [{ path: { type: PathPrefix, value: / } }]
      backendRefs:
        - { name: my-app-v1, port: 80, weight: 95 }
        - { name: my-app-v2, port: 80, weight: 5 }
```

Weights are integers from 0 to 1,000,000 and are relative, so `95`/`5` and `19`/`1` are the same split. Distribution is per request, not per client, so a browser session will flip between versions unless the application is stateless or `sessionPersistence` is supported by the implementation. A backend whose Service exists but has no ready endpoints still receives its share and those requests fail with 503, which is why a promotion script should check EndpointSlices before moving weight. Move weight in steps by patching the route rather than editing it:

```sh
kubectl patch httproute my-app -n my-namespace --type json -p '[
  {"op":"replace","path":"/spec/rules/1/backendRefs/0/weight","value":50},
  {"op":"replace","path":"/spec/rules/1/backendRefs/1/weight","value":50}]'
```

Argo Rollouts and Flagger both drive exactly this field, so a manual split and an automated one are interchangeable; see [Argo CD](https://www.wiki.jodisand.me/argocd/) for the GitOps side.

### GRPCRoute

gRPC is HTTP/2 with the method encoded in the path, so `GRPCRoute` matches on service and method instead of path and gets the same filters and weights:

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata: { name: my-grpc, namespace: my-namespace }
spec:
  parentRefs: [{ name: web, namespace: infra, sectionName: https }]
  hostnames: [grpc.example.com]
  rules:
    - matches:
        - method: { service: my.package.UserService, method: GetUser }   # type Exact by default
      backendRefs: [{ name: my-grpc, port: 9090 }]
```

The listener must be `HTTPS` (or `HTTP` with an implementation that allows h2c). An `HTTPRoute` and a `GRPCRoute` on the same hostname is a conflict; the older route wins and the other reports `Accepted=False`.

## Timeouts and retries

`timeouts.request` bounds the whole exchange from the gateway's point of view; `timeouts.backendRequest` bounds one attempt to a backend and must be no larger than `request`. Values are Go durations such as `30s` or `250ms`, and `0s` disables the timeout where the implementation permits. Both are standard since v1.1 at Extended support, which means an implementation may reject them with `UnsupportedValue`.

```yaml
  rules:
    - backendRefs: [{ name: my-app, port: 80 }]
      timeouts: { request: 10s, backendRequest: 3s }
      retry:                                       # GEP-1731; check kubectl explain before relying on it
        attempts: 2                                # retries after the first attempt
        codes: [502, 503, 504]                     # 500-504 must be supported; connection errors always retry
        backoff: 100ms
```

Retries only make sense for idempotent requests; the API does not filter by method, so put `POST` endpoints in their own rule without `retry`. When both a timeout and retries are set, the request timeout caps the total including every retry.

## Policy attachment

Anything the core API does not model (rate limits, authentication, access logging, connection limits) is expressed by an implementation's own CRD that targets a Gateway, listener, route or Service through `targetRefs`. Envoy Gateway's `BackendTrafficPolicy` and `SecurityPolicy`, Cilium's `CiliumNetworkPolicy` and Traefik's `Middleware` via `ExtensionRef` all fit this pattern. A policy on the Gateway applies to every route unless a route-level policy overrides it, so list them per implementation when a route behaves differently from its spec:

```sh
kubectl api-resources --api-group=gateway.envoyproxy.io -o name          # which policy kinds exist
kubectl get backendtrafficpolicy,securitypolicy -A 2>/dev/null           # what is attached where
```

## Migrating from Ingress

Ingress-NGINX was retired in March 2026 and receives no further fixes. [ingress2gateway](https://github.com/kubernetes-sigs/ingress2gateway) converts Ingress objects and common annotations to Gateway API resources as a starting point.

| Ingress | Gateway API |
| --- | --- |
| `ingressClassName` | `gatewayClassName` on the Gateway |
| `spec.tls` | `listeners[].tls.certificateRefs` |
| `spec.rules[].host` | `hostnames` on the Route |
| Path type `Prefix` | `path.type: PathPrefix` |
| `nginx.ingress.kubernetes.io/rewrite-target` | `URLRewrite` filter |
| `nginx.ingress.kubernetes.io/ssl-redirect` | `RequestRedirect` route on the HTTP listener |
| Controller-specific canary annotations | `backendRefs[].weight` |

Ingress-NGINX has behaviours that do not carry over, such as regex path matching and annotation-driven defaults. Run both side by side on different addresses, compare responses with `curl --resolve`, and move DNS last. See [Before you migrate](https://kubernetes.io/blog/2026/02/27/ingress-nginx-before-you-migrate/).

## Troubleshooting Gateway API

| Symptom | Likely cause | Check |
| --- | --- | --- |
| Gateway has no address | Controller not running, or the load balancer is still provisioning | `kubectl describe gateway`, controller logs, `kubectl get svc -n <controller-namespace>` |
| `attachedRoutes: 0` on the listener | Namespace, hostname or `sectionName` mismatch | Route `status.parents[].conditions`, see the table above |
| TLS handshake fails or serves the wrong certificate | Listener `ResolvedRefs=False`, or SNI does not match `hostname` | `openssl s_client -connect <ip>:443 -servername app.example.com` |
| Works by IP with `Host` header, fails by name | DNS points somewhere else | `dig app.example.com`, [DNS](https://www.wiki.jodisand.me/dns/#a-name-that-will-not-resolve) |
| Intermittent 503 | Some backends failing readiness | EndpointSlice membership over time |
| Wrong backend answers | A more specific match on another route wins | List every route on that hostname and compare matches |
| Route ignored after CRD upgrade | CRD versions older than the implementation expects | Bundle version annotation on the CRDs, implementation's compatibility matrix |
| `Accepted=False` reason `UnsupportedValue` | Field such as `timeouts`, `retry` or a regex path not supported by this implementation | `kubectl describe httproute` message text, implementation's conformance report |
| Certificate in another namespace never loads | Missing ReferenceGrant `from: Gateway` in the Secret's namespace | `kubectl get referencegrant -n <certs-ns> -o yaml`, listener `ResolvedRefs` reason |
| Passthrough listener accepts but nothing connects | `HTTPRoute` attached to a `TLS` listener, or client sends no SNI | `allowedRoutes.kinds`, `openssl s_client -servername` vs without |
| Canary weight changed but traffic did not move | Controller reconciled an older cached route, or endpoints for the new backend are not ready | `status.parents[].conditions[].observedGeneration` vs `metadata.generation`, EndpointSlice for the canary |
| gRPC calls fail with `UNIMPLEMENTED` | Method match too narrow, or `HTTPRoute` on the same hostname won the conflict | `kubectl get grpcroute,httproute -A` filtered by hostname |
| Redirect loop on HTTPS | `RequestRedirect` rule attached to the HTTPS listener as well as HTTP | `parentRefs[].sectionName` on the redirect route |
| Header filter has no effect | Filter placed under a `backendRefs[]` entry, not the rule, or a policy on the Gateway overrides it | `kubectl get httproute -o yaml`, implementation policy CRDs |

For timing and status code analysis of the requests themselves, see [HTTP](https://www.wiki.jodisand.me/http/#timing).

## Oneliners

```sh
# Every route and the parents it claims
kubectl get httproute -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.parentRefs[*].name}{"\n"}{end}'

# Routes that failed to attach to at least one parent
kubectl get httproute -A -o json | jq -r '.items[] | select([.status.parents[]?.conditions[]? | select(.type=="Accepted" and .status!="True")] | length > 0) | "\(.metadata.namespace)/\(.metadata.name)"'

# Attached route count per listener
kubectl get gateway -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{range .status.listeners[*]}{.name}={.attachedRoutes}{" "}{end}{"\n"}{end}'

# Gateway addresses
kubectl get gateway -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.status.addresses[*].value}{"\n"}{end}'

# Hit a host through the gateway address, ignoring DNS and keeping SNI correct
curl -sv --resolve app.example.com:443:"$(kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}')" https://app.example.com/healthz

# Certificates referenced by every listener
kubectl get gateway -A -o json | jq -r '.items[].spec.listeners[]?.tls?.certificateRefs[]?.name' | sort -u

# Backends referenced by routes that have no EndpointSlice (same-namespace refs)
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.rules[].backendRefs[]? | "\(.namespace // $ns) \(.name)"' | sort -u | while read -r ns svc; do kubectl get endpointslice -n "$ns" -l kubernetes.io/service-name="$svc" --no-headers 2>/dev/null | grep -q . || echo "$ns/$svc has no endpoints"; done

# Controller errors (Envoy Gateway; substitute your implementation's namespace and deployment)
kubectl logs -n envoy-gateway-system deploy/envoy-gateway --tail 200 | grep -iE 'error|reject'

# Installed CRD bundle version and channel
kubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}{"\t"}{.metadata.annotations.gateway\.networking\.k8s\.io/channel}{"\n"}'

# Which Gateway API kinds are installed at all
kubectl api-resources --api-group=gateway.networking.k8s.io -o name

# GatewayClasses and whether a controller has accepted them
kubectl get gatewayclass -o custom-columns='NAME:.metadata.name,CONTROLLER:.spec.controllerName,ACCEPTED:.status.conditions[?(@.type=="Accepted")].status'

# Every hostname served by any route, with the route that claims it
kubectl get httproute,grpcroute,tlsroute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .kind as $k | .spec.hostnames[]? | "\(.)\t\($k) \($ns)/\($n)"' | sort

# Hostnames claimed by more than one route (precedence surprises live here)
kubectl get httproute -A -o json | jq -r '.items[].spec.hostnames[]?' | sort | uniq -d

# Routes whose status lags their spec (controller has not reconciled the latest edit)
kubectl get httproute -A -o json | jq -r '.items[] | select(.metadata.generation != (.status.parents[0].conditions[0].observedGeneration // 0)) | "\(.metadata.namespace)/\(.metadata.name) gen=\(.metadata.generation)"'

# Every listener with its protocol, port, hostname and TLS mode
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.listeners[] | "\($ns)/\($n)\t\(.name)\t\(.protocol):\(.port)\t\(.hostname // "*")\t\(.tls.mode // "-")"'

# Listeners in conflict
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .status.listeners[]? | select(any(.conditions[]; .type=="Conflicted" and .status=="True")) | "\($ns)/\($n) listener \(.name)"'

# Weighted splits currently in force
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.rules[] | select((.backendRefs // []) | length > 1) | "\($ns)/\($n)\t" + ([.backendRefs[] | "\(.name)=\(.weight // 1)"] | join(" "))'

# Shift a canary to 50/50 (rule index 1, backends 0 and 1)
kubectl patch httproute my-app -n my-namespace --type json -p '[{"op":"replace","path":"/spec/rules/1/backendRefs/0/weight","value":50},{"op":"replace","path":"/spec/rules/1/backendRefs/1/weight","value":50}]'

# All ReferenceGrants and what they permit
kubectl get referencegrant -A -o json | jq -r '.items[] | .metadata.namespace as $ns | "\($ns)/\(.metadata.name)\tfrom " + ([.spec.from[] | "\(.kind)@\(.namespace)"] | join(",")) + "\tto " + ([.spec.to[] | "\(.kind)/\(.name // "*")"] | join(","))'

# Cross-namespace backendRefs that have no matching ReferenceGrant
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.rules[].backendRefs[]? | select(.namespace != null and .namespace != $ns) | "\($ns) -> \(.namespace)/\(.name)"' | sort -u | while read -r from _ to; do ns=${to%%/*}; kubectl get referencegrant -n "$ns" -o json | jq -e --arg f "$from" '.items[].spec.from[] | select(.kind=="HTTPRoute" and .namespace==$f)' >/dev/null 2>&1 || echo "no grant in $ns for routes from $from"; done

# Certificate the gateway actually serves for a hostname
openssl s_client -connect "$(kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}')":443 -servername app.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates

# Days until each listener certificate expires
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.listeners[].tls.certificateRefs[]? | "\(.namespace // $ns) \(.name)"' | sort -u | while read -r ns s; do exp=$(kubectl get secret -n "$ns" "$s" -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -enddate | cut -d= -f2); echo "$ns/$s $(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))d"; done

# Watch a route's conditions change while you edit it
kubectl get httproute my-app -n my-namespace -w -o jsonpath='{.status.parents[0].conditions[*].reason}{"\n"}'

# Does a header-based canary match: send the header and read which backend answered
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' -H 'x-canary: always' --resolve app.example.com:443:"$GW_IP" https://app.example.com/

# Fire 100 requests and count status codes during a weight shift
for _ in $(seq 100); do curl -s -o /dev/null -w '%{http_code}\n' --resolve app.example.com:443:"$GW_IP" https://app.example.com/; done | sort | uniq -c

# Live-render what kubectl would send before applying a route edit
kubectl apply -f route.yaml --dry-run=server -o yaml | yq '.status // "no status: object is new"'

# Delete every route that points at a Service which no longer exists (prints first; remove echo to act)
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.rules[].backendRefs[]? | "\($ns) \($n) \(.namespace // $ns) \(.name)"' | sort -u | while read -r rns rn sns svc; do kubectl get svc -n "$sns" "$svc" >/dev/null 2>&1 || echo kubectl delete httproute -n "$rns" "$rn"; done
```

## Scripts

Report every Gateway and Route in the cluster with its readiness, so a broken attachment shows up in one screen rather than one `describe` at a time.

```sh
#!/usr/bin/env bash
# gateway-status.sh: one line per Gateway listener and per Route parent with the conditions that matter
set -euo pipefail

cond() { jq -r --arg t "$2" '[.[]? | select(.type==$t)][0] | "\(.status // "?")/\(.reason // "-")"' <<<"$1"; }

printf '== Gateways ==\n'
kubectl get gateway -A -o json | jq -c '.items[]' | while read -r gw; do
  ns=$(jq -r .metadata.namespace <<<"$gw"); name=$(jq -r .metadata.name <<<"$gw")
  addr=$(jq -r '[.status.addresses[]?.value] | join(",") // "-"' <<<"$gw")
  prog=$(cond "$(jq '.status.conditions' <<<"$gw")" Programmed)
  printf '%s/%s addr=%s programmed=%s\n' "$ns" "$name" "${addr:--}" "$prog"
  jq -c '.status.listeners[]?' <<<"$gw" | while read -r l; do
    printf '  listener %-16s routes=%-3s accepted=%s resolved=%s conflicted=%s\n' \
      "$(jq -r .name <<<"$l")" "$(jq -r .attachedRoutes <<<"$l")" \
      "$(cond "$(jq .conditions <<<"$l")" Accepted)" "$(cond "$(jq .conditions <<<"$l")" ResolvedRefs)" \
      "$(cond "$(jq .conditions <<<"$l")" Conflicted)"
  done
done

printf '\n== Routes ==\n'
for kind in httproute grpcroute tlsroute; do
  kubectl get "$kind" -A -o json 2>/dev/null | jq -c '.items[]' | while read -r r; do
    ns=$(jq -r .metadata.namespace <<<"$r"); name=$(jq -r .metadata.name <<<"$r")
    parents=$(jq -c '.status.parents // []' <<<"$r")
    if [ "$parents" = "[]" ]; then printf '%s %s/%s NO STATUS (unseen by any controller)\n' "$kind" "$ns" "$name"; continue; fi
    jq -c '.[]' <<<"$parents" | while read -r p; do
      printf '%s %s/%s -> %s/%s accepted=%s resolved=%s\n' "$kind" "$ns" "$name" \
        "$(jq -r '.parentRef.namespace // "'"$ns"'"' <<<"$p")" "$(jq -r .parentRef.name <<<"$p")" \
        "$(cond "$(jq .conditions <<<"$p")" Accepted)" "$(cond "$(jq .conditions <<<"$p")" ResolvedRefs)"
    done
  done
done
```

Promote a canary in steps, checking that the new backend has ready endpoints and that error rate stays low before each increase; on any failure it resets the weight to zero.

```sh
#!/usr/bin/env bash
# canary-promote.sh ROUTE NAMESPACE RULE_INDEX STABLE_IDX CANARY_IDX HOST
# Steps the canary weight through 5, 25, 50, 100 and rolls back on a 5xx rate above 2%.
set -euo pipefail
route=$1 ns=$2 rule=$3 stable=$4 canary=$5 host=$6
gw_ip=${GW_IP:?set GW_IP to the gateway address}
canary_svc=$(kubectl get httproute "$route" -n "$ns" -o jsonpath="{.spec.rules[$rule].backendRefs[$canary].name}")

set_weights() {
  kubectl patch httproute "$route" -n "$ns" --type json -p "[
    {\"op\":\"replace\",\"path\":\"/spec/rules/$rule/backendRefs/$stable/weight\",\"value\":$((100 - $1))},
    {\"op\":\"replace\",\"path\":\"/spec/rules/$rule/backendRefs/$canary/weight\",\"value\":$1}]" >/dev/null
}
rollback() { echo "rolling back: canary weight 0" >&2; set_weights 0; exit 1; }
trap rollback ERR

ready=$(kubectl get endpointslice -n "$ns" -l kubernetes.io/service-name="$canary_svc" -o json | jq '[.items[].endpoints[]? | select(.conditions.ready==true)] | length')
[ "$ready" -gt 0 ] || { echo "no ready endpoints behind $canary_svc" >&2; exit 1; }

for w in 5 25 50 100; do
  set_weights "$w"; echo "canary=$w% stable=$((100 - w))%"
  sleep "${SETTLE:-30}"
  total=0 errors=0
  for _ in $(seq "${PROBES:-100}"); do
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 --resolve "$host:443:$gw_ip" "https://$host/" || echo 000)
    total=$((total + 1)); case $code in 5*|000) errors=$((errors + 1));; esac
  done
  echo "  probes=$total errors=$errors"
  [ $((errors * 100 / total)) -le 2 ] || rollback
done
echo "promotion complete"
```

Upstream reference: [Gateway API documentation](https://gateway-api.sigs.k8s.io/), [API specification](https://gateway-api.sigs.k8s.io/reference/api-spec/).


