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.
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:
| Kind | Direction | Meaning |
|---|---|---|
| InputWeak | read | Watch & read; re-run on change. No teardown obligations. |
| InputStrong | read | Weak + the input cannot be destroyed until this controller removes its finalizer. Use when you create outputs derived from it. |
| InputDestroyReady | read | Watch your own outputs for "tearing down with no finalizers left" — i.e. safe to destroy. |
| OutputExclusive | write | Only this controller may write this type. Runtime refuses two exclusive claims. |
| OutputShared | write | Multiple controllers write the type, but each instance is owned by its creator. |
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.
The skeleton, conceptually:
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.
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.Concurrencyspawns 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.
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:
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.
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.