caravan #
Declarative provisioning that converges a provider to a spec.
Overview #
You can throw away the instance a service runs on and rebuild it from its declaration, boot image and type included. You cannot do that with its data volume. Most provisioning tools still handle both the same way, so a change of instance type, or a small mismatch on a disk, can turn into a plan that deletes your data.
caravan keeps them apart. You declare a target; caravan asks the provider
what exists and works out, in a pure planner, the ordered steps that close
the gap. If closing it would destroy something, you get a conflict and no
plan. A provider backend runs the plan step by step and waits for each
resource to settle before the next.
Three rules hold for every plan.
- A plan never destroys the data volume. It won't delete, shrink or reformat it. If the volume doesn't match, you get a conflict to resolve.
- Compute is disposable. Change the boot image or the instance type and the instance is stopped and replaced. Its data volume and routed address are detached first and kept.
- Routine updates belong to the update channel. If an over-the-air update changes the image an instance runs, the instance keeps its provision tag and caravan leaves it alone. In-place updates are the job of uplink; caravan replaces compute only when the declared provision input changes.
Three tools manage one fleet, and each owns one thing. uniboot builds the images. It owns their layout, their slots and the content store, and it is the only tool that writes image bytes to a medium. uplink moves trust: it packages, signs, verifies and delivers releases, and it owns anti-rollback and the confirmation witness. Anti-rollback means a device refuses any release whose version is below a counter it keeps, so an old and vulnerable release cannot be put back. The confirmation witness is a statement the device signs with its own key after it has booted a release and confirmed it, naming that release's digest.
caravan owns the intent. It declares which target runs which release, plans,
converges and refuses, and it never builds, signs or reads image bytes. The
three tools name a release by one digest: the SHA-256 of the release's
descriptor, which is the signed manifest listing the release's images by
their own digests. A boot slot stores those 32 bytes, and caravan reads them
as the hex after @sha256: in the target's image: reference. uniboot
computes the digest at build time and uplink carries it. caravan asserts it
(the provision tag is the digest inside the image: reference, so caravan
hashes nothing), and the device reports it back.
The planner does no I/O and knows no provider, so the same planner serves
every kind of target. Each kind has a backend that implements the
Caravan.PROVIDER signature, with constraints of its own:
- Local (
caravan.vz) runs virtual machines under the macOSvzlauncher and uses a state directory as the provider database. It has no firewall, address or registry. - Cloud (
caravan.scaleway) manages the instance, the data volume, a firewall, a routed address and a registry. Plans show catalogue prices, and an instance is renewed by replacing it. A routed address is a public IP address reserved independently of any instance (a Scaleway flexible IP), so it can move from the instance being replaced to the new one while the domain's DNS record keeps pointing at it. A registry is a namespace in the provider's container registry, where images are pushed before an instance runs them. - Bench board (
caravan.board) drives a physical board whose storage this host can see. The provider database lives on the board itself. A flash rewrites only the system partitions, so the state partition survives. Each target says how its board is powered (manual,always-onor acommand); there are no per-board presets. - Field (
caravan.spool) reaches a device during connectivity windows instead of over a standing connection. Commands wait durably in a spool that the transport drains when a window opens. What caravan observes is the device's own last check-in, andrenewal: stageupdates the device through the update channel without ever replacing it.
A target file stays with a device for its whole life. On the bench a board
is renewal: replace, and it becomes renewal: stage when it ships. On a
field target most spec fields assert facts instead of requesting them. The
hardware is matched, and drift on a physical fact is a conflict you fix in
the target file.
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 #
A target is a YAML file. The provider is whichever member carries its own
settings (scaleway:, vz:, board: or spool:). A target file can
therefore only spell fields its provider has, and the decoder insists on the
ones the provider requires:
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 bootstrap document (bootstrap-file) is the configuration a new
instance receives at first boot; on a cloud it is the user data. It may hold
secrets, so caravan never looks inside it and every printer redacts it.
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, numbered, the actions that would close the gap. A step that
starts a recurring charge shows its price when the provider's catalogue has
one ([EUR 7.99/month], from Scaleway's server types), and
[provider charge] when it does not. Each step is priced in whatever unit
limits its target. A release staged over a windowed link, for instance, shows
the bytes it has to carry ([1.2MiB over the link]). That figure is the size
of the release descriptor plus the signed size of each image in it that the
device did not list as held at its last check-in. A device that has never
reported counts as holding nothing, so staging to it costs the whole bundle.
The envelope's signature and CBOR framing are small constants and are left
out. The listing ends with one total per unit. apply runs the plan once you
confirm it; --yes skips the confirmation.
A directory of targets is a fleet, and caravan converges it in filename
order behind a gate. The gate measures convergence. caravan has no idea what
a healthy service looks like, so service health plays no part. A target is
planned only when every target before it in filename order has an empty
plan. The first target with a pending plan or a conflict closes the gate,
and every target after it is Held for that pass. A phased rollout can
therefore never get ahead of a target that refuses or has not yet confirmed.
Credentials are resolved the same way the scw CLI resolves them: from the
environment first, then from the profile.
Conflicts #
Some drift can only be closed by destroying something. The observed volume may be smaller than the spec asks for, or report different IOPS. (IOPS, input/output operations per second, is the rate a block volume is provisioned to sustain; on Scaleway it tells apart storage classes of the same size.) A stale attachment left by a deleted server may still hold the volume. A volume of the wrong storage class may sit under the expected name. The managed firewall may accept inbound traffic the target does not declare. The planner never turns any of these into a delete or resize step. It stops with a conflict that names the resource and the mismatch, and you resolve it in one of three explicit ways:
applyalone never resolves a conflict. It refuses and names the flag for each resolution the conflict offers, both flags when it offers both. An operator told only about--forcewould never learn that the volume can be deleted at all.planprints the same resolutions step by step before it fails on the conflict.apply --forceruns the resolutions that lose nothing and prints each step. It detaches a data volume from every stale holder in one pass. It adopts a same-named resource that caravan did not create by tagging it. It also records the target's provision state on an instance that reports none.apply --destroy-dataruns the destructive resolution. A volume that cannot be reconciled (too small, different IOPS, the wrong class, or foreign) is detached from every holder, each holder being powered off first. It is then deleted and recreated empty on the next pass. In an interactive run you must type the volume's name again, and--yesdoes not skip that. Without a terminal,--yestogether with--destroy-datais the authorisation. The provider call checks the same name again on the server side.
A foreign data volume is the one conflict that offers both fixes. You can
adopt it by tagging it where it is, or delete it and recreate it empty.
--destroy-data on its own takes the destructive fix. When both flags are
given --force wins, so adoption is tried before deletion.
A plan by itself never contains a destroy step. Destruction comes only from
an explicit resolution. There is always one other way out: change the
target to match what exists and run plan again.
Neither flag answers two conflicts, because any fix caravan offered would
assert something the provider contradicts. The first is an instance that
carries more than one provision tag. It claims several states and proves
none, even when the wanted one is among them, and it is what a delivery left
half finished looks like. The tags are read sorted and deduplicated, so the
verdict depends on the instance and never on the order the provider listed
them in. A replace target resolves it by replacing the instance. A stage
target refuses: staging and activating a release leaves the stale tag in
place, and recording the wanted state would claim a convergence the instance
contradicts. The fix there is to remove the stale tags. The second conflict
arises when more than one resource claims the target's name. caravan will
not choose between them, so it refuses and names every candidate.
Identity without a state file #
caravan keeps no state file; the provider is the only source of truth. Two mechanisms make that work.
Resources are identified by name and by a provenance tag. A target named
camel looks up its resources under the names the planner gives them:
instance camel, data volume camel-data, firewall camel-fw. A name alone
proves no ownership. Provider names are not unique, and a resource with the
same name may be a stale leftover or a plant. caravan therefore writes the
identity tag caravan:camel on every taggable resource it creates, and a
lookup needs both the name and the tag. A resource with the name but without
the tag is a Foreign_resource conflict, and caravan does not quietly adopt
it. Adopting a data volume decides what the node's persistent state is, so
somebody has to make that decision, and apply --force makes it by writing
the tag. Routed addresses have no provider name, so for them the tag is the
only handle.
The provision state is an instance tag. When caravan creates an instance
it also writes caravan:provision: followed, verbatim, by the digest in the
target's image: reference, the one uniboot computed at build time. On every
plan, observe reads the tags back and compares that identity with the one
the current target file pins. If they are equal, the instance realises the
current declaration, and caravan ignores the image it actually runs, since
updates the instance applied itself are legitimate drift. If they differ, the
declaration has 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. An instance with no tag at all also counts as
drift. It has proved nothing, and treating it as converged would let an
instance block its own delivery just by reporting less than one with a stale
tag.
The comparison is between the declared identity and the recorded one, and
never between the declared image and the observed image. That choice is what
lets in-place updates over
uplink live alongside
declarative replacement. A reference without a digest pins no identity. A
replace target then compares the observed image with the declared one,
which suits a local path on a disposable target. A stage target is refused
as soon as its file is read, because an identity that has to be echoed back
over a link cannot be a local path.
The provider database #
The cloud backend can ask its provider, so it keeps no database of its own; the tags above are how it recognises what it created. The other three backends have no such API. Each keeps the database itself, in the one place that belongs to the target: a state directory on this host, the board's own boot partition, or the spool directory the fleet service writes.
Some other party can write to each of those directories, so no backend
reaches them by path. Eio is OCaml's effects-based I/O library, and a
capability there is a directory handle opened once; every later path is
resolved against it, so code that holds it can reach only that subtree. Each
backend resolves its directory once, opens it as such a capability and goes
through it for every file. A join resolves one component at a time inside the
subtree, and a component that leads out of it fails instead of being
followed. A symlink planted inside therefore cannot send a read, a write or a
delete elsewhere on the host, whether it sits where a file should be or in
place of a directory the backend creates. The vz and spool backends take the
switch that keeps their capability open (~sw, Eio's scope for resources,
which closes the handle when it ends) and work only while it lives. The board
backend instead opens its boot partition once per operation, because a flash
unmounts that filesystem and the host cannot unmount one this process holds
open.
-
Local (
caravan.vz): each instance is a directory. It holds the boot disk (an APFS clone of the released image where the filesystem supports it), the tags, the list of attached volumes and the pid of thevzprocess. Each volume is a sparse file. The pid is stored with the process's start identity, so a recycled pid is never mistaken for the launcher.instance-typefollows this backend's own grammar,<cpus>c-<memory>g, so4c-8gmeans four CPUs and 8 GiB. The launcher reads the list of attached volumes once, at start-up, so attaching or detaching a volume needs the instance stopped; both refuse while its process runs. -
Bench (
caravan.board): before any operation, the backend has the host prove that the mount point the target names is a mounted partition of the device it names (withfindmntandlsblkon Linux,diskutilon macOS). It refuses when the partition records another board's serial number. The target declares the device and the mount point separately, and without this check a renumbered device could have one board's eMMC rewritten while the metadata landed on another's. A flash unmounts the partition, writes the device and mounts it again. Only then does it write all of the instance metadata (serial number, tags, image reference, name, flavour and volume record) through the filesystem that came back, and it reads the metadata again before it reports success. Nothing goes through a view the flash invalidated, and nothing recorded before the flash is lost. If the kernel holds the boot partition mounted and the backend was given no mount control, the backend refuses to write under it. -
Field (
caravan.spool): apply writes one file per intent into<spool>/outbox, named so that queueing the same intent twice does nothing. Every file is written under a dotted temporary name and then renamed into place. Dotted names are reserved for the spool's own files, so the transport never picks up a half-written file.With a signing key, a detached COSE_Sign1 signature is published next to each intent (as its
.sigsibling) and again under the SHA-256 of the bytes it covers. COSE_Sign1 is the single-signer signature structure of COSE (RFC 9052), encoded in CBOR, and "detached" means the signed bytes are not copied into it. Queueing a command again replaces the intent file in place, and a crash can interrupt that between the new intent and its sibling. The signature is therefore looked up by the digest of the intent bytes on disk. Whichever generation the crash leaves queued, old or new, is checked against its own signature and never against its neighbour's.Some checks need no key, and they refuse a contact window on every spool, signed or not. The queued command must match the name that labels it. Its sequence number must be above the window already acknowledged. No bootstrap document may be left over that no queued intent pins (a bootstrap document is spooled under its digest, and a
bootstrapintent pins that digest). A pinned operator public key answers what those checks cannot, namely who authorised the bytes. A window carries all of its commands or none of them, and a refused intent is moved aside as evidence so that it does not block every later window.Convergence has to be proved; caravan does not take the report's word for it. With the device's public key pinned, a provision state counts only when the device's own signed witness of the release it booted verifies against that key.
observechecks the stored witness again instead of trusting whatever the report path accepted.
Library #
The same engine is available as a library. Caravan.Plan.v is pure: given a
spec and the observed state, it returns actions or a conflict, so the
reconciliation logic can be tested without any provider.
Caravan.Make (P) runs plans over any Caravan.PROVIDER:
module Engine = Caravan.Make (Caravan_scaleway)
(* Observe, plan, apply. A conflict and a backend failure both come back
as values; nothing raises. *)
let converge backend spec =
let engine = Engine.v backend in
let error fmt = Fmt.kstr (fun message -> Error message) fmt in
match Caravan.observe engine spec with
| Error e -> error "%a" (Caravan.pp_error engine) e
| Ok observed -> (
match
Caravan.Plan.v ~capabilities:(Caravan.capabilities engine) spec observed
with
| Error conflict -> error "%a" Caravan.Conflict.pp conflict
| Ok plan -> (
match Caravan.apply engine plan with
| Ok applied -> Ok (Caravan.Applied.instance applied)
| Error (action, e) ->
error "%a: %a" Caravan.Action.pp action
(Caravan.pp_error engine) e))
A plan is computed within its provider's capabilities, and the provider
states them. Every Caravan.PROVIDER answers capabilities, an engine
passes the value on as Caravan.capabilities, and Plan.v requires it with
no default. A capability is a fact that changes the plan itself, as opposed
to how a step runs. So far there is one: whether a volume can attach to a
running instance. A provider whose instance reads its disk list only at
launch says so, and the plan wraps the attach in a stop and a start, so a
single apply still converges the target. An instance that is already stopped
takes the attach as it is. The fields of a target are not capabilities. If a
target asks for a routed address or a staged release that the backend cannot
provide, the step refuses and names the field to clear.
Plan.t is a closed value. Plan.of_actions is the only way to build one
from actions a caller writes. It refuses the first step that names a planned
resource that no earlier step of the right kind creates, and says which step
named which index. Plan.v emits nothing else, so apply never has a plan
of its own to refuse. Every error it returns comes from the backend, labelled
with the step it happened in and printed by Caravan.pp_error. That printer
comes from the engine and not from the backend module, so a caller that made
the backend's error type abstract can still print what went wrong.
apply reads no declaration. Each step carries every value it acts on: the
name and type of the instance it creates, the identity and provision tags it
writes, and the key the bootstrap document goes under. What an apply does to
the provider is exactly what the approved plan showed, whatever the target
file says by the time it runs. Fleet.plan and Fleet.progress implement
the rollout gate. They are pure as well, so the ordering logic is tested with
no fleet at all.
All backends share one shape: a v that takes labelled settings and returns
(t, error) result, the flat Caravan.PROVIDER signature, and a submodule
for anything else the backend offers. The library has one module per
concept (Spec, Observed, Plan, Action, Conflict, Kind,
Resource, Renewal, Release, Price, Bootstrap, Fleet). The modules
a caller builds values of keep t abstract behind a v constructor and flat
accessors. A new spec field then arrives as an optional argument and breaks
no caller. Bootstrap documents are opaque Caravan.Bootstrap.t values that
every printer in the library redacts, so a plan written to a log never leaks
the payload.
API #
caravan.mli holds the Spec, Observed and Plan
types, Conflict with its two resolution functions, the PROVIDER
signature, Make (P : PROVIDER), and Fleet for the rollout order. The
backends are caravan_vz.mli,
caravan_board.mli,
caravan_spool.mli and
caravan_scaleway.mli. Each adds what
only it has. The local backend has its flavour grammar and the board its
power and mount control. The spool has a ground side and a transport side,
and Scaleway has its resource mapping and secret store.
Related work and credits #
- I took the idea from InfraKit
(Docker, now archived), which paired a builder of immutable images,
LinuxKit, with a toolkit that
converged infrastructure to a declared spec. caravan does the same job
next to uniboot, itself
inspired by LinuxKit. The code shares nothing with InfraKit, and there are
no plugin daemons here, just one observe, plan and apply with explicit
conflicts. The provision tag is InfraKit's trick too: its group plugin
tagged every instance with the SHA of its configuration and reconciled by
comparing tags, so no state file was needed. Kubernetes does the same with
its
pod-template-hashlabel. - Terraform made plan-then-apply the usual workflow, with a plan a person reads before anything changes. caravan's CLI works that way, one target at a time.
- If you come from containers, balena is the nearest whole-stack equivalent, with its own OS, an on-device supervisor that converges to a desired state, and a fleet console. caravan runs images and unikernels instead of containers. The same planner also provisions the cloud and the bench, which device platforms leave to other tools, and it does not assume a supervisor that is always connected.
- Mender, RAUC, SWUpdate and Eclipse hawkBit cover what uniboot and uplink cover here: health-gated rollback on the device, signed delivery of updates, and servers for rollout campaigns. 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 to the confirmed release, the release on trial and the last known good one, so a rollback costs a pointer and no partition. caravan sits above that layer and decides which target must converge to which declared input.
- Device shadows and twins (AWS IoT, Azure IoT Hub) are the desired-versus-reported pattern that caravan's spec and observation follow, down to the rule that a device you have not heard from is not a device that has gone. caravan keeps the pattern free of any broker: the spool is a directory and the report is a file. The same reconciler also covers targets no shadow service models, namely the cloud itself and the bench.
- The spool is the oldest pattern here. UUCP moved queued work over dial-up
windows from
/var/spooldecades before IoT took up the term store-and-forward, and/var/spoolis still the right name for the model.
Licence #
ISC License. See LICENSE.md for details.