gw-10 — The Hitchhiker's Guide to CRDs, Operators & the Gateway API
Companion to CONCEPTS.md, with the runnable operator in
src/go/operator/. It closes the loop with gw-08: an operator is a control plane whose source of truth is the Kubernetes API.
The JD wants "Operator patterns" and the "Kubernetes Gateway API." This
lab builds the operator pattern in its smallest correct form — an
in-memory apiserver, a level-triggered reconcile loop, finalizers, and
status — so you understand exactly what controller-runtime does for you.
Run bash scripts/verify.sh:
apply (service missing): Accepted=false(ServiceNotFound) Programmed=false dpPushes=0
service created (self-heal): Accepted=true(Valid) Programmed=true(Synced) dpPushes=1
reconcile x2 (idempotent): Accepted=true Programmed=true dpPushes=1
delete issued (Terminating): exists=true ...
reconcile (finalizer cleanup): exists=false dpPushes=2 dpRoutes=0
That lifecycle — self-heal, idempotent, finalizer cleanup — is the whole pattern.
1. Level-triggered reconciliation (reconcile.go)
The single most important operator concept: a controller reconciles to
desired state, it doesn't react to events. Reconcile(name) reads
the current state every call and converges — so missing an event, or
restarting the controller, never leaves the world permanently wrong.
TestLevelTriggeredSelfHealing proves it: apply an EdgeRoute whose target
Service doesn't exist → status Accepted=false/ServiceNotFound. Then
create the Service and reconcile again without touching the CR →
status flips to Accepted=true/Programmed=true on its own. The
controller re-derived the truth from current state. An edge-triggered
"on add do X" controller couldn't do this — it would be stuck on the
stale "service missing" it saw at creation. This is the thermostat (not
light-switch) mental model made concrete.
Idempotency
TestIdempotentReconcile reconciles identical state five times and
asserts the data plane was pushed once (dpPushes == 1). The
MemDataPlane versions config by content hash, so reprogramming the same
route set is a no-op — exactly the gw-08 content-hash debounce. Reconcile
runs on every watched change and on resync, so under churn it's called
constantly; it must be idempotent and fast or it backs up the work queue.
Rebuild the whole set, don't mutate incrementally
reprogram() rebuilds the data-plane config from all live EdgeRoutes
every time, rather than adding/removing one. That's what makes reconcile
idempotent and correct after missed events — and it's why
TestDeleteReprogramsRemaining shows that deleting r1 leaves the data
plane with exactly r2.
2. Status as the contract (reconcile.go)
A user applies a CR and walks away; status.conditions is how they (and
dashboards) learn whether it took effect. The crucial ordering:
Programmed=true is set only AFTER the data-plane push succeeds.
TestStatusProgrammedOnlyAfterSuccess forces the data plane to fail
(FailNext) and asserts status becomes Programmed=false/PushFailed —
the CR analog of an xDS NACK (gw-08). A controller that reports success
before the side effect actually happened is lying to its users; status
must reflect reality. Surface Programmed=false / generation-vs-observed
skew as a metric and alert on it (gw-11).
3. Finalizers: clean teardown (store.go, reconcile.go)
When you kubectl delete a CR, Kubernetes sets a deletionTimestamp but
keeps the object while any finalizers remain — giving the
controller a chance to clean up external state first. TestFinalizerCleanup
walks it: the live object carries a finalizer; Delete puts it in
Terminating (still Exists); the next Reconcile deprograms the
data plane (the route is already excluded from ListLive because it's
being deleted), then removes the finalizer, which lets the object
actually disappear.
Why deprogram before the object is gone? Otherwise there's a window where the CR is deleted but traffic still flows to a dead backend. Finalizers close that window. The maintainer hazard (CONCEPTS §6): a finalizer whose cleanup can never complete (data plane permanently unreachable) blocks deletion forever — so bound the cleanup and keep a break-glass to remove a stuck finalizer.
4. The controller / work queue (controller.go)
Controller drains a work queue, calling Reconcile per key
(TestControllerProcessesQueue). A real controller-runtime controller
adds caching informers (watch once, serve many cached reads), a
rate-limited requeue (retry with backoff), and leader election
(one active replica, HA without double-writes). The level-triggered core
— enqueue on change, reconcile to desired state — is exactly what you
built. When you scaffold with kubebuilder, you're filling in this
Reconcile method; everything else is the framework.
5. The Gateway API and how this becomes Envoy Gateway
The CONCEPTS file covers the Gateway API — the standard, role-
separated routing CRDs (GatewayClass/Gateway/HTTPRoute) that replace
Ingress. An HTTPRoute is the declarative form of gw-03's route table;
this operator's EdgeRoute is a stand-in for it. Swap the MemDataPlane
for gw-08's xDS SnapshotCache and point the Source at the Kubernetes
API (Gateway-API CRDs + EndpointSlices, gw-09), and you have Envoy
Gateway / Contour in miniature: watch CRDs → reconcile into xDS → push
to Envoy → write status back. gw-08 + gw-10 together are a complete,
K8s-native gateway control plane.
6. Hands-on
cd src/go
bash ../scripts/verify.sh # tests + the lifecycle demo
# Then build the real thing with kubebuilder (steps/01): define the CRD,
# implement this exact Reconcile against a real apiserver, and watch
# `kubectl get edgeroute -o yaml` show the status conditions you set.
7. Exercises
- Wire to gw-08: implement
DataPlaneover gw-08'sSnapshotCacheso the operator reconciles CRs into xDS and an Envoy serves them. - Watch the referenced Service: enqueue the EdgeRoute when its target Service's endpoints change (gw-09), so endpoint churn re-triggers reconcile — then your data plane always has current endpoints.
- CRD versioning as migration: add a
v2spec field, write a conversion fromv1, and keep both clients working — a migration at the API level (gw-12). - Leader election: run two controllers and add a simple lease so only one reconciles; prove no double-writes.
- Real kubebuilder operator (steps/01): scaffold, define the CRD,
port this
Reconcile, deploy tokind, and confirm self-heal + finalizer cleanup on a live cluster.