Lesson 32 of 66 · 15 min read · Updated

Gateway API Resource Model — Roles, Listeners, allowedRoutes and Status Conditions

In the previous article you installed Envoy Gateway and sent traffic to kiosk-api through a GatewayClass, a Gateway, and an HTTPRoute. Everything worked on the first try, which is nice, but it also means you never saw the model push back.

In real clusters, routes get rejected. A team attaches a route to a listener that does not want it, a hostname does not line up, a Service name has a typo. Ingress would quietly ignore all of that. Gateway API refuses, and tells you why, through status conditions. To read those messages you need to understand the rules the controller is applying.

This article takes the resource model apart piece by piece, then breaks it on purpose so you can see every common failure and fix it.

We will cover the following topics:

  1. The roles behind GatewayClass, Gateway, and routes, and how RBAC enforces them
  2. GatewayClass in detail: controllerName and parametersRef
  3. Listeners: name, port, protocol, hostname, and what makes two listeners conflict
  4. allowedRoutes: which namespaces and which route kinds may attach to a listener
  5. parentRefs and sectionName: how a route chooses where to attach
  6. Hostname intersection: how listener and route hostnames combine
  7. Status conditions on Gateways, listeners, and routes, and the reasons you will see most often

Technical Requirements

  1. The minikube cluster from the previous article, with Envoy Gateway v1.9.2 and the eg GatewayClass installed.
  2. The kiosk-api Deployment and Service in the storefront namespace.
  3. kubectl and curl.

Core Concepts

Roles and Ownership

Gateway API was designed around three personas. Each one edits a different kind, which is what makes the split enforceable.

RoleEditsTypical RBAC
Infrastructure providerGatewayClass, implementation config such as EnvoyProxyCluster-admin only
Cluster operatorGateway, TLS Secrets, namespace labelsPlatform team, in an infrastructure namespace
Application developerHTTPRoute, GRPCRoute, their own ServicesEach team in its own namespace

The key idea is that a route asks to attach to a Gateway, and the Gateway decides whether to accept it. An application team can write any route it likes, but it cannot make the shared Gateway serve a hostname or a namespace the platform team did not allow. That decision is encoded in the Gateway's listeners.

GatewayClass: controllerName and parametersRef

A GatewayClass has two important fields.

controllerName is a string in domain/path form. Each implementation watches for its own value. Envoy Gateway watches for gateway.envoyproxy.io/gatewayclass-controller. A GatewayClass with a value no controller recognises is simply never accepted. The field is immutable after creation.

parametersRef points to implementation-specific configuration. With Envoy Gateway it points to an EnvoyProxy resource, which controls how the data plane is deployed: replica count, resources, the Service type, and much more.

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg-ha
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: ha-proxy-config
namespace: envoy-gateway-system
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: ha-proxy-config
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyDeployment:
replicas: 2

Every Gateway of class eg-ha now gets two Envoy replicas. This is how a platform offers several "flavours" of Gateway: one class per flavour, each with its own parameters. A single Gateway can also override the class with spec.infrastructure.parametersRef, which is useful when only one Gateway needs different settings.

Listeners

A Gateway is a list of listeners. Each listener is a logical endpoint defined by four fields:

FieldRequiredMeaning
nameYesUnique within the Gateway. Routes use it as sectionName.
portYesThe port the proxy listens on.
protocolYesHTTP, HTTPS, TLS, TCP, or UDP. It decides which route kinds make sense.
hostnameNoRestricts the listener to one hostname or a wildcard such as *.storefront.local. Empty means any hostname.

Several listeners may share a port. That is how one Gateway serves many hostnames on port 80. The rule is that listeners on the same port and protocol must be distinguishable by hostname. Two HTTP listeners on port 80 with the same hostname, or both without a hostname, are in conflict. The controller marks them with a Conflicted condition and does not program them.

With Envoy Gateway, every listener port also appears on the Envoy Service. A Gateway with listeners on ports 80 and 8080 gets a Service exposing both.

