core  /  controllers pkg/controller
Chapter 03

Controllers

A controller is a single thread of execution that turns inputs it reads into outputs it writes, re-running whenever its inputs change. It's the only place your business logic lives. COSI offers two flavours — a whole-world reconcile controller and a per-item queue controller — plus generic, ready-made controllers that cover most needs without writing a loop at all.

#The controller contract

The interface is four methods. You declare your name, your dependencies, and a Run that loops until its context is cancelled.

pkg/controller/controller.gogo
type Controller interface {
    Name() string          // unique; also the owner stamped on outputs
    Inputs() []Input       // resources it may read (dynamic — can change at runtime)
    Outputs() []Output     // resources it may write (static — fixed at registration)
    Run(context.Context, Runtime, *zap.Logger) error
}

The Runtime passed to Run is the controller's view of the world: a reader, a writer (scoped to its outputs), an EventCh() that fires on input changes, and QueueReconcile() to ask for a re-run. It is not the global engine — it's a guarded facade.

#Inputs & outputs — the dependency declaration

This declaration is the heart of COSI's "graph as documentation" principle. The kinds carry real semantics that the engine enforces:

KindDirectionMeaning
InputWeakreadWatch & read; re-run on change. No teardown obligations.
InputStrongreadWeak + the input cannot be destroyed until this controller removes its finalizer. Use when you create outputs derived from it.
InputDestroyReadyreadWatch your own outputs for "tearing down with no finalizers left" — i.e. safe to destroy.
OutputExclusivewriteOnly this controller may write this type. Runtime refuses two exclusive claims.
OutputSharedwriteMultiple controllers write the type, but each instance is owned by its creator.
Outputs are static, inputs are dynamic.

You fix your outputs at registration so the engine can build the conflict map up-front. But Runtime.UpdateInputs() lets a controller change what it watches mid-run — e.g. start watching the specific network a VM references once you've read the VM. The dependency graph is live.

#The reconcile loop

A classic (non-queue) controller is a for loop over an event channel. The engine coalesces bursts of input changes into a single wakeup, so you always reconcile against the latest state rather than replaying every intermediate change.

wait on EventCh() input changed → wake read inputs safe.ReaderList / Get compute desired your logic write outputs Modify · cleanup stale single goroutine
One controller, one goroutine, one direction. On error the engine restarts the loop with exponential backoff.

The skeleton, conceptually:

reconcile skeletongo
func (ctrl *MyController) Run(ctx context.Context, r controller.Runtime, log *zap.Logger) error {
    for {
        select {
        case <-ctx.Done():
            return nil
        case <-r.EventCh():   // an input changed (coalesced)
        }

        // 1. read the current world
        items, err := safe.ReaderListAll[*vm.VM](ctx, r)
        if err != nil { return err }

        // 2. write desired outputs (Modify = create-or-update, conflict-safe)
        for item := range items.All() {
            if err := safe.WriterModify(ctx, r, network.NewNetwork(item.Metadata().ID()),
                func(n *network.Network) error {
                    n.TypedSpec().VMRef = item.Metadata().ID()
                    return nil
                }); err != nil { return err }
        }

        // 3. (optional) destroy outputs whose inputs disappeared
        r.ResetRestartBackoff() // we made it through cleanly
    }
}

#Controller vs. QController

The classic controller reconciles the whole world on every wakeup. That's simple and correct, but when you have thousands of independent items, or per-item failures that shouldn't block everything else, you want the QController: a work queue where each item is reconciled independently, with per-item backoff and configurable concurrency.

Controller whole-world reconcile • any input change → 1 wakeup • lists & processes ALL inputs • failure → retry everything • single goroutine, serialized • can be driven by external events simple · few or coupled items QController per-item queue • change → enqueue that item • Reconcile(ptr) one at a time • failure → requeue THAT item • N workers, concurrent • MapInput: secondary → primary scalable · many independent items
Same data model, two execution strategies. Most real controllers in Talos/Omni are QControllers.
pkg/controller/qcontroller.gogo
type QController interface {
    Name() string
    Settings() QSettings              // inputs, outputs, concurrency, run/shutdown hooks
    // Reconcile ONE item. Return nil → done; error → requeue with backoff;
    // RequeueError → requeue after a chosen interval.
    Reconcile(context.Context, *zap.Logger, QRuntime, resource.Pointer) error
    // Map a change in a secondary input to the primary item(s) it affects.
    MapInput(context.Context, *zap.Logger, QRuntime, ReducedResourceMetadata) ([]resource.Pointer, error)
}

