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";