Every platform team has a script that someone runs by hand every Tuesday. Ours checked Prometheus during rollouts and pasted the graph into Slack. This is the story of turning it into a controller, and the three things the tutorials skipped.
Start from the resource, not the code
The temptation is to open the SDK scaffold and start typing. Resist it. The first hour should be spent on a YAML file that does not exist yet — the resource a user would write if the feature already worked:
apiVersion: sentinel.example.com/v1alpha1kind: RolloutAnalysismetadata: name: checkoutspec: target: kind: Deployment name: checkout metrics: - name: p99-latency query: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{app="checkout"}[5m])) maxValue: 0.25 interval: 30s failureLimit: 3If you cannot write this file, you do not know what you are building. Once you can, the controller almost writes itself: watch these, query that, compare, act.
The reconcile loop is the whole program
Everything an operator does lives in one function that gets called with a name and returns “done” or “try again later”. The scaffold hides that behind a lot of generated code; the part you own is this:
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var analysis sentinelv1alpha1.RolloutAnalysis if err := r.Get(ctx, req.NamespacedName, &analysis); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) }
verdict, err := r.evaluate(ctx, analysis.Spec.Metrics) if err != nil { return ctrl.Result{RequeueAfter: analysis.Spec.Interval.Duration}, err }
analysis.Status.LastVerdict = verdict analysis.Status.Failures = countFailures(analysis.Status, verdict) if analysis.Status.Failures >= analysis.Spec.FailureLimit { if err := r.pauseRollout(ctx, analysis.Spec.Target); err != nil { return ctrl.Result{}, err } r.Recorder.Event(&analysis, "Warning", "RolloutPaused", "metric regression detected") }
if err := r.Status().Update(ctx, &analysis); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: analysis.Spec.Interval.Duration}, nil}The highlighted block is the only part that does anything. Everything else is fetching state and writing it back. That ratio is normal; if your reconciler is mostly action, it is probably not idempotent.
Three things the tutorials skipped
1. Status is an API, not a log
Early versions wrote a human-readable message into status. Users then wrote scripts that grep’d that message, and the first wording change broke them. Status needs the same discipline as Spec: typed fields, conditions with stable reasons, and a promise not to change them casually.
2. Requeue is not a retry
RequeueAfter says “look at this again in 30 s whether or not anything changed”. Returning an error says “something went wrong, back off exponentially”. Mixing them up gives you either a controller that hammers Prometheus during an outage or one that never notices the outage ended.
3. Test with envtest, then with a real cluster, then with a bad one
envtest spins up a real API server without nodes and catches every schema and RBAC mistake in seconds. What it does not catch is the behaviour when Prometheus returns a 502, when the target deployment is deleted mid-analysis, or when two analyses point at one deployment. Those need a kind cluster and deliberate sabotage:
kubectl -n monitoring scale deploy/prometheus --replicas=0kubectl get rolloutanalysis checkout -wWatching the status conditions flip to MetricsUnavailable and back — without pausing anything — was the moment the thing felt finished.
Was it a weekend?
The first working version, yes. The version that could be trusted with production rollouts took another three weekends and one incident. That version became Rollout Sentinel, and the Tuesday script has been deleted.