allowedRoutes

Each listener carries an allowedRoutes block that answers two questions: from which namespaces may routes attach, and which kinds of route.

allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
storefront.local/gateway-access: "true"
kinds:
- kind: HTTPRoute

namespaces.from takes one of three values:

ValueWho may attach
SameOnly routes in the Gateway's own namespace. This is the default.
AllRoutes in any namespace.
SelectorRoutes in namespaces whose labels match selector.

The default of Same is deliberately conservative. A shared Gateway in an infrastructure namespace will reject every application route until the platform team opens it up. Selector is usually the right production choice, because the platform team controls namespace labels and can grant access one namespace at a time.

kinds restricts the route types. If you omit it, the listener accepts the kinds that fit its protocol, such as HTTPRoute (and GRPCRoute where supported) for an HTTP listener.

parentRefs and sectionName

A route names the Gateways it wants to attach to in spec.parentRefs. Each entry has these fields:

FieldDefaultMeaning
nameRequiredThe Gateway's name.
namespaceThe route's namespaceNeeded when the Gateway lives elsewhere.
sectionNameEmptyA listener name. Attach to that listener only.
portEmptyAttach to the listeners on that port.

Without sectionName the route tries to attach to every listener of the Gateway, and succeeds on the ones that allow it. With sectionName it targets exactly one listener, which makes intent explicit and failures easier to read. A route can list several parents, and its status reports a separate result for each one.

Hostname Intersection

Both the listener and the route can carry hostnames. The route is served for the intersection of the two. If the intersection is empty, the route does not attach to that listener.

Listener hostnameRoute hostnamesResult
emptystorefront.localAttaches. Serves storefront.local.
storefront.localemptyAttaches. Inherits storefront.local.
storefront.localstorefront.localAttaches.
*.storefront.localkiosk.storefront.localAttaches. Serves kiosk.storefront.local.
*.storefront.localstorefront.localDoes not attach. A wildcard needs at least one extra label.
*.storefront.localkiosk.storefront.local, kiosk.example.comAttaches. Serves only kiosk.storefront.local.
storefront.localkiosk.example.comDoes not attach. Reason: NoMatchingListenerHostname.

A wildcard such as *.storefront.local matches any number of labels in front, so a.b.storefront.local matches too. It never matches the bare storefront.local.

Status Conditions

Status lives in three places. Learn where to look and most problems become one command away.

Gateway conditions in status.conditions:

ConditionTrue meansCommon False reasons
AcceptedThe spec is valid and a controller owns itInvalid, UnsupportedAddress, or Pending while no controller has claimed it
ProgrammedThe data plane is readyInvalid, Pending, AddressNotAssigned

Listener status in status.listeners[], one entry per listener, each with an attachedRoutes count:

ConditionMeaning
AcceptedThe listener's configuration is supported.
ProgrammedThe listener is live in the proxy.
ResolvedRefsReferenced objects such as TLS certificates exist and are allowed.
ConflictedTrue is bad here. The listener clashes with another one, for example HostnameConflict.

Route status in status.parents[], one entry per parentRef:

ConditionReasonCause
Accepted=FalseNotAllowedByListenersThe listener's allowedRoutes excludes this namespace or kind.
Accepted=FalseNoMatchingListenerHostnameNo listener hostname intersects the route hostnames.
Accepted=FalseNoMatchingParentThe sectionName or port does not exist on that Gateway.
ResolvedRefs=FalseBackendNotFoundA backendRef points to a Service that does not exist.
ResolvedRefs=FalseRefNotPermittedA backendRef points into another namespace without a ReferenceGrant.
ResolvedRefs=FalseInvalidKindA backendRef points to a kind the implementation does not support.

Notice the difference between the two route conditions. Accepted=False means the route is not attached at all, so its hostnames return 404. ResolvedRefs=False means the route is attached but some backend is broken. Requests matching a rule with an invalid backend receive an HTTP 500 from the Gateway.

