Controllers
A controller repeatedly converges current state. Start with one decorated function; use stages and cases when named progress or alternative behaviors make the flow clearer. Application handles clients, watches, retry queues, signals and cleanup.
from cloudcoil.application import Application
from cloudcoil.controller import Context
from cloudcoil.models.kubernetes.core.v1 import ConfigMap
app = Application("configmaps")
configs = app.controller(ConfigMap, label_selector="example.com/manage=true", workers=4)
@configs.reconcile(every=60)
async def reconcile(config: ConfigMap, ctx: Context[ConfigMap]) -> ConfigMap:
config.data = {**(config.data or {}), "managed-by": "cloudcoil"}
return config
if __name__ == "__main__":
app.main()
A decorated handler takes (resource) or (resource, ctx). The resource is a deep
copy of the present, non-deleting primary object; omit Context if unused. Events may
coalesce: handlers observe current state, not a history or an exactly-once stream.
Different keys can run concurrently, but one key never does. Handlers must tolerate
repeated execution and partial child writes.
Choose the execution structure
| Structure | Use it for | What runs |
|---|---|---|
@reconcile() |
Most controllers; ordinary guards and loops | One handler per pass |
@stage(condition=..., depends=...) |
Several responsibilities with visible progress | Every stage, in dependency order, until one waits or fails |
@case(when=..., after=...) and @otherwise() |
Mutually exclusive behavior | The first matching case, or the fallback |
A controller chooses one row. A stage can itself contain cases. Start with a reconcile function and split it only when names and conditions make the flow clearer. See stages and reporting for ordering and outcomes.
Registration and composition
For a reusable module, create Controller(Model, owns=(...)), decorate handlers,
then call app.include(controller). Import that module before running the app;
there is no automatic module scanning. app.controller(Model, ...) creates and includes
the same group directly. Registrations perform no I/O. Manifest generation validates
completed definitions; startup freezes them. Duplicate inclusion and late registration
fail explicitly. A group has one reconciliation function, automatic stages, or cases.
Watches and cached reads
Declare owned children with owns=(ConfigMap, Deployment). Owner watches compare
parent group/kind/UID and support cluster-scoped owners with namespaced children.
For non-owning dependencies, register a mapper:
from cloudcoil.controller import ResourceKey
from cloudcoil.models.kubernetes.core.v1 import Secret
@configs.watch(Secret)
def changed(secret: Secret) -> list[ResourceKey]:
return [ResourceKey("settings", secret.namespace)]
Mappers are synchronous and must be fast and free of I/O. Updates map both old and new versions, so removing a label or reference wakes former dependents. Mapper errors stop the controller instead of losing events. The primary informer syncs before secondary handlers, and every informer syncs before workers start.
Use ctx.cached(Kind).get/list for declared informer snapshots, await ctx.get(Kind,
name) for a live read, and await ctx.client(Kind) for full client operations.
Controller informers do not silently evict objects; scope and selectors control
memory. A missing cached object may have left the selected scope: it is not proof
that destructive cleanup is authorized. See read contracts.
Managing children
await ctx.ensure(ConfigMap(data={"message": "hello"})) creates or converges an
owned child. Name and namespace default to the parent. Supply explicit metadata for
multiple children of the same kind; declare owns so child drift triggers repair.
Only supplied fields are managed: maps merge, lists replace, explicit None removes a field, and omitted fields (including server-allocated Service IPs) survive. No-op writes are skipped. Existing unrelated children cannot be adopted. UID/version conflicts retry the whole reconciliation. Cross-namespace ownership is rejected.
There is no multi-resource transaction. Obsolete children require explicit guarded deletion and corresponding RBAC. See the child set example.
Returning resources and status
Return a modified primary resource to save successful spec/metadata/status edits.
Returning None does not save incidental primary edits. Explicit ctx.set_status
and ctx.condition reports are saved even on failure; see the reporting contract.
Unchanged values cause no PATCH. Use Result(resource=obj, requeue_after=60) for an
explicit per-pass timer, or @reconcile(every=60) for periodic successful passes.
All changes compare with an independent dispatch baseline, guarded by UID and
resourceVersion. They never rebase a stale desired snapshot over a new live read.
The returned object must have the same primary identity/version. Discovery sends
status to /status when present. Changes to main fields and status require two
non-atomic writes: a successful main write is not rolled back if status then conflicts.
Ordinary exceptions retry with backoff and jitter. raise Wait("Pending", after=10)
reports expected pending work; TerminalError suppresses automatic error retries.
Later inputs/resyncs still reconcile. reconcile_timeout bounds the callback and
its persistence; shutdown cancellation stops without writing a failure report.
Finalizers
For a resource representing external state, pair provisioning and cleanup. This
excerpt assumes an ExternalRecord model and an idempotent provider adapter; see
the complete finalizer example.
records = app.controller(ExternalRecord)
@records.reconcile(every=60)
async def reconcile(record: ExternalRecord) -> None:
assert record.metadata and record.metadata.uid
await provider.put(record.metadata.uid, record.spec.value)
@records.finalize("example.com/external-record")
async def cleanup(record: ExternalRecord) -> None:
assert record.metadata and record.metadata.uid
await provider.delete(record.metadata.uid)
The runtime persists the finalizer before normal reconciliation, rechecks deletion, and updates its write baseline after that live write. Deleting objects carrying the finalizer enter cleanup instead. Cleanup errors retry and retain the finalizer; success removes it. One finalizer handler owns this controller's cleanup; compose multiple operations explicitly inside it. Cleanup must be idempotent and return None, or raise Wait while pending. Cache absence never triggers destructive cleanup.
Use application lifespans for process/leadership resources; finalizers belong to individual Kubernetes objects.