Software Engineering WikiSE Wiki

Gateway API

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

Reviewed MarkdownEdit

On this page

Cheatsheet#

Written against Gateway API v1.6 (standard channel). Every implementation (Envoy Gateway, Cilium, Istio, NGINX Gateway Fabric, Traefik and others) reads the same objects; controller names and log locations differ.

TaskCommand
Installed API versionkubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}'
Which classes exist, and are they acceptedkubectl get gatewayclass
Is the Gateway programmed, what addresskubectl get gateway -A
Why notkubectl describe gateway web -n infra
Routes and hostnameskubectl get httproute -A
Route attachment statuskubectl get httproute my-app -n my-namespace -o jsonpath='{.status.parents}' | jq
Listener addresskubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}'
Backends behind a routekubectl get endpointslice -n my-namespace -l kubernetes.io/service-name=my-app
Test a host without DNScurl -H 'Host: app.example.com' http://<gateway-ip>/
Cross-namespace permissionskubectl 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.

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=FalseNo controller claims this controllerName, or its parameters are invalid
Gateway Accepted=FalseThe 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=TrueSome rules are invalid and dropped, the rest are served
All conditions true, still 503 or 500No ready endpoints behind the Service, or the backend is refusing connections
All conditions true, 404No 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#

ResourceOwned byPurpose
GatewayClassInfrastructure providerNames the controller implementation; cluster-scoped
GatewayPlatform teamListeners, ports, TLS certificates, which routes may attach
ListenerSetApplication or platform teamAdds listeners to an existing Gateway from another namespace (standard since v1.5)
HTTPRoute, GRPCRouteApplication teamL7 matching and forwarding, in the application’s namespace
TLSRoute, TCPRoute, UDPRouteApplication teamSNI or port-based forwarding (TLSRoute standard since v1.5, TCP and UDP since v1.6)
ReferenceGrantOwner of the namespace being referencedPermission for a cross-namespace reference
BackendTLSPolicyService ownerTLS 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#

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.

An HTTPRoute#

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.

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.

  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 typeLevelWhat it does
RequestHeaderModifierCoreset, add, remove request headers before forwarding
ResponseHeaderModifierCoreSame operations on the response
RequestRedirectCoreReturn a 301/302 with a new scheme, hostname, port or path; no backend is contacted
URLRewriteExtendedChange hostname or path (ReplaceFullPath, ReplacePrefixMatch) before forwarding
RequestMirrorExtendedCopy requests to another backend; percent or fraction limits the share
CORSExtendedPreflight handling and Access-Control-* headers at the gateway
ExtensionRefImplementation-specificReference 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.

  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:

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.

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:

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:

  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:

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 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:

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.

  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:

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 converts Ingress objects and common annotations to Gateway API resources as a starting point.

IngressGateway API
ingressClassNamegatewayClassName on the Gateway
spec.tlslisteners[].tls.certificateRefs
spec.rules[].hosthostnames on the Route
Path type Prefixpath.type: PathPrefix
nginx.ingress.kubernetes.io/rewrite-targetURLRewrite filter
nginx.ingress.kubernetes.io/ssl-redirectRequestRedirect route on the HTTP listener
Controller-specific canary annotationsbackendRefs[].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.

Troubleshooting Gateway API#

SymptomLikely causeCheck
Gateway has no addressController not running, or the load balancer is still provisioningkubectl describe gateway, controller logs, kubectl get svc -n <controller-namespace>
attachedRoutes: 0 on the listenerNamespace, hostname or sectionName mismatchRoute status.parents[].conditions, see the table above
TLS handshake fails or serves the wrong certificateListener ResolvedRefs=False, or SNI does not match hostnameopenssl s_client -connect <ip>:443 -servername app.example.com
Works by IP with Host header, fails by nameDNS points somewhere elsedig app.example.com, DNS
Intermittent 503Some backends failing readinessEndpointSlice membership over time
Wrong backend answersA more specific match on another route winsList every route on that hostname and compare matches
Route ignored after CRD upgradeCRD versions older than the implementation expectsBundle version annotation on the CRDs, implementation’s compatibility matrix
Accepted=False reason UnsupportedValueField such as timeouts, retry or a regex path not supported by this implementationkubectl describe httproute message text, implementation’s conformance report
Certificate in another namespace never loadsMissing ReferenceGrant from: Gateway in the Secret’s namespacekubectl get referencegrant -n <certs-ns> -o yaml, listener ResolvedRefs reason
Passthrough listener accepts but nothing connectsHTTPRoute attached to a TLS listener, or client sends no SNIallowedRoutes.kinds, openssl s_client -servername vs without
Canary weight changed but traffic did not moveController reconciled an older cached route, or endpoints for the new backend are not readystatus.parents[].conditions[].observedGeneration vs metadata.generation, EndpointSlice for the canary
gRPC calls fail with UNIMPLEMENTEDMethod match too narrow, or HTTPRoute on the same hostname won the conflictkubectl get grpcroute,httproute -A filtered by hostname
Redirect loop on HTTPSRequestRedirect rule attached to the HTTPS listener as well as HTTPparentRefs[].sectionName on the redirect route
Header filter has no effectFilter placed under a backendRefs[] entry, not the rule, or a policy on the Gateway overrides itkubectl get httproute -o yaml, implementation policy CRDs

For timing and status code analysis of the requests themselves, see HTTP.

Oneliners#

# 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.

#!/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.

#!/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, API specification.