Hands-On: Kubernetes Commands

Show every condition of a Gateway

Type, status, and reason on one line each.

kubectl get gateway storefront-gateway -n storefront -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'

Show how many routes each listener has

attachedRoutes is the fastest check that a route really landed.

kubectl get gateway storefront-gateway -n storefront -o jsonpath='{range .status.listeners[*]}{.name}: attachedRoutes={.attachedRoutes}{"\n"}{end}'

Show a route's result for each parent

Prints the Gateway, listener, and every condition with its reason.

kubectl get httproute kiosk-api -n storefront -o jsonpath='{range .status.parents[*]}{.parentRef.name}/{.parentRef.sectionName}{"\n"}{range .conditions[*]} {.type}={.status} ({.reason}): {.message}{"\n"}{end}{end}'

Describe a route to read status and events together

Slower to read but includes the human-readable messages.

kubectl describe httproute kiosk-api -n storefront

Label a namespace to let it attach

Used with allowedRoutes.namespaces.from: Selector.

kubectl label namespace reporting storefront.local/gateway-access=true

List all routes in the cluster with their hostnames

A quick overview of who is attached where.

kubectl get httproute -A

Read the explanation of any field from the API

The CRDs carry their documentation.

kubectl explain gateway.spec.listeners.allowedRoutes

Step-by-Step Example

The Scenario

The storefront platform team wants one Gateway with two entry points. Port 80 serves the public storefront.local site, and only the storefront namespace may publish there. Port 8080 serves internal tools under *.internal.storefront.local, and any namespace the platform team labels may publish there. A new team runs a reports-api in the reporting namespace and wants to use the internal entry point.

Along the way you will make the most common mistakes on purpose and fix each one from its status message.

Step 1 — A Gateway with Two Listeners

Save as 1-storefront-gateway.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: storefront-gateway
namespace: storefront
spec:
gatewayClassName: eg
listeners:
- name: public
protocol: HTTP
port: 80
hostname: storefront.local
allowedRoutes:
namespaces:
from: Same # only routes in storefront
- name: internal
protocol: HTTP
port: 8080
hostname: "*.internal.storefront.local"
allowedRoutes:
namespaces:
from: Selector # only labelled namespaces
selector:
matchLabels:
storefront.local/gateway-access: "true"
kinds:
- kind: HTTPRoute
kubectl apply -f 1-storefront-gateway.yaml
kubectl get gateway storefront-gateway -n storefront -o jsonpath='{range .status.listeners[*]}{.name}: attachedRoutes={.attachedRoutes}{"\n"}{end}'
public: attachedRoutes=0
internal: attachedRoutes=0

Both listeners exist and nothing is attached yet. Note the quotes around the wildcard hostname. YAML would otherwise treat * as the start of an alias.

Step 2 — Break It: A Hostname That Does Not Intersect

Write the kiosk-api route with a hostname the Gateway does not serve. Save as 2-kiosk-api-route.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kiosk-api
namespace: storefront
spec:
parentRefs:
- name: storefront-gateway
sectionName: public
hostnames:
- kiosk.example.com
rules:
- backendRefs:
- name: kiosk-api
port: 80

The rule has no matches, which means it matches every path.

kubectl apply -f 2-kiosk-api-route.yaml
kubectl get httproute kiosk-api -n storefront -o jsonpath='{range .status.parents[*]}{.parentRef.name}/{.parentRef.sectionName}{"\n"}{range .conditions[*]} {.type}={.status} ({.reason}){"\n"}{end}{end}'
storefront-gateway/public
Accepted=False (NoMatchingListenerHostname)
ResolvedRefs=True (ResolvedRefs)

The backend is fine, but the route is not attached. The public listener only serves storefront.local, and kiosk.example.com does not intersect with it. With Ingress, the equivalent mistake produced no error at all.

Step 3 — Fix It

Change the hostname to storefront.local in the file, or simply remove the hostnames field so the route inherits the listener's hostname. Then apply again:

