Reconcile Fly Machines
The fly-reconcile
example shows chant’s differentiator on Fly: a stateless reconcile. Fly’s
Machines API has no server-side declarative apply and chant keeps no state
file, so flyApply does the reconcile itself — it diffs your typed resources
against what the platform reports, and prunes only the machines it owns.
Where local-fly boots and tears down the
emulator each run, this example points at a persistent mudflaps, so you run
the op repeatedly and watch the reconcile converge.
The infra
Section titled “The infra”src/infra.ts is one app, a persistent volume, and two machines — web (which
mounts the volume) and worker:
import { App, Machine, Volume, MachineConfig, MachineGuest, MachineMount, Fly } from "@intentius/chant-lexicon-fly";
const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });const data = new Volume({ name: "data", region: "iad", size_gb: 1 });const guest = new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 });
const web = new Machine({ name: "web", region: "iad", config: new MachineConfig({ image: "flyio/hellofly:latest", guest, mounts: [new MachineMount({ volume: "data", path: "/data" })], }),});const worker = new Machine({ name: "worker", region: "iad", config: new MachineConfig({ image: "flyio/hellofly:latest", guest }) });
export { app, data, web, worker };The serializer stamps managed-by: chant into each machine’s config.metadata
on its own — the ownership marker the owned-only prune reads back. flyApply
creates the volume before the machine that mounts it (the dependency is on the
wire — the volume is POSTed first), and FLY011 checks at build time that the
mount names a declared volume.
The op
Section titled “The op”ops/fly.op.ts is Build → Apply only — no emulator lifecycle, so state
persists between runs:
import { Op, phase, build } from "@intentius/chant/op";import { flyApplyStep } from "@intentius/chant-lexicon-fly";
export default Op({ name: "fly-reconcile", overview: "Reconcile the plan against a running mudflaps (create / update / prune)", taskQueue: "fly", phases: [ phase("Build", [build(".", { script: "build:fly" })]), phase("Apply", [flyApplyStep("dist/fly.json", { endpoint: "http://localhost:4280", prune: true })]), ],});Run it
Section titled “Run it”Boot mudflaps once, then run the op:
docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
cd examples/fly-reconcilenpm install
chant run fly-reconcileThe first run creates everything:
created: app/fly-reconcile-democreated: volume/fly-reconcile-demo/datacreated: machine/fly-reconcile-demo/webcreated: machine/fly-reconcile-demo/workerWatch it reconcile
Section titled “Watch it reconcile”The point is what re-running does. State lives on the platform, not in a file, so
flyApply reads it back each time.
Re-apply is a no-op. Run it again with no source change — nothing is touched:
unchanged: app/fly-reconcile-demonoop: volume/fly-reconcile-demo/datanoop: machine/fly-reconcile-demo/webnoop: machine/fly-reconcile-demo/workerA config change updates in place. Edit web’s config.image in
src/infra.ts and re-run — flyApply leases the machine, updates it, and waits
the new instance to started. worker, unchanged, still no-ops:
updated: machine/fly-reconcile-demo/webnoop: machine/fly-reconcile-demo/workerRemoving a machine prunes it. Delete the worker machine from src/infra.ts
and re-run. Prune is on, so the machine chant no longer declares is destroyed —
it carries the managed-by: chant marker:
noop: machine/fly-reconcile-demo/webpruned: fly-reconcile-demo/workerForeign machines survive. Create a machine directly in mudflaps, without the ownership marker, then re-run:
curl -X POST http://localhost:4280/v1/apps/fly-reconcile-demo/machines \ -H 'content-type: application/json' \ -d '{"name":"legacy","region":"iad","config":{"image":"flyio/hellofly:latest"}}'The owned-only prune leaves legacy alone — chant deletes only what it owns, so
a resource created outside chant is never touched. Authority stays with the
platform; chant reconciles its own slice of it.
Stop the emulator when done:
docker rm -f mudflapsRun it against real Fly
Section titled “Run it against real Fly”Drop the endpoint override and give it a token — the same op, no code change:
phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpointexport FLY_API_TOKEN=...chant run fly-reconcileWith no endpoint, flyApply falls through to FLY_FLAPS_BASE_URL (or Fly’s
https://api.machines.dev) and authenticates with the token. The reconcile is
the same against real Fly — the emulator is just an endpoint swap.