Declarative provisioning that converges a provider to a spec
0

Configure Feed

Select the types of activity you want to include in your feed.

OCaml 98.5%
Dune 0.4%
Other 1.1%
60 1 0

Clone this repository

https://git.vm.fail/gazagnaire.org/caravan https://git.vm.fail/did:plc:fw7vyck2a6d7g2ry7sjezxjw
ssh://git@git.recoil.org:2222/gazagnaire.org/caravan ssh://git@git.recoil.org:2222/did:plc:fw7vyck2a6d7g2ry7sjezxjw

For self-hosted knots, clone URLs may differ based on your setup.


README.md

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 macOS vz launcher, a state directory as the provider database. Free and instant; compute is disposable (renewal: replace costs 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 a command); 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: stage updates 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:

  • apply alone never resolves a conflict.
  • apply --force runs 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-data runs 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 (--yes does not skip that; without a terminal, --yes plus --destroy-data is 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, --force wins.

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 camel looks up its resources by the names the planner assigns -- instance camel, data volume camel-data, firewall camel-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 tag caravan:camel on every taggable resource it creates, and resolution requires name and tag together. A same-named resource without the tag is a Foreign_resource conflict, never a silent adoption -- for the data volume, adoption decides what the node's persistent state is, so it must be a decision. apply --force adopts 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, observe reads 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_provision writes 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.

  • 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-hash label.
  • 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/spool decades before the words store-and-forward reached IoT, and /var/spool is still the name the model deserves.

License#

ISC License. See LICENSE.md for details.