kubectl apply -f 2-kiosk-api-route.yaml
kubectl get httproute kiosk-api -n storefront -o jsonpath='{range .status.parents[*]}{range .conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}{end}'
Accepted=True (Accepted)
ResolvedRefs=True (ResolvedRefs)

Start a port-forward that covers both listener ports, and test:

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system --selector=gateway.envoyproxy.io/owning-gateway-namespace=storefront,gateway.envoyproxy.io/owning-gateway-name=storefront-gateway -o jsonpath='{.items[0].metadata.name}')
kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 8888:80 9999:8080 &
curl -s -H "Host: storefront.local" http://localhost:8888/
{"servedByPod":"kiosk-api-6b9d7f5c9-wz4tn","podIp":"10.244.0.16","node":"minikube","path":"/"}

On Docker Desktop's built-in kind cluster the port-forward is optional: the Envoy LoadBalancer Service is published on localhost for every listener port, so http://localhost/ and http://localhost:8080/ reach the two listeners directly. Keep using the port-forward if those ports are already taken on your machine.

Step 4 — Break It: A Backend That Does Not Exist

Edit the route and misspell the Service name as kiosk-apii, then apply and check:

kubectl apply -f 2-kiosk-api-route.yaml
kubectl get httproute kiosk-api -n storefront -o jsonpath='{range .status.parents[*]}{range .conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}{end}'
Accepted=True (Accepted)
ResolvedRefs=False (BackendNotFound)

This time the route is attached. Only the backend is broken. Look at what a client gets:

curl -s -o /dev/null -w "%{http_code}\n" -H "Host: storefront.local" http://localhost:8888/
500

A 500 instead of a 404 is a strong hint. It means a route matched, but its backend could not be resolved. Fix the name back to kiosk-api, apply, and the request succeeds again.

Step 5 — Deploy the reports-api in Another Namespace

The reporting team runs the same image under a different name. Save as 3-reports-api.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: reporting
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: reports-api
namespace: reporting
spec:
replicas: 2
selector:
matchLabels:
app: reports-api
template:
metadata:
labels:
app: reports-api
spec:
containers:
- name: reports-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: v1
kind: Service
metadata:
name: reports-api
namespace: reporting
spec:
selector:
app: reports-api
ports:
- name: http
port: 80
targetPort: http
kubectl apply -f 3-reports-api.yaml
kubectl rollout status deployment reports-api -n reporting

Step 6 — Break It: Attaching to a Listener That Does Not Allow You

The reporting team first tries the public listener. Save as 4-reports-api-route.yaml:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: reports-api
namespace: reporting
spec:
parentRefs:
- name: storefront-gateway
namespace: storefront # the Gateway lives in another namespace
sectionName: public
rules:
- backendRefs:
- name: reports-api
port: 80
kubectl apply -f 4-reports-api-route.yaml
kubectl get httproute reports-api -n reporting -o jsonpath='{range .status.parents[*]}{.parentRef.name}/{.parentRef.sectionName}{"\n"}{range .conditions[*]} {.type}={.status} ({.reason}){"\n"}{end}{end}'
storefront-gateway/public
Accepted=False (NotAllowedByListeners)
ResolvedRefs=True (ResolvedRefs)

The public listener only allows routes from its own namespace. The reporting team can write the route, but it cannot force its way onto the public site. This is the role split working as intended.

Step 7 — Break It Again: Right Listener, Unlabelled Namespace

Change sectionName to internal and add a hostname under the internal wildcard:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: reports-api
namespace: reporting
spec:
parentRefs:
- name: storefront-gateway
namespace: storefront
sectionName: internal
hostnames:
- reports.internal.storefront.local
rules:
- backendRefs:
- name: reports-api
port: 80
kubectl apply -f 4-reports-api-route.yaml
kubectl get httproute reports-api -n reporting -o jsonpath='{range .status.parents[*]}{range .conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}{end}'
Accepted=False (NotAllowedByListeners)
ResolvedRefs=True (ResolvedRefs)