Key QController mechanics:

  • Per-item backoff. Each queue key has its own exponential backoff, so one poison item retrying doesn't slow healthy ones.
  • Concurrency. QSettings.Concurrency spawns N workers pulling from the queue; the queue de-duplicates so an item in flight won't be processed twice.
  • RequeueError. Return one to say "come back in 5s" without it counting as a crash — perfect for polling an external system that isn't ready yet.
  • MapInput. Translates a change in one resource type into the primary keys it affects (e.g. "this Host changed → re-reconcile every VM on it").

#Type-safe state access: the safe package

The raw Reader/Writer deal in resource.Resource (interface) values. The safe package is a thin generic layer that gives you concrete typed values and iterators with zero casts — you'll use it everywhere inside controllers.

pkg/safego
vm, err  := safe.ReaderGetByID[*vm.VM](ctx, r, "web-1")      // typed Get
list, err := safe.ReaderListAll[*vm.VM](ctx, r)              // typed List

for v := range list.All() {        // range-over-func iterator, v is *vm.VM
    println(v.TypedSpec().CPUs)    // no type assertion
}

err = safe.WriterModify(ctx, r, network.NewNetwork("web-1"), // typed create-or-update
    func(n *network.Network) error { n.TypedSpec().Ready = true; return nil })

#Generic controllers — don't write a loop

Most controllers follow the same shape: "for each input resource, produce one output resource." COSI ships generic controllers that implement that shape for you. You provide a couple of functions; they handle the loop, finalizers, and cleanup.

🔁

transform.Controller

1:1 mapping, synchronous. Give it a MapMetadataFunc (input → output identity) and a TransformFunc (fill the output spec from the input). It creates, updates, and garbage-collects outputs as inputs come and go. Optional input finalizers.

qtransform.QController

Same idea, queue-based and concurrent. Adds an UnmapMetadataFunc (output → input) so output changes can find their source, and always manages finalizers. The workhorse for large fleets.

🧹

cleanup.Controller

Manages finalizers without producing outputs. Built-in handlers: HasNoOutputs (block teardown until dependents are gone) and RemoveOutputs (actively destroy dependents on teardown).

💥

destroy.Controller

A safety valve: destroys any tearing-down resource that has no owner and no finalizers. Mops up orphans left by a crashed controller.

#A complete transform controller

Here's the entire definition of a controller that turns each VM into a derived NetworkConfig — finalizer management and cleanup included — in about 20 lines, because transform does the loop:

vmnet/controller.gogo
func NewVMNetController() *transform.Controller[*vm.VM, *netcfg.NetworkConfig] {
    return transform.NewController(
        transform.Settings[*vm.VM, *netcfg.NetworkConfig]{
            Name: "vmnet.NetworkConfigController",

            // input identity → output identity (1:1)
            MapMetadataFunc: func(in *vm.VM) *netcfg.NetworkConfig {
                return netcfg.NewNetworkConfig(in.Metadata().ID())
            },

            // fill the output spec from the input spec
            TransformFunc: func(_ context.Context, _ controller.Reader, _ *zap.Logger,
                in *vm.VM, out *netcfg.NetworkConfig) error {
                out.TypedSpec().Hostname = in.Metadata().ID()
                out.TypedSpec().Bridge   = "br-" + in.TypedSpec().Image
                return nil
            },

            // called when a VM tears down — gate output cleanup on real work
            FinalizerRemovalFunc: func(ctx context.Context, _ controller.Reader, _ *zap.Logger,
                in *vm.VM) error {
                return detachBridge(ctx, in.Metadata().ID()) // your side-effect
            },
        },
        transform.WithInputFinalizers(), // keep the VM alive until net is cleaned up
    )
}

Register it with rt.RegisterController(NewVMNetController()) and you have a fully reconciling, finalizer-correct controller. Create a VM → a NetworkConfig appears. Teardown the VM → detachBridge runs, the finalizer drops, both are destroyed in order.

Mental checkpoint.

A controller declares inputs (dynamic, watched) and outputs (static, owned), then reconciles desired state in a single goroutine. Choose Controller for simple/coupled work, QController for large independent fleets. Reach for transform/qtransform before writing a loop. Next: how the engine actually wires all these together and decides who wakes up.