Skip to content

Reconcile Fly Machines

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/tutorials/fly-machines-reconcile/.
In `examples/fly-reconcile`, run `npm install` and boot mudflaps as the page shows. Run `npx chant run fly-reconcile --no-record` against it.
Run it again unchanged, then change a Machine in `src/infra.ts` and run it once more, and show me the output of each run.
Keep the local endpoint override in place and set no `FLY_API_TOKEN`. Shut mudflaps down at the end.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

The fly-reconcile example shows a stateless reconcile on Fly. 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.

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, as the ownership marker the owned-only prune reads back. flyApply creates the volume before the machine that mounts it (the volume is POSTed first), and FLY011 checks at build time that the mount names a declared volume.

ops/fly.op.ts is Build → Apply only. It leaves the emulator running, 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 })]),
],
});

Boot mudflaps once, then run the op:

Terminal window
docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
cd examples/fly-reconcile
npm install
chant run fly-reconcile

The first run creates everything:

created: app/fly-reconcile-demo
created: volume/fly-reconcile-demo/data
created: machine/fly-reconcile-demo/web
created: machine/fly-reconcile-demo/worker

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, and nothing is touched:

unchanged: app/fly-reconcile-demo
noop: volume/fly-reconcile-demo/data
noop: machine/fly-reconcile-demo/web
noop: machine/fly-reconcile-demo/worker

A 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/web
noop: machine/fly-reconcile-demo/worker

Removing 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, since it carries the managed-by: chant marker:

noop: machine/fly-reconcile-demo/web
pruned: fly-reconcile-demo/worker

Foreign machines survive. Create a machine directly in mudflaps, without the ownership marker, then re-run:

Terminal window
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 it never touches a resource created outside chant.

Stop the emulator when done:

Terminal window
docker rm -f mudflaps

Drop the endpoint override and give it a token. The op and the code stay the same:

phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpoint
Terminal window
export FLY_API_TOKEN=...
chant run fly-reconcile

With 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; only the endpoint changes.

chant