Skip to content

Kubernetes operators that read like prose.A thin DSL over controller-runtime

Describe what a reconcile does as a sequence of named steps. prose handles the boilerplate and gives you tracing, wide-event logging, metrics, and Kubernetes events for free.

Why prose?

A reconcile is a single observable transaction. Every other decision falls out of that one sentence.

Raw controller-runtime vs. prose

Same reconcile, minus the noise that buries the intent.

Raw controller-runtime

The interesting logic, buried in plumbing

  • Boilerplate prologue
    Every reconciler opens with the same Get + IgnoreNotFound and pause/finalizer gating
  • Scattered observability
    Log lines, span annotations, and metric increments interleaved through the logic
  • Implicit control flow
    Requeue smuggled into error types; early returns that skip half the logic
  • Blind spots
    Reconcile-level metrics only; a slow or flapping step is invisible
  • Repeated wiring
    Owns / Watches / predicates re-assembled by hand in every SetupWithManager

prose

The intent, on the page

  • Framework-owned prologue
    The Get, the requeue plumbing, and the setup wiring are handled for you
  • One wide event
    Every field, fed once, emitted unmissably at the transaction boundary
  • Explicit outcomes
    Continue / Requeue / RequeueAfter / Done; backoff is a result, not an error
  • Per-step telemetry
    A span, a duration, and a metric for every step, for free
  • One sentence per step
    The builder chain is the controller: what it watches, the order of work, where telemetry goes

One sentence per thing the operator does

There is no Reconcile method to write and no SetupWithManager to wire up. You describe the reconcile as a linear, observable sequence of named steps, and prose handles the boilerplate around them.

prose.For[*v1alpha1.Foo](mgr).
    Owns(&appsv1.Deployment{}).
    WithObservability(
        prose.Otel(tracer),
        prose.WideEvents(logger),
        prose.Recorder(mgr.GetEventRecorderFor("foo")),
    ).
    When("paused", isPaused).Skip().
    Describe("dependencies", func(g *prose.Group[*v1alpha1.Foo]) {
        g.Step("configmap", upsertConfigMap)
        g.Step("deployment", upsertDeployment)
    }).
    Step("status", syncStatus).
    Complete()

A step holds only business logic. It contributes fields with rctx.Set and returns an outcome; it never logs, traces, or counts.

func upsertDeployment(rctx *prose.Context[*v1alpha1.Foo]) (prose.Outcome, error) {
    foo := rctx.Object() // already fetched, typed, no Get, no cast

    desired := buildDeployment(foo)
    rctx.Set("deployment.image", desired.Spec.Template.Spec.Containers[0].Image)

    if err := rctx.Apply(desired); err != nil {
        return prose.Requeue, humane.Wrap(err, "apply deployment",
            "check that the controller has RBAC to create Deployments in this namespace")
    }
    return prose.Continue, nil
}

When the reconcile returns, the framework emits exactly one structured record describing everything that happened, flattened into dotted keys that mirror your group nesting:

one wide event per reconcile
controller=foo namespace=team-a name=widget generation=7 result=requeue requeue_after=30s duration=412ms
  dependencies.configmap.duration=8ms  dependencies.configmap.outcome=continue
  dependencies.deployment.duration=190ms dependencies.deployment.outcome=continue dependencies.deployment.image=ghcr.io/...:v2
  status.duration=14ms status.outcome=requeue

One reconcile, one queryable row. The same rctx.Set call also lands on the OpenTelemetry span, so you write the field once and it shows up in both your logs and your traces.

Ready to build one?

The five-minute guide gets you from prose.For to a running reconciler. If you want the why first, start with the mental model.

Early and moving

prose is early and the API surface is still settling. The concepts are stable; signatures may change before a tagged release. Signatures live on pkg.go.dev; these docs are the narrative around them.