caravan#
Declarative provisioning that converges a provider to a spec.
Overview#
caravan reconciles what an infrastructure provider reports against a
declared target. A pure planner compares observed state with the spec
and returns the ordered actions that close the gap -- or refuses with a
conflict when closing it would destroy something. A provider backend
then executes the plan, step by step, waiting for each resource to
settle.
Three rules shape every plan:
- The data volume is never destroyed. The one resource whose loss is unrecoverable can never be deleted, shrunk, or reformatted by a plan. Any mismatch on it surfaces as a conflict to resolve explicitly, never as a synthesized destructive action.
- Compute is disposable. Reconciling immutable bootstrap state -- a new boot image, a new instance type -- stops and replaces the instance, but first detaches and preserves its data volume and routed address.
- Routine updates are out of scope. An instance whose running system image drifted by itself -- an over-the-air update -- keeps its provision tag and is left alone. In-place system updates belong to the update channel (uplink); caravan only replaces compute when the declared provision input changes.
caravan is one of three tools sharing one fleet; each owns one thing:
- uniboot owns the artifact: it builds images, owns layout, slots and the content store, and is the only tool that writes image bytes to any medium -- publishing to a cloud, flashing a bench device, running once locally.
- uplink owns the movement of trust: it packages, signs, verifies and delivers releases; it owns anti-rollback and the confirm witness.
- caravan owns the intent: it declares which target realizes which release, plans, converges, refuses; it never builds, never signs, never interprets image bytes.
- Identity is one digest: minted by uniboot at build, carried by
uplink, asserted by caravan (the provision tag is the digest inside
the
image:reference -- caravan hashes nothing), echoed by the device.
The planner is I/O-free and provider-neutral, and one planner spans
the whole fleet spectrum -- a VM on the laptop, a cloud instance, a
board on the bench, a device in the field behind a windowed link.
Each is a backend implementing the same Caravan.PROVIDER contract
with its own constraints:
- local (
caravan.vz): virtual machines under the macOSvzlauncher, a state directory as the provider database. Free and instant; compute is disposable (renewal: replacecosts an APFS clone and a process); no firewall, address, or registry. - cloud (
caravan.scaleway): the full substrate -- instance, data volume, firewall, routed address, registry -- with catalog prices on the plan and replacement as the renewal discipline. - bench board (
caravan.board): a physical board whose storage is exposed to this host -- a Compute Module in rpiboot mode, an SD card in a reader. The provider database lives on the board itself; a replacement flash reads the partition table from the device and rewrites only the system partitions, so the state partition survives. A target says how its board is powered (manual,always-on, or acommand); there are no per-board presets, so nothing about a board's behaviour is implied by its name. - field (
caravan.spool): a device reached through connectivity windows rather than a connection -- a sensor on a duty-cycled radio, a gateway on a ship, a satellite. Commands queue durably in a spool the transport drains when a window opens; observation is the device's own last check-in;renewal: stageupdates through the update channel and the device is never replaced.
The same target file crosses classes as the device moves through its
life: a board is renewal: replace on the bench and flips to
renewal: stage when it ships, and the identity tags written at the
last bench flash are what its check-ins echo from the field. On a
field target most spec fields are assertions rather than commands:
the hardware the device was built with -- its CPU, its storage -- is
matched, not requested, and drift on a physical fact is a conflict
whose fix is the target file. What stays mutable over the link is
exactly the release, the bootstrap document, and the tags.
Installation#
Install with opam:
$ opam install caravan
If opam cannot find the package, it may not yet be released in the
public opam-repository. Add the overlay repository, then install it:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install caravan
Usage#
Declare a target in YAML:
name: camel
scaleway:
zone: fr-par-2
instance-type: STANDARD2-A2C-8G
image: 11111111-2222-4222-8222-222222222222
volume-gib: 50
inbound-tcp: [80, 443]
registry: camel
bootstrap-file: ./bootstrap.yaml
The provider is the member that carries its own settings -- one of
scaleway:, vz:, board: or spool: -- so a target file can only
spell the fields its provider actually has, and a field the provider
requires is required by the decoder rather than checked later. A board
on the bench and a field device on a windowed link:
name: bench-01
board:
device: /dev/disk4
boot: /Volumes/boot
serial: cm5-01
power: manual
instance-type: cm5-8g
image: ./bench.img
volume-gib: 8
address: false
name: sat-1
spool:
directory: /var/spool/caravan/sat-1
intent-key: ./ops.pem
renewal: stage
instance-type: vehicle
image: release@sha256:1111111111111111111111111111111111111111111111111111111111111111
volume-gib: 1
address: false
Then:
$ caravan plan -c camel.yaml # the actions that would run, billable marked
$ caravan apply -c camel.yaml # show the plan, confirm, execute
$ caravan status -c camel.yaml # what the provider reports; converged or not
plan prints the numbered actions that would close the gap. Each step
that starts recurring charges is marked with its price when the
provider's catalog exposes one ([EUR 7.99/month], from Scaleway's
server-type catalog) and with [provider charge] otherwise; a total:
line sums the priced steps and says when unpriced charged steps
remain. apply executes the plan after confirmation (--yes skips
it), printing each step as it runs and finishing with the routed
address and registry endpoint.
Some drift cannot be closed without destroying data: the observed
volume is smaller than the spec asks for, a stale attachment from a
deleted server still holds it, a volume of the wrong storage class
sits under the expected name. The planner never turns these into
delete or resize steps that would ride into an apply -- it stops
with a conflict naming the resource and the mismatch. Resolving one is
explicit, in three tiers:
applyalone never resolves a conflict.apply --forceruns the lossless resolutions, printing each step: detaching a data volume from a stale holder, adopting a same-named resource caravan did not create by tagging it.apply --destroy-dataruns the destructive one: the volume that cannot be reconciled -- too small, wrong class, or foreign -- is detached from every holder, deleted, and recreated empty on the next pass. An interactive run must retype the volume's name (--yesdoes not skip that; without a terminal,--yesplus--destroy-datais the authorization), and the same name is checked again server-side by the provider call. When one conflict has both a lossless and a destructive fix,--forcewins.
The provider console is never required for a resource caravan owns;
plan shows which flag applies when it reports a conflict. A plan by
itself never contains a destroy step -- destruction only ever comes
from the explicit resolution path. The remaining alternative is always
to change the target to match reality (for example, declare the size
the volume actually has) and run plan again.
A directory of targets is a fleet, in filename order, under a
health-gated rollout: caravan plan -c fleet/ prints each target's
verdict -- converged, the plan at the gate, a conflict, or held --
and apply converges the target at the gate, then advances. A
conflict closes the gate the same way unfinished work does, so a
phased rollout never runs ahead of a target that refuses or has not
confirmed; prefix filenames (01-, 02-) to spell the order.
Credentials resolve the way the scw CLI resolves them (environment,
then profile); --profile selects a named profile.
caravan keeps no state file; the provider is the only source of truth. Two mechanisms make that work:
- Identity by name and provenance tag. A target named
camellooks up its resources by the names the planner assigns -- instancecamel, data volumecamel-data, firewallcamel-fw-- but a name alone is not proof of ownership: provider names are not unique, and a same-named resource may be a stale leftover or a plant. So caravan writes the identity tagcaravan:camelon every taggable resource it creates, and resolution requires name and tag together. A same-named resource without the tag is aForeign_resourceconflict, never a silent adoption -- for the data volume, adoption decides what the node's persistent state is, so it must be a decision.apply --forceadopts one explicitly by recording the tag (how a pre-caravan volume is taken over, once, on purpose). Routed addresses have no provider name at all, so for them the tag is also the only handle. Registry namespaces take no tags; their names are region-unique at the provider and image integrity rides on digest pinning, not registry identity. - Provision state as an instance tag. When caravan creates an
instance, it writes one more tag on it:
caravan:provision:<sha256-prefix of the target's image reference>. On every plan,observereads the tags back and compares that fingerprint against the one computed from the current target file. Equal means the instance is the realization of the current declaration, and the image the instance actually runs is ignored -- self-applied updates are legitimate drift. Different means the declaration changed since the instance was provisioned, and the plan replaces it: stop, detach the data volume and the address, delete, recreate from the new image, reattach. A managed instance with no provision tag at all is adopted in place:Record_provisionwrites the tag without a restart.
The comparison is declared-fingerprint against recorded-fingerprint, never declared-image against observed-image -- that is what lets in-place updates over uplink coexist with declarative replacement.
Library#
The same engine is a library. Caravan.plan is pure -- spec in,
observed state in, actions or conflict out -- so reconciliation logic
tests without a provider. Caravan.Make (P) executes plans over any
Caravan.PROVIDER:
module Engine = Caravan.Make (Caravan_scaleway)
let converge backend spec =
let engine = Engine.v backend in
match Caravan.observe engine spec with
| Error e -> Error (`Provider e)
| Ok observed -> (
match Caravan.Plan.v spec observed with
| Error conflict -> Error (`Conflict conflict)
| Ok plan -> (
match Caravan.apply engine spec plan with
| Ok applied -> Ok (Caravan.Applied.instance applied)
| Error (action, e) -> Error (`Step (action, e))))
Make (B).v packs a backend with its connected handle once; everything
after that is a call on the engine, so no caller names the backend
module twice. Every backend has the same shape around that: a v taking
labelled settings and answering (t, error) result, the flat
Caravan.PROVIDER contract, and whatever else it offers behind a
submodule of its own (Caravan_scaleway.Of_scaleway,
Caravan_spool.Ground and Caravan_spool.Transport,
Caravan_vz.Instance_type).
The library itself is one module per concept, each with the same value
shape -- an abstract t, a v constructor, flat accessors, equal and
pp as the type warrants: Spec, Observed, Plan, Action,
Conflict, Kind, Resource, Renewal, Release, Price,
Bootstrap. Spec.v owns what an omitted field means, so a target file
and a program that both leave one out agree by construction, and
Spec.update derives a variant without the record being exposed.
Bootstrap documents are opaque Caravan.Bootstrap.t values: every
printer in the library redacts them, so a plan rendered into a log
never leaks the payload.
Related work and credits#
- InfraKit (Docker, archived) is
where the concept comes from: pair an immutable-image builder with a
toolkit that actively converges infrastructure to a declared spec.
InfraKit played that role next to
LinuxKit; caravan plays it
next to uniboot, which
is itself LinuxKit-inspired.
The architecture differs -- caravan is a one-shot observe/plan/apply
with explicit conflicts and no plugin daemons, and shares no code
with InfraKit -- but the pairing is InfraKit's idea, and so is
recording the provisioned configuration as a tag on the instance
instead of in a state file: InfraKit's group plugin tagged every
instance with its config SHA and reconciled by comparing tags, which
is exactly what the provision tag does here. Kubernetes uses the
same mechanism as the
pod-template-hashlabel. - Terraform established the plan-then-apply workflow with a human-reviewed plan; caravan's CLI follows that shape, scoped to one target.
- balena is the closest whole-stack analog in the container world: a purpose-built OS, an on-device supervisor converging each device to a desired state, and a fleet console. caravan differs in substrate (images and unikernels rather than containers), in reach (the same planner also provisions the cloud substrate and the bench, which device platforms leave to other tools), and in not assuming an always-connected supervisor.
- Mender, RAUC, SWUpdate and Eclipse hawkBit own the layers that correspond to uniboot and uplink here: health-gated rollback on the device, signed update delivery, and campaign rollout servers. Those tools reserve a second full partition for the rollback copy (classic A/B); uniboot instead keeps every version as an immutable object in a content-addressed store and moves small pointers -- the release trusted, tried, and held in reserve -- so a rollback costs a pointer, not a partition. caravan sits above that layer as the declarative reconciler deciding which target must converge to which declared input.
- Device shadows and twins (AWS IoT, Azure IoT Hub) are the desired-versus-reported pattern caravan's spec-versus-observed follows, including that an unheard device is not an absent one. caravan keeps the pattern out of any broker: the spool is a directory, the report is a file, and the same reconciler covers targets no shadow service models -- the cloud substrate itself and the bench.
- The spool itself is the oldest pattern in the list: UUCP moved
queued work over dial-up windows from
/var/spooldecades before the words store-and-forward reached IoT, and/var/spoolis still the name the model deserves.
License#
ISC License. See LICENSE.md for details.