Same reason, different cause. The internal listener uses a namespace selector, and reporting does not carry the label yet.

Step 8 — Fix It: The Platform Team Grants Access

Labelling the namespace is the platform team's decision, not the application team's. In a real cluster only the platform team would have RBAC permission to do it.

kubectl label namespace reporting storefront.local/gateway-access=true
kubectl get httproute reports-api -n reporting -o jsonpath='{range .status.parents[*]}{range .conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}{end}'
Accepted=True (Accepted)
ResolvedRefs=True (ResolvedRefs)

The controller re-evaluated the route as soon as the label appeared. No change to the route was needed. Test it through port 8080:

curl -s -H "Host: reports.internal.storefront.local" http://localhost:9999/
{"servedByPod":"reports-api-694f5d7855-xtg9d","podIp":"unknown","node":"unknown","path":"/"}

The podIp and node fields say unknown only because this Deployment passes POD_NAME but not POD_IP or NODE_NAME. If the status still shows NotAllowedByListeners right after labelling, wait a second or two and run the command again; the controller reconciles asynchronously.

Step 9 — Prove the Listeners Are Isolated

The public port must not serve the internal hostname, and the internal port must not serve the public one:

curl -s -o /dev/null -w "%{http_code}\n" -H "Host: reports.internal.storefront.local" http://localhost:8888/
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: storefront.local" http://localhost:9999/
404
404

Each listener only knows the routes attached to it.

Step 10 — Read the Whole Picture from the Gateway

kubectl get gateway storefront-gateway -n storefront -o jsonpath='{range .status.listeners[*]}{.name}: attachedRoutes={.attachedRoutes}{"\n"}{end}'
public: attachedRoutes=1
internal: attachedRoutes=1

One route on each listener, exactly as designed. When something does not work, start here, then move to the route's status.parents.

Step 11 — Break the Gateway Itself

Finally, see what happens when nobody owns a Gateway. Create one that references a GatewayClass that does not exist:

cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: orphan-gateway
namespace: storefront
spec:
gatewayClassName: does-not-exist
listeners:
- name: http
protocol: HTTP
port: 80
EOF
kubectl get gateway orphan-gateway -n storefront -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
Accepted=Unknown (Pending)
Programmed=Unknown (Pending)

Unknown with reason Pending is the default status the API server writes. No controller has ever looked at this Gateway, so nothing was provisioned. If you see this on a Gateway you expected to work, check the class name and whether its GatewayClass is Accepted.

Step 12 — Clean Up

Stop the port-forward and remove what this article created. Keep the eg GatewayClass, Envoy Gateway, and kiosk-api for the rest of the series.

kill %1
kubectl delete gateway orphan-gateway storefront-gateway -n storefront
kubectl delete httproute kiosk-api -n storefront
kubectl delete namespace reporting

Summary

  1. Gateway API splits ownership: GatewayClass for the infrastructure provider, Gateway for the cluster operator, routes for application teams. A route asks to attach, and the Gateway decides.
  2. A GatewayClass is claimed by the controller whose controllerName matches. parametersRef points to implementation config such as Envoy Gateway's EnvoyProxy.
  3. A Gateway is a list of listeners, each with a name, port, protocol, and optional hostname. Listeners on the same port must differ by hostname, or they become Conflicted.
  4. allowedRoutes limits which namespaces (Same by default, All, or Selector) and which route kinds may attach.
  5. Routes target Gateways with parentRefs, and a specific listener with sectionName.
  6. A route serves the intersection of its hostnames and the listener's. No intersection means NoMatchingListenerHostname.
  7. Accepted=False means the route is not attached and its traffic gets 404. ResolvedRefs=False means it is attached but a backend is broken, and matching requests get 500.
  8. Check status.listeners[].attachedRoutes on the Gateway first, then status.parents[] on the route.

In the next articles of this series you will build on this model: serving HTTPS from the Gateway, writing richer route rules, and splitting traffic between versions of a .NET API.