From 3682fc05b6b53323887e2acfb954eba1666f81e7 Mon Sep 17 00:00:00 2001
From: lex00 <121451605+lex00@users.noreply.github.com>
Date: Mon, 13 Jul 2026 22:31:07 -0600
Subject: [PATCH 1/2] docs: Reconcile Fly Machines tutorial + fly-reconcile
example (#868)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds examples/fly-reconcile — one app + two machines whose op reconciles against
a *running* mudflaps (Build → Apply only, no boot/teardown), so re-runs show the
stateless reconcile flyApply does: create → no-op → in-place update → owned-only
prune, with a machine created outside chant surviving the prune (no managed-by
marker). Plus tutorials/fly-machines-reconcile.mdx and a compile test.
Registered in the Fly tutorials sidebar group after local-fly.
Scope note: the example is machines-only. Adding a Volume with a mount hits a
mudflaps fidelity gap — a mounted machine reads back config.mounts=null, so it
perpetually re-applies against the emulator (INTENTIUS/mudflaps#59). The
volume+mount enrichment is deferred to that fix; machines alone give a clean
offline no-op.
Verified: docs build clean; the full reconcile sequence (create/no-op/update/
prune/foreign-survives) runs green against ghcr.io/intentius/mudflaps:0.4.0;
examples compile tests pass.
---
docs/astro.config.mjs | 1 +
.../docs/tutorials/fly-machines-reconcile.mdx | 149 ++++++++++++++++++
examples/examples.test.ts | 45 ++++++
examples/fly-reconcile/.gitignore | 3 +
examples/fly-reconcile/README.md | 62 ++++++++
examples/fly-reconcile/chant.config.ts | 6 +
examples/fly-reconcile/ops/fly.op.ts | 27 ++++
examples/fly-reconcile/package.json | 19 +++
examples/fly-reconcile/src/infra.ts | 26 +++
examples/fly-reconcile/tsconfig.json | 15 ++
10 files changed, 353 insertions(+)
create mode 100644 docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
create mode 100644 examples/fly-reconcile/.gitignore
create mode 100644 examples/fly-reconcile/README.md
create mode 100644 examples/fly-reconcile/chant.config.ts
create mode 100644 examples/fly-reconcile/ops/fly.op.ts
create mode 100644 examples/fly-reconcile/package.json
create mode 100644 examples/fly-reconcile/src/infra.ts
create mode 100644 examples/fly-reconcile/tsconfig.json
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index b4f3fcca6..7d045d89f 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -89,6 +89,7 @@ export default defineConfig({
label: 'Fly',
items: [
{ label: 'Deploy Fly Machines Offline', slug: 'tutorials/local-fly' },
+ { label: 'Reconcile Fly Machines', slug: 'tutorials/fly-machines-reconcile' },
{ label: 'Fly Deploy with Checkpoint Rollback', slug: 'tutorials/fly-deploy-rollback' },
{ label: 'Managed Agents Worker on Sprites', slug: 'tutorials/sprites-managed-agent-worker' },
],
diff --git a/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
new file mode 100644
index 000000000..aa39757ab
--- /dev/null
+++ b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
@@ -0,0 +1,149 @@
+---
+title: Reconcile Fly Machines
+description: flyApply reconciles typed Machines against the live Fly control plane with no state file — create, no-op re-apply, in-place update, and owned-only prune that leaves foreign machines alone. Runs offline against a persistent mudflaps emulator, or real Fly by dropping one override.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+The [`fly-reconcile`](https://github.com/INTENTIUS/chant/tree/main/examples/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`](/chant/tutorials/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
+
+`src/infra.ts` is one app and two machines — `web` and `worker`:
+
+```ts
+import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
+
+const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });
+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 }) });
+const worker = new Machine({ name: "worker", region: "iad", config: new MachineConfig({ image: "flyio/hellofly:latest", guest }) });
+
+export { app, 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.
+
+## The op
+
+`ops/fly.op.ts` is `Build → Apply` only — no emulator lifecycle, so state
+persists between runs:
+
+```ts
+import { Op, phase, build } from "@intentius/chant/op";
+import { flyApplyStep } from "@intentius/chant-lexicon-fly";
+
+export default Op({
+ name: "fly",
+ 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
+
+Boot mudflaps once, then run the op:
+
+```bash
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+
+cd examples/fly-reconcile
+npm install
+
+chant run fly
+```
+
+The first run creates everything:
+
+```
+created: app/fly-reconcile-demo
+created: machine/fly-reconcile-demo/web
+created: machine/fly-reconcile-demo/worker
+```
+
+
+
+## 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-demo
+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 —
+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:
+
+```bash
+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:
+
+```bash
+docker rm -f mudflaps
+```
+
+## Run it against real Fly
+
+Drop the endpoint override and give it a token — the same op, no code change:
+
+```ts
+phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpoint
+```
+
+```bash
+export FLY_API_TOKEN=...
+chant run fly
+```
+
+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 — the emulator is just an endpoint swap.
diff --git a/examples/examples.test.ts b/examples/examples.test.ts
index 97becec9e..8704ba845 100644
--- a/examples/examples.test.ts
+++ b/examples/examples.test.ts
@@ -22,6 +22,7 @@ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
import { k8sPlugin } from "@intentius/chant-lexicon-k8s/plugin";
import deployOp from "./getting-started/deploy.op";
import flyDeployOp from "./local-fly/ops/fly.op";
+import flyReconcileOp from "./fly-reconcile/ops/fly.op";
import agentTaskOp from "./sprites-agent-task/ops/agent-task.op";
import guardedTaskOp from "./sprites-agent-task/ops/guarded-task.op";
import managedAgentSessionOp from "./sprites-managed-agent-worker/ops/managed-agent-session.op";
@@ -237,6 +238,50 @@ describe("local-fly deploy Op (#744)", () => {
});
});
+// ── Fly reconcile — fly-reconcile (#868) ─────────────────────────────
+// A multi-machine stack whose op reconciles against a *running* mudflaps (no
+// boot/teardown), so re-runs show create → no-op → update → owned-only prune.
+// Build-validated here (the live reconcile is documented in the tutorial); a
+// separate block compile-validates the Build → Apply op.
+
+describeExample(
+ "fly-reconcile",
+ {
+ lexicon: "fly",
+ serializer: [flySerializer],
+ outputKey: "fly",
+ examplesDir: import.meta.dirname,
+ },
+ {
+ checks: (output) => {
+ const plan = JSON.parse(output) as Record<
+ string,
+ { endpoint: string; method: string; body: Record }
+ >;
+ const reqs = Object.values(plan);
+ const app = reqs.find((r) => r.endpoint === "/v1/apps");
+ expect(app!.body.app_name).toBe("fly-reconcile-demo");
+ // Two machines, each carrying the managed-by marker the prune reads back.
+ const machines = reqs.filter((r) => /\/v1\/apps\/[^/]+\/machines$/.test(r.endpoint));
+ expect(machines.map((m) => m.body.name).sort()).toEqual(["web", "worker"]);
+ for (const m of machines) expect(m.body.config.metadata["managed-by"]).toBe("chant");
+ },
+ },
+);
+
+describe("fly-reconcile op (#868)", () => {
+ test("compiles to a Build → Apply reconcile with prune on", () => {
+ const props = (flyReconcileOp as unknown as {
+ props: { name: string; phases: Array<{ name: string; steps: Array<{ fn: string; args?: Record }> }> };
+ }).props;
+ expect(props.name).toBe("fly");
+ expect(props.phases.map((p) => p.name)).toEqual(["Build", "Apply"]);
+ const apply = props.phases.find((p) => p.name === "Apply")!.steps[0];
+ expect(apply.fn).toBe("flyApply");
+ expect(apply.args?.prune).toBe(true);
+ });
+});
+
// ── Sprites agent task — sprites-agent-task (#762) ───────────────────
// Pure activity-sequence Ops (no build, no serialized plan). The live run is
// documented in the example README (against the in-process fake or real
diff --git a/examples/fly-reconcile/.gitignore b/examples/fly-reconcile/.gitignore
new file mode 100644
index 000000000..320c107b3
--- /dev/null
+++ b/examples/fly-reconcile/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+package-lock.json
diff --git a/examples/fly-reconcile/README.md b/examples/fly-reconcile/README.md
new file mode 100644
index 000000000..32cf07817
--- /dev/null
+++ b/examples/fly-reconcile/README.md
@@ -0,0 +1,62 @@
+# fly-reconcile
+
+One Fly **app + two machines**, reconciled against a **running** mudflaps by
+`flyApply`. Where [`local-fly`](../local-fly) boots and tears down the emulator
+each run, this example points at a persistent mudflaps so you can run it
+repeatedly and watch the reconcile: create → no-op → in-place update → owned-only
+prune. No `flyctl`, no state file.
+
+| Resource | Serializes to | Applied by |
+|----------|---------------|------------|
+| App + 2 Machines | flaps create bodies (JSON) | `flyApply` (prune on) |
+
+The op is `Build → Apply` only — it does not manage the emulator, so state
+survives between runs. `flyApply` GET-then-creates each resource, updates changed
+machines in place, and (with `prune: true`) destroys chant-owned machines no
+longer declared. A machine created directly in mudflaps, without the
+`managed-by: chant` marker, is left untouched.
+
+## Run it
+
+Boot mudflaps once, then run the op as many times as you like:
+
+```bash
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+
+npm install
+chant run fly # first run: creates app + web + worker
+chant run fly # second run: no-op — nothing changed
+```
+
+Then exercise the reconcile:
+
+- **Update in place.** Change `web`'s `config.image` in `src/infra.ts` and
+ re-run — `flyApply` leases the machine, updates it, and waits `started`.
+- **Owned-only prune.** Delete the `worker` machine and re-run — the prune
+ destroys it, because it carries the `managed-by: chant` marker.
+- **Foreign resources survive.** Create a machine directly in mudflaps
+ (`curl -X POST .../machines`) and re-run — the prune leaves it alone; it has no
+ ownership marker.
+
+Stop the emulator when done:
+
+```bash
+docker rm -f mudflaps
+```
+
+## Real Fly
+
+Drop the endpoint override and give it a token — the same op, no code change:
+
+```ts
+// ops/fly.op.ts
+phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpoint
+```
+
+```bash
+export FLY_API_TOKEN=...
+chant run fly
+```
+
+With no `endpoint`, `flyApply` falls through to `FLY_FLAPS_BASE_URL` (or Fly's
+`https://api.machines.dev`) and sends `Authorization: Bearer $FLY_API_TOKEN`.
diff --git a/examples/fly-reconcile/chant.config.ts b/examples/fly-reconcile/chant.config.ts
new file mode 100644
index 000000000..1d614b925
--- /dev/null
+++ b/examples/fly-reconcile/chant.config.ts
@@ -0,0 +1,6 @@
+import type { ChantConfig } from "@intentius/chant";
+
+// The fly lexicon (loads the flaps applier + mudflaps lifecycle activities:
+// flyApply / flapsUp / flapsDown) plus temporal (the base activities: chantBuild,
+// httpCheck). `chant run fly` resolves each Op step's `fn` against these.
+export default { lexicons: ["fly", "temporal"] } satisfies ChantConfig;
diff --git a/examples/fly-reconcile/ops/fly.op.ts b/examples/fly-reconcile/ops/fly.op.ts
new file mode 100644
index 000000000..ceb6b33e2
--- /dev/null
+++ b/examples/fly-reconcile/ops/fly.op.ts
@@ -0,0 +1,27 @@
+import { Op, phase, build } from "@intentius/chant/op";
+import { flyApplyStep } from "@intentius/chant-lexicon-fly";
+
+/**
+ * Reconcile the app + volume + two machines against a **running** mudflaps —
+ * `chant run fly`. Unlike the local-fly deploy Op, this one does not boot or tear
+ * down the emulator: you start mudflaps once and run this repeatedly, so the
+ * state persists across runs and the reconcile is observable (re-apply no-op,
+ * in-place update, owned-only prune).
+ *
+ * docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+ *
+ * `flyApply` GET-then-creates each resource, updates changed machines in place,
+ * and — with `prune: true` — destroys chant-owned machines no longer declared. A
+ * machine created directly in mudflaps (no `managed-by: chant` marker) is left
+ * untouched. To target real Fly, drop the `endpoint` override and set
+ * `FLY_FLAPS_BASE_URL` / `FLY_API_TOKEN`.
+ */
+export default Op({
+ name: "fly",
+ 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 })]),
+ ],
+});
diff --git a/examples/fly-reconcile/package.json b/examples/fly-reconcile/package.json
new file mode 100644
index 000000000..29de3a8a4
--- /dev/null
+++ b/examples/fly-reconcile/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "fly-reconcile-example",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:fly": "chant build src --lexicon fly -o dist/fly.json",
+ "build": "npm run build:fly",
+ "lint": "chant lint src"
+ },
+ "dependencies": {
+ "@intentius/chant": "*",
+ "@intentius/chant-lexicon-fly": "*",
+ "@intentius/chant-lexicon-temporal": "*"
+ },
+ "devDependencies": {
+ "typescript": "*"
+ }
+}
diff --git a/examples/fly-reconcile/src/infra.ts b/examples/fly-reconcile/src/infra.ts
new file mode 100644
index 000000000..cb1f05e26
--- /dev/null
+++ b/examples/fly-reconcile/src/infra.ts
@@ -0,0 +1,26 @@
+import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
+
+// A small multi-machine Fly stack: one app and two machines. It exists to show
+// the reconcile flyApply does — create → no-op re-apply → in-place update →
+// owned-only prune. The serializer stamps `managed-by: chant` into each
+// machine's `config.metadata`, the ownership marker the prune reads back.
+//
+// `org_slug` is required by the Machines API. `Fly.OrgSlug` resolves from
+// `FLY_ORG` at build time, defaulting to `personal` offline.
+const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });
+
+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 }),
+});
+
+const worker = new Machine({
+ name: "worker",
+ region: "iad",
+ config: new MachineConfig({ image: "flyio/hellofly:latest", guest }),
+});
+
+export { app, web, worker };
diff --git a/examples/fly-reconcile/tsconfig.json b/examples/fly-reconcile/tsconfig.json
new file mode 100644
index 000000000..d0db1f3c7
--- /dev/null
+++ b/examples/fly-reconcile/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": ["src", "ops"],
+ "exclude": ["node_modules", "dist"]
+}
From 1ab53664c20b0936512618273487e39d9cbd7944 Mon Sep 17 00:00:00 2001
From: lex00 <121451605+lex00@users.noreply.github.com>
Date: Mon, 13 Jul 2026 22:41:46 -0600
Subject: [PATCH 2/2] fly-reconcile: add the volume + mount now that mudflaps
0.4.1 persists mounts (#868, #59)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
mudflaps 0.4.1 (INTENTIUS/mudflaps#60) persists machine config.mounts, so a
mounting machine no-ops on re-apply. Enrich the reconcile example/tutorial:
- bump SPRITZER... no: bump MUDFLAPS_IMAGE to 0.4.1.
- add a Volume + a web mount back to fly-reconcile/src/infra.ts; the compile test
asserts the volume create body + web's mount.
- tutorial + README show the volume in create/no-op output and the image 0.4.1.
Verified end-to-end against ghcr.io/intentius/mudflaps:0.4.1: create (app +
volume + web + worker) → re-apply is a clean no-op for ALL resources, including
the mounted web (previously it re-applied forever). Docs build clean; compile
tests pass.
---
.../docs/tutorials/fly-machines-reconcile.mdx | 27 ++++++++++++++-----
examples/examples.test.ts | 6 +++++
examples/fly-reconcile/README.md | 16 +++++------
examples/fly-reconcile/src/infra.ts | 20 ++++++++++----
.../fly/src/op/activities/emulator-images.ts | 2 +-
5 files changed, 51 insertions(+), 20 deletions(-)
diff --git a/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
index aa39757ab..d2ac3445e 100644
--- a/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
+++ b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
@@ -17,22 +17,35 @@ the op repeatedly and watch the reconcile converge.
## The infra
-`src/infra.ts` is one app and two machines — `web` and `worker`:
+`src/infra.ts` is one app, a persistent volume, and two machines — `web` (which
+mounts the volume) and `worker`:
```ts
-import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
+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 }) });
+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, web, worker };
+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.
+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
@@ -59,7 +72,7 @@ export default Op({
Boot mudflaps once, then run the op:
```bash
-docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
cd examples/fly-reconcile
npm install
@@ -71,6 +84,7 @@ 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
```
@@ -90,6 +104,7 @@ The point is what re-running does. State lives on the platform, not in a file, s
```
unchanged: app/fly-reconcile-demo
+noop: volume/fly-reconcile-demo/data
noop: machine/fly-reconcile-demo/web
noop: machine/fly-reconcile-demo/worker
```
diff --git a/examples/examples.test.ts b/examples/examples.test.ts
index 8704ba845..abeebc1bf 100644
--- a/examples/examples.test.ts
+++ b/examples/examples.test.ts
@@ -261,10 +261,16 @@ describeExample(
const reqs = Object.values(plan);
const app = reqs.find((r) => r.endpoint === "/v1/apps");
expect(app!.body.app_name).toBe("fly-reconcile-demo");
+ // A volume, created before the machine that mounts it.
+ const volume = reqs.find((r) => /\/v1\/apps\/[^/]+\/volumes$/.test(r.endpoint));
+ expect(volume!.body.name).toBe("data");
// Two machines, each carrying the managed-by marker the prune reads back.
const machines = reqs.filter((r) => /\/v1\/apps\/[^/]+\/machines$/.test(r.endpoint));
expect(machines.map((m) => m.body.name).sort()).toEqual(["web", "worker"]);
for (const m of machines) expect(m.body.config.metadata["managed-by"]).toBe("chant");
+ // web mounts the volume by name.
+ const web = machines.find((m) => m.body.name === "web");
+ expect(web!.body.config.mounts).toEqual([{ volume: "data", path: "/data" }]);
},
},
);
diff --git a/examples/fly-reconcile/README.md b/examples/fly-reconcile/README.md
index 32cf07817..d68915df4 100644
--- a/examples/fly-reconcile/README.md
+++ b/examples/fly-reconcile/README.md
@@ -1,14 +1,14 @@
# fly-reconcile
-One Fly **app + two machines**, reconciled against a **running** mudflaps by
-`flyApply`. Where [`local-fly`](../local-fly) boots and tears down the emulator
-each run, this example points at a persistent mudflaps so you can run it
-repeatedly and watch the reconcile: create → no-op → in-place update → owned-only
-prune. No `flyctl`, no state file.
+One Fly **app + a volume + two machines** (`web` mounts the volume), reconciled
+against a **running** mudflaps by `flyApply`. Where [`local-fly`](../local-fly)
+boots and tears down the emulator each run, this example points at a persistent
+mudflaps so you can run it repeatedly and watch the reconcile: create → no-op →
+in-place update → owned-only prune. No `flyctl`, no state file.
| Resource | Serializes to | Applied by |
|----------|---------------|------------|
-| App + 2 Machines | flaps create bodies (JSON) | `flyApply` (prune on) |
+| App + Volume + 2 Machines | flaps create bodies (JSON) | `flyApply` (prune on) |
The op is `Build → Apply` only — it does not manage the emulator, so state
survives between runs. `flyApply` GET-then-creates each resource, updates changed
@@ -21,10 +21,10 @@ longer declared. A machine created directly in mudflaps, without the
Boot mudflaps once, then run the op as many times as you like:
```bash
-docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
npm install
-chant run fly # first run: creates app + web + worker
+chant run fly # first run: creates app + volume + web + worker
chant run fly # second run: no-op — nothing changed
```
diff --git a/examples/fly-reconcile/src/infra.ts b/examples/fly-reconcile/src/infra.ts
index cb1f05e26..d9c45d27e 100644
--- a/examples/fly-reconcile/src/infra.ts
+++ b/examples/fly-reconcile/src/infra.ts
@@ -1,7 +1,8 @@
-import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
+import { App, Machine, Volume, MachineConfig, MachineGuest, MachineMount, Fly } from "@intentius/chant-lexicon-fly";
-// A small multi-machine Fly stack: one app and two machines. It exists to show
-// the reconcile flyApply does — create → no-op re-apply → in-place update →
+// A small multi-resource Fly stack: one app, a persistent volume, and two
+// machines — `web` (which mounts the volume) and `worker`. It exists to show the
+// reconcile flyApply does — create → no-op re-apply → in-place update →
// owned-only prune. The serializer stamps `managed-by: chant` into each
// machine's `config.metadata`, the ownership marker the prune reads back.
//
@@ -9,12 +10,21 @@ import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant
// `FLY_ORG` at build time, defaulting to `personal` offline.
const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });
+// A persistent volume. flyApply creates it before any machine that mounts it —
+// the dependency order is on the wire (the volume is POSTed first).
+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 }),
+ config: new MachineConfig({
+ image: "flyio/hellofly:latest",
+ guest,
+ // The mount references the declared volume by name (FLY011 checks this).
+ mounts: [new MachineMount({ volume: "data", path: "/data" })],
+ }),
});
const worker = new Machine({
@@ -23,4 +33,4 @@ const worker = new Machine({
config: new MachineConfig({ image: "flyio/hellofly:latest", guest }),
});
-export { app, web, worker };
+export { app, data, web, worker };
diff --git a/lexicons/fly/src/op/activities/emulator-images.ts b/lexicons/fly/src/op/activities/emulator-images.ts
index 51bd345e0..de19b33ba 100644
--- a/lexicons/fly/src/op/activities/emulator-images.ts
+++ b/lexicons/fly/src/op/activities/emulator-images.ts
@@ -15,7 +15,7 @@
*/
/** Pinned mudflaps (Fly Machines / flaps emulator) image. */
-export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.0";
+export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.1";
/** Pinned spritzer (Fly Sprites emulator) image. */
export const SPRITZER_IMAGE = "ghcr.io/intentius/spritzer:0.4.0";