gw-10 step 02 — Program the data plane, and clean up with finalizers
Goal
Make the operator a real control plane: reconcile the full set of routes into a data-plane config (the gw-08 xDS snapshot), and use a finalizer to deprogram cleanly on delete so no orphaned config or traffic-to-a-deleted-backend remains.
Code — the DataPlane programmer (bridges to gw-08)
package controller
import (
"context"
"sort"
netv1 "github.com/10xdev/gw10/api/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type DataPlane interface {
Program(ctx context.Context, er *netv1.EdgeRoute) error
}
// xdsProgrammer rebuilds the WHOLE snapshot from all EdgeRoutes and
// pushes it (gw-08 SnapshotCache). Rebuilding the full set — rather than
// mutating incrementally — keeps reconcile idempotent and correct.
type xdsProgrammer struct {
client.Client
cache SnapshotSink // your gw-08 cache.SetSnapshot wrapper
}
func (p *xdsProgrammer) Program(ctx context.Context, _ *netv1.EdgeRoute) error {
var list netv1.EdgeRouteList
if err := p.List(ctx, &list); err != nil {
return err
}
routes := make([]netv1.EdgeRoute, 0, len(list.Items))
for _, er := range list.Items {
if er.DeletionTimestamp.IsZero() { // exclude routes being deleted
routes = append(routes, er)
}
}
// Deterministic order -> stable version -> no spurious pushes (gw-08).
sort.Slice(routes, func(i, j int) bool { return routes[i].Name < routes[j].Name })
snap := buildSnapshotFromRoutes(routes) // -> listeners/routes/clusters/EDS
return p.cache.Set(ctx, "edge-envoy", snap)
}
Code — finalizer for clean teardown
const finalizer = "net.10xdev.io/deprogram"
func (r *EdgeRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var er netv1.EdgeRoute
if err := r.Get(ctx, req.NamespacedName, &er); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Handle deletion: run cleanup, THEN drop the finalizer to let it go.
if !er.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&er, finalizer) {
// Deprogram: rebuild the snapshot WITHOUT this route so the
// data plane stops sending it traffic before the object vanishes.
if err := r.Programmer.Program(ctx, &er); err != nil {
return ctrl.Result{Requeue: true}, nil // retry cleanup
}
controllerutil.RemoveFinalizer(&er, finalizer)
_ = r.Update(ctx, &er)
}
return ctrl.Result{}, nil
}
// Ensure the finalizer is present on live objects.
if !controllerutil.ContainsFinalizer(&er, finalizer) {
controllerutil.AddFinalizer(&er, finalizer)
_ = r.Update(ctx, &er)
}
// ... normal reconcile (program + status) from step 01 ...
return ctrl.Result{}, nil
}
Tasks
- Implement
xdsProgrammerreusing your gw-08SnapshotCache. Apply twoEdgeRoutes; confirm Envoy serves both (config came from CRDs → operator → xDS → Envoy: the full stack). - Add the finalizer.
kubectl deletean EdgeRoute; confirm the object stays inTerminatinguntil the operator deprograms it, then disappears — and Envoy stops routing that prefix before the object is gone (no orphaned config). - Break-glass: simulate the data plane being unreachable during
delete; show the object is stuck in
Terminating(finalizer can't complete), then remove the finalizer by hand and explain why that's the emergency escape hatch.
Acceptance
- End-to-end:
kubectl apply EdgeRoute→ Envoy serves the route;kubectl delete→ route deprogrammed before deletion completes. - Finalizer prevents orphaned data-plane config; you can articulate the stuck-finalizer hazard and its break-glass.
Discussion prompts
- Why rebuild the entire snapshot from all EdgeRoutes each reconcile instead of incrementally adding/removing one? (Idempotency, correctness after missed events, matches gw-08's content-hash versioning.)
- Why deprogram before the object is deleted (finalizer) rather than after? (Avoid a window where the object is gone but traffic still flows to a dead backend.)
- This operator + gw-08 is exactly Envoy Gateway / Contour in miniature. What does a production version add (Gateway API conformance, webhooks, multi-tenancy, leader election, metrics)?