@intentius/chant-lexicon-fly 0.124.67 → 0.124.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/components/fly-release.d.ts +22 -3
  3. package/dist/components/fly-release.d.ts.map +1 -1
  4. package/dist/components/fly-release.js +86 -38
  5. package/dist/describe-resources.d.ts.map +1 -1
  6. package/dist/describe-resources.js +8 -3
  7. package/dist/integrity.json +6 -6
  8. package/dist/lint/audit-catalog.js +1 -1
  9. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +4 -0
  10. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -1
  11. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +10 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/okf/index.md +1 -1
  14. package/dist/okf/rules/FLY011.md +2 -2
  15. package/dist/okf/types/Machine.md +1 -1
  16. package/dist/okf/types/Volume.md +1 -1
  17. package/dist/op/activities/fly-apply.d.ts +91 -1
  18. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  19. package/dist/op/activities/fly-apply.js +165 -7
  20. package/dist/op/activities/machine-release.d.ts +105 -4
  21. package/dist/op/activities/machine-release.d.ts.map +1 -1
  22. package/dist/op/activities/machine-release.js +147 -8
  23. package/dist/op/activities/machines-fake.d.ts +30 -0
  24. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  25. package/dist/op/activities/machines-fake.js +60 -3
  26. package/dist/rules/fly011-mount-references-declared-volume.ts +9 -2
  27. package/dist/skills/chant-fly-ops.md +26 -2
  28. package/dist/skills/chant-fly-patterns.md +4 -2
  29. package/dist/skills/chant-fly.md +1 -1
  30. package/dist/volume-id.d.ts +9 -0
  31. package/dist/volume-id.d.ts.map +1 -0
  32. package/dist/volume-id.js +10 -0
  33. package/package.json +3 -3
  34. package/src/components/fly-release.test.ts +231 -0
  35. package/src/components/fly-release.ts +106 -39
  36. package/src/describe-resources.test.ts +27 -0
  37. package/src/describe-resources.ts +9 -2
  38. package/src/lint/audit-catalog.ts +1 -1
  39. package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +31 -0
  40. package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +9 -2
  41. package/src/op/activities/fly-apply.test.ts +170 -2
  42. package/src/op/activities/fly-apply.ts +229 -9
  43. package/src/op/activities/machine-release.test.ts +175 -1
  44. package/src/op/activities/machine-release.ts +222 -12
  45. package/src/op/activities/machines-fake.ts +89 -3
  46. package/src/op/activities/machines-local.test.ts +1 -0
  47. package/src/skills/chant-fly-ops.md +26 -2
  48. package/src/skills/chant-fly-patterns.md +4 -2
  49. package/src/skills/chant-fly.md +1 -1
  50. package/src/volume-id.ts +11 -0
@@ -491,3 +491,234 @@ describe("fly-release with a scheduled Machine (#4696)", () => {
491
491
  expect(s.fake.calls.some((c) => c.startsWith("POST"))).toBe(false);
492
492
  });
493
493
  });
494
+
495
+ /**
496
+ * #4719: two serving Machines behind Flycast with Fly checks, as Arugula's
497
+ * platform runs. The release rolls them one at a time on their checks,
498
+ * verifies both, and on a failure puts back each one it changed.
499
+ */
500
+ describe("fly-release over serving Machines with Fly checks (#4719)", () => {
501
+ const CHECKED = { services: [{ internal_port: 8080, protocol: "tcp", checks: [{ type: "http", port: 8080, path: "/platform.json", method: "GET" }] }] };
502
+ const machine = (name: string, extra: Record<string, unknown> = CHECKED) => ({
503
+ endpoint: "/v1/apps/shop/machines",
504
+ method: "POST",
505
+ body: { name, config: { image: "shop:declared", metadata: { "managed-by": "chant" }, ...extra } },
506
+ });
507
+ const writePlan = (plan: Record<string, unknown>) => writeFileSync(planPath, JSON.stringify(plan));
508
+ const CHECK = "servicecheck-00-http-8080";
509
+ const FAST = { intervalMs: 1, timeoutMs: 50 };
510
+
511
+ /**
512
+ * A fake whose Machines report one check: critical for the first few reads
513
+ * of each new instance, then passing, unless `healthy` says the Machine
514
+ * cannot pass at all. `events` holds each Machine create and update and
515
+ * the first passing read of each instance, in order.
516
+ */
517
+ function checkedFake(healthy: (m: { name: string; config: Record<string, unknown> }) => boolean = () => true, opts: { exec?: () => void } = {}) {
518
+ const events: string[] = [];
519
+ const reads = new Map<string, number>();
520
+ const fake = createMachinesFake({
521
+ exec: () => {
522
+ opts.exec?.();
523
+ return { exit_code: 0 };
524
+ },
525
+ checks: (_app, m) => {
526
+ if (!declares(m.config)) return undefined;
527
+ if (!healthy(m)) return [{ name: CHECK, status: "critical", output: "503 Service Unavailable" }];
528
+ const n = (reads.get(m.instance_id) ?? 0) + 1;
529
+ reads.set(m.instance_id, n);
530
+ if (n < 3) return [{ name: CHECK, status: "critical", output: "503 Service Unavailable" }];
531
+ if (n === 3) events.push(`passing ${m.name}`);
532
+ return [{ name: CHECK, status: "passing" }];
533
+ },
534
+ });
535
+ const http = fake.http;
536
+ fake.http = async (method, url, body, headers, signal) => {
537
+ const path = new URL(url).pathname.split("/");
538
+ // POST /v1/apps/<app>/machines/<id>: an update of that Machine.
539
+ if (method === "POST" && path.length === 6 && path[4] === "machines") {
540
+ const m = [...fake.machines.values()].flat().find((x) => x.id === path[5]);
541
+ events.push(`update ${m?.name}`);
542
+ }
543
+ // POST /v1/apps/<app>/machines: a new Machine.
544
+ if (method === "POST" && path.length === 5 && path[4] === "machines") events.push(`create ${(body as { name?: string }).name}`);
545
+ return http(method, url, body, headers, signal);
546
+ };
547
+ return { fake, events };
548
+ }
549
+ const declares = (config: Record<string, unknown>) => Array.isArray(config.services) && config.services.length > 0;
550
+
551
+ test("rolls the Machines one at a time: the second is updated only once the first passes its checks", async () => {
552
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
553
+ const { fake, events } = checkedFake();
554
+ const s = setup(fake);
555
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }));
556
+ events.length = 0;
557
+
558
+ await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: FAST }));
559
+ expect(events).toEqual(["update web", "passing web", "update web2", "passing web2"]);
560
+ expect(s.fake.machine("shop", "web")!.config.image).toBe("shop:2");
561
+ expect(s.fake.machine("shop", "web2")!.config.image).toBe("shop:2");
562
+ });
563
+
564
+ test("a Machine whose checks never pass times out, restores every Machine it updated in reverse order, and names the Machine and check", async () => {
565
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
566
+ // web2 cannot pass on the new image.
567
+ const { fake, events } = checkedFake((m) => !(m.name === "web2" && m.config.image === "shop:2"));
568
+ const s = setup(fake);
569
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }));
570
+ const web = structuredClone(fake.machine("shop", "web")!.config);
571
+ const web2 = structuredClone(fake.machine("shop", "web2")!.config);
572
+ events.length = 0;
573
+ s.lines.length = 0;
574
+
575
+ await expect(
576
+ s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: FAST })),
577
+ ).rejects.toThrow(/^machine shop\/web2 \(m[0-9a-f]+\) did not pass its Fly checks within 50ms: check servicecheck-00-http-8080 is critical \(503 Service Unavailable\)$/);
578
+ expect(fake.machine("shop", "web")!.config).toEqual(web);
579
+ expect(fake.machine("shop", "web2")!.config).toEqual(web2);
580
+ // Last updated, first restored; each restore waits for its Machine's checks.
581
+ expect(events).toEqual(["update web", "passing web", "update web2", "update web2", "passing web2", "update web", "passing web"]);
582
+ expect(s.lines.filter((l) => l.includes("restore"))).toEqual([
583
+ "fly-release: restored shop/web2 to its previous config",
584
+ "fly-release: restored shop/web to sha256:aaaaaaaaaaaa",
585
+ ]);
586
+ expect(await s.configs.read({ app: "shop", machine: "web", digest: "sha256:bbbbbbbbbbbb" })).toBeUndefined();
587
+ });
588
+
589
+ test("a serving Machine whose checks never pass is restored before the next Machine is touched", async () => {
590
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
591
+ const { fake, events } = checkedFake((m) => !(m.name === "web" && m.config.image === "shop:2"));
592
+ const s = setup(fake);
593
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }));
594
+ events.length = 0;
595
+ await expect(
596
+ s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: FAST })),
597
+ ).rejects.toThrow(/machine shop\/web .* check servicecheck-00-http-8080 is critical/);
598
+ expect(events).toEqual(["update web", "update web", "passing web"]);
599
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:1");
600
+ expect(fake.machine("shop", "web2")!.config.image).toBe("shop:1");
601
+ });
602
+
603
+ test("a check still passing from before the update does not count: the roll waits for one reported since", async () => {
604
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
605
+ const events: string[] = [];
606
+ const reads = new Map<string, number>();
607
+ // The first reads of each new instance carry the old instance's passing
608
+ // status, stamped before the update; then a fresh one arrives.
609
+ const fake = createMachinesFake({
610
+ checks: (_app, m) => {
611
+ const n = (reads.get(m.instance_id) ?? 0) + 1;
612
+ reads.set(m.instance_id, n);
613
+ if (n <= 3) return [{ name: CHECK, status: "passing", updated_at: "2000-01-01T00:00:00.000Z" }];
614
+ if (n === 4) events.push(`fresh ${m.name}`);
615
+ return [{ name: CHECK, status: "passing", updated_at: m.updated_at }];
616
+ },
617
+ });
618
+ const http = fake.http;
619
+ fake.http = async (method, url, body, headers, signal) => {
620
+ const path = new URL(url).pathname.split("/");
621
+ if (method === "POST" && path.length === 6 && path[4] === "machines") {
622
+ events.push(`update ${[...fake.machines.values()].flat().find((x) => x.id === path[5])?.name}`);
623
+ }
624
+ return http(method, url, body, headers, signal);
625
+ };
626
+ const s = setup(fake);
627
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }));
628
+ events.length = 0;
629
+ await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: FAST }));
630
+ expect(events).toEqual(["update web", "fresh web", "update web2", "fresh web2"]);
631
+
632
+ // A check that never reports again times out, saying so.
633
+ const stuck = createMachinesFake({ checks: () => [{ name: CHECK, status: "passing", updated_at: "2000-01-01T00:00:00.000Z" }] });
634
+ const t = setup(stuck);
635
+ await expect(t.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }))).rejects.toThrow(
636
+ /machine shop\/web .* did not pass its Fly checks within 50ms: check servicecheck-00-http-8080 has not reported since the update at .* \(last at 2000-01-01T00:00:00.000Z\)/,
637
+ );
638
+ });
639
+
640
+ test("without checks, the release waits for started only, as before #4719, even with checks failing", async () => {
641
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
642
+ const { fake, events } = checkedFake(() => false);
643
+ const s = setup(fake);
644
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" } }));
645
+ events.length = 0;
646
+ const out = await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" } }));
647
+ expect(out.digest).toBe("sha256:bbbbbbbbbbbb");
648
+ expect(events).toEqual(["update web", "update web2"]);
649
+ });
650
+
651
+ test("without checks, an apply that fails partway restores the Machines it had already updated", async () => {
652
+ writePlan({ shop: PLAN.shop, web: machine("web", {}), web2: machine("web2", {}) });
653
+ // web2 never starts on the new image.
654
+ const fake = createMachinesFake({ settle: (m) => (m.name === "web2" && m.config.image === "shop:2" ? "failed" : "started") });
655
+ const s = setup(fake);
656
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" } }));
657
+ s.lines.length = 0;
658
+ await expect(
659
+ s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, wait: { intervalMs: 0, deadlineMs: 50 } })),
660
+ ).rejects.toThrow(/machine shop\/m[0-9a-f]+ did not reach started within the deadline/);
661
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:1");
662
+ expect(fake.machine("shop", "web2")!.config.image).toBe("shop:1");
663
+ expect(s.lines.filter((l) => l.includes("restore"))).toEqual([
664
+ "fly-release: restored shop/web to sha256:aaaaaaaaaaaa",
665
+ "fly-release: restored shop/web2 to its previous config",
666
+ ]);
667
+ });
668
+
669
+ test("checks: true rolls with the defaults", { timeout: 20_000 }, async () => {
670
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
671
+ const { fake, events } = checkedFake();
672
+ const s = setup(fake);
673
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: true }));
674
+ expect(events).toEqual(["create web", "passing web", "create web2", "passing web2"]);
675
+ });
676
+
677
+ test("a Machine with no checks configured is done once started, as before", async () => {
678
+ writePlan({ shop: PLAN.shop, web: machine("web", {}), web2: machine("web2", {}) });
679
+ const { fake, events } = checkedFake();
680
+ const s = setup(fake);
681
+ // A zero timeout: a Machine that had to wait for a check would fail at once.
682
+ const zero = { intervalMs: 1, timeoutMs: 0 };
683
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: zero }));
684
+ const out = await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: zero }));
685
+ expect(out.digest).toBe("sha256:bbbbbbbbbbbb");
686
+ expect(events).toEqual(["create web", "create web2", "update web", "update web2"]);
687
+ expect(s.fake.machine("shop", "web2")!.config.image).toBe("shop:2");
688
+ });
689
+
690
+ test("verify covers every Machine the release updated: one whose checks fail after the roll fails the release, and both are restored", async () => {
691
+ writePlan({ shop: PLAN.shop, web: machine("web"), web2: machine("web2") });
692
+ // The migration takes web2's database away: its checks fail from then on.
693
+ let broken = false;
694
+ const { fake } = checkedFake((m) => !(broken && m.name === "web2" && m.config.image === "shop:2"), { exec: () => void (broken = true) });
695
+ const s = setup(fake);
696
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { machine: "web", image: "shop:1", images: { web2: "shop:1" }, checks: FAST }));
697
+ s.lines.length = 0;
698
+
699
+ await expect(
700
+ s.release.run(
701
+ CTX,
702
+ input("sha256:bbbbbbbbbbbb", { machine: "web", image: "shop:2", images: { web2: "shop:2" }, checks: FAST, migrations: [{ name: "1.sql", command: "migrate" }] }),
703
+ ),
704
+ ).rejects.toThrow(/machine shop\/web2 .* check servicecheck-00-http-8080 is critical/);
705
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:1");
706
+ expect(fake.machine("shop", "web2")!.config.image).toBe("shop:1");
707
+ expect(s.lines.filter((l) => l.includes("restore"))).toEqual([
708
+ "fly-release: restored shop/web2 to its previous config",
709
+ "fly-release: restored shop/web to sha256:aaaaaaaaaaaa",
710
+ ]);
711
+ });
712
+
713
+ test("verify reads the serving Machine's checks too", async () => {
714
+ writePlan({ shop: PLAN.shop, web: machine("web") });
715
+ let broken = false;
716
+ const { fake } = checkedFake((m) => !(broken && m.config.image === "shop:2"), { exec: () => void (broken = true) });
717
+ const s = setup(fake);
718
+ await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { image: "shop:1", checks: FAST }));
719
+ await expect(
720
+ s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { image: "shop:2", checks: FAST, migrations: [{ name: "1.sql", command: "migrate" }] })),
721
+ ).rejects.toThrow(/verify shop\/web: machine shop\/web \(m[0-9a-f]+\): check servicecheck-00-http-8080 is critical/);
722
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:1");
723
+ });
724
+ });
@@ -29,8 +29,19 @@
29
29
  * 3. verify: the Machine is started with this release, and its health
30
30
  * endpoint answers when a URL is given;
31
31
  * 4. on a failure after the Machine changed, restore: the config it replaced
32
- * goes back (or, on a first release, the Machine is stopped), and the step
33
- * fails.
32
+ * goes back (or, on a first release, the Machine is stopped), and so does
33
+ * the old config of each other Machine the release updated, and the step
34
+ * fails. A failure partway through the apply restores the Machines it had
35
+ * already updated the same way.
36
+ *
37
+ * Given `checks` (#4719), the apply rolls the plan's Machines one at a time on
38
+ * their Fly checks: each Machine it starts must pass its checks
39
+ * (`checks[].status` on the Machine, reported since its update) before the
40
+ * next is updated, within `checks.timeoutMs` (default five minutes). A Machine
41
+ * with no checks is done once started. Verify then also needs every Machine
42
+ * the release started to pass its checks, and a failure restores the Machines
43
+ * the release changed last changed first, each restore waiting for its
44
+ * Machine's checks. Without `checks`, only `started` is waited for.
34
45
  *
35
46
  * A scheduled Machine (`config.schedule`, #4696) is released without being
36
47
  * started, whether it is the one that serves or another Machine in the plan:
@@ -57,6 +68,7 @@ import type { Capability, DeployContext } from "@intentius/chant/components/capa
57
68
  import type { ReceiptStore, EffectReceiptRef } from "@intentius/chant/op/receipt-store";
58
69
  import {
59
70
  findMachine,
71
+ flyMachineChecks,
60
72
  flyMachineExec,
61
73
  flyMachineRelease,
62
74
  flyMachineRestart,
@@ -65,9 +77,12 @@ import {
65
77
  flyMachineVerify,
66
78
  readMachineRelease,
67
79
  releaseTarget,
80
+ MachineReleaseError,
68
81
  type FlyMachineReleaseResult,
82
+ type MachineChecksOpts,
69
83
  type MachineFile,
70
84
  type MachineRelease,
85
+ type ReleasedMachine,
71
86
  } from "../op/activities/machine-release";
72
87
  import { defaultFlyHttp, machineSchedule, parsePlan, resolveEndpoint, skipsLaunch, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
73
88
  import type { MachineConfigStore } from "../release-store";
@@ -174,6 +189,14 @@ export interface FlyReleaseInput {
174
189
  /** Migrations, in order. */
175
190
  migrations?: FlyMigration[];
176
191
  verify?: FlyVerify;
192
+ /**
193
+ * Roll the Machines one at a time on their Fly checks (#4719): each Machine
194
+ * the release starts must pass its checks before the next is updated, and
195
+ * verify reads every one's checks. `true` takes the defaults: five minutes
196
+ * per Machine, polled every second. A Machine with no checks configured is
197
+ * done once started. Without it, only `started` is waited for.
198
+ */
199
+ checks?: MachineChecksOpts | true;
177
200
  /** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
178
201
  endpoint?: string;
179
202
  wait?: WaitOpts;
@@ -297,15 +320,33 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
297
320
  const log = deps.log ?? ((line: string) => console.error(line));
298
321
  const httpFor = () => deps.http ?? defaultFlyHttp();
299
322
 
300
- /** On a failed release, put the plan's other Machines it updated back on their old configs. */
301
- async function restoreOthers(released: FlyMachineReleaseResult, input: FlyReleaseInput, http: FlyHttp): Promise<void> {
302
- for (const other of released.others) {
303
- if (other.action !== "updated" || !other.previous) continue;
323
+ /**
324
+ * On a failed release, put each Machine it changed back. The serving
325
+ * Machine gets its old config, or is stopped when it served nothing before;
326
+ * another Machine it updated gets its old config.
327
+ *
328
+ * Without `checks` this is the order and the calls fly-release made before
329
+ * #4719: the serving Machine first, then the others in plan order. With
330
+ * `checks`, the last Machine changed goes back first, and each restore
331
+ * waits for its Machine's checks before the next.
332
+ */
333
+ async function restoreApplied(applied: ReleasedMachine[], input: FlyReleaseInput, http: FlyHttp, rolling: MachineChecksOpts | undefined, servingWas?: string | null): Promise<void> {
334
+ const order = rolling ? [...applied].reverse() : [...applied.filter((m) => m.serving), ...applied.filter((m) => !m.serving)];
335
+ for (const m of order) {
336
+ if (m.serving ? rolling && m.action === "noop" : m.action !== "updated" || !m.previous) continue;
337
+ const where = { app: m.app, machine: m.name, endpoint: input.endpoint, wait: input.wait };
304
338
  try {
305
- await flyMachineRestore({ app: other.app, machine: other.name, config: other.previous, endpoint: input.endpoint, wait: input.wait }, undefined, http);
306
- log(`fly-release: restored ${other.app}/${other.name} to its previous config`);
339
+ if (m.previous) {
340
+ await flyMachineRestore({ ...where, config: m.previous, ...(rolling ? { checks: rolling } : {}) }, undefined, http);
341
+ const was = m.serving ? (servingWas === undefined ? readMachineRelease(m.previous.metadata)?.digest : (servingWas ?? undefined)) : undefined;
342
+ log(`fly-release: restored ${m.app}/${m.name} to ${was ?? "its previous config"}`);
343
+ } else {
344
+ await flyMachineStop(where, undefined, http);
345
+ log(`fly-release: nothing had been released on ${m.app}/${m.name}; stopped it`);
346
+ }
307
347
  } catch (restoreErr) {
308
- log(`fly-release: restore of ${other.app}/${other.name} failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
348
+ const why = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
349
+ log(m.serving && !rolling ? `fly-release: restore failed: ${why}` : `fly-release: restore of ${m.app}/${m.name} failed: ${why}`);
309
350
  }
310
351
  }
311
352
  }
@@ -359,21 +400,31 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
359
400
  ...(input.env ?? {}),
360
401
  };
361
402
 
362
- const released = await flyMachineRelease(
363
- {
364
- plan,
365
- machine: input.machine,
366
- release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
367
- image: input.image,
368
- ...(input.images ? { images: input.images } : {}),
369
- env,
370
- ...(source ? { files: source.files, cmd: source.cmd } : {}),
371
- endpoint: input.endpoint,
372
- wait: input.wait,
373
- },
374
- undefined,
375
- http,
376
- );
403
+ const rolling: MachineChecksOpts | undefined = input.checks === true ? {} : input.checks;
404
+ let released: FlyMachineReleaseResult;
405
+ try {
406
+ released = await flyMachineRelease(
407
+ {
408
+ plan,
409
+ machine: input.machine,
410
+ release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
411
+ image: input.image,
412
+ ...(input.images ? { images: input.images } : {}),
413
+ env,
414
+ ...(source ? { files: source.files, cmd: source.cmd } : {}),
415
+ endpoint: input.endpoint,
416
+ wait: input.wait,
417
+ ...(rolling ? { checks: rolling } : {}),
418
+ },
419
+ undefined,
420
+ http,
421
+ );
422
+ } catch (err) {
423
+ // A Machine failed to settle or to pass its checks partway through the
424
+ // roll: put back every Machine changed so far, then fail.
425
+ if (err instanceof MachineReleaseError) await restoreApplied(err.applied, input, http, rolling);
426
+ throw err;
427
+ }
377
428
  const where = { app: released.app, machine: released.machine.name, endpoint: input.endpoint };
378
429
  log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}${released.unlaunched ? ", stopped until its schedule starts it" : ""}`);
379
430
  for (const other of released.others) {
@@ -411,22 +462,38 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
411
462
  }
412
463
  if (migrations.some((m) => m.fired)) await flyMachineRestart({ ...where, wait: input.wait }, undefined, http);
413
464
  }
414
- await flyMachineVerify({ ...where, digest: input.digest, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
415
- } catch (err) {
416
- // Restore: the Machine changed, so put back what it served (or stop it
417
- // when it served nothing), then fail the step.
418
- try {
419
- if (released.previous) {
420
- await flyMachineRestore({ ...where, config: released.previous.config, wait: input.wait }, undefined, http);
421
- log(`fly-release: restored ${released.app}/${released.machine.name} to ${previousRelease?.digest ?? "its previous config"}`);
422
- } else {
423
- await flyMachineStop({ ...where, wait: input.wait }, undefined, http);
424
- log(`fly-release: nothing had been released on ${released.app}/${released.machine.name}; stopped it`);
425
- }
426
- } catch (restoreErr) {
427
- log(`fly-release: restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
465
+ const serving = released.applied.find((m) => m.serving);
466
+ await flyMachineVerify(
467
+ {
468
+ ...where,
469
+ digest: input.digest,
470
+ ...(input.verify ?? {}),
471
+ ...(rolling ? { checks: true, ...(serving?.updatedAt ? { checksSince: serving.updatedAt } : {}) } : {}),
472
+ },
473
+ undefined,
474
+ http,
475
+ deps.fetch,
476
+ );
477
+ // With checks, every other Machine the release started serves too: started, with its checks passing.
478
+ for (const m of rolling ? released.applied : []) {
479
+ if (m.serving || m.action === "noop" || m.unlaunched) continue;
480
+ await flyMachineChecks(
481
+ {
482
+ app: m.app,
483
+ machine: m.name,
484
+ endpoint: input.endpoint,
485
+ timeoutMs: input.verify?.timeoutMs ?? 120_000,
486
+ intervalMs: input.verify?.intervalMs,
487
+ ...(m.updatedAt ? { since: m.updatedAt } : {}),
488
+ },
489
+ undefined,
490
+ http,
491
+ );
428
492
  }
429
- await restoreOthers(released, input, http);
493
+ } catch (err) {
494
+ // Restore: the Machines changed, so put back what each served (or stop
495
+ // the serving one when it served nothing), then fail the step.
496
+ await restoreApplied(released.applied, input, http, rolling, previousRelease?.digest ?? null);
430
497
  throw err;
431
498
  }
432
499
 
@@ -240,6 +240,33 @@ describe("flyPlan: declared-vs-live change set (create/noop/delete/adopt)", () =
240
240
  expect(changeSet.entries.find((e) => e.name === "web")?.action).toBe("update");
241
241
  });
242
242
 
243
+ test("a mount named in the plan and mounted by id on the live Machine is a noop; another volume is an update (#4717)", async () => {
244
+ const planWith = (volume: string) =>
245
+ JSON.stringify({
246
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP } },
247
+ web: {
248
+ endpoint: `/v1/apps/${APP}/machines`,
249
+ method: "POST",
250
+ body: { name: "web", config: { image: "nginx:1", mounts: [{ volume, path: "/data" }], metadata: { ...OWNED_META } } },
251
+ },
252
+ });
253
+ const ents = entities([
254
+ ["app", "Fly::Machines::App"],
255
+ ["web", "Fly::Machines::Machine"],
256
+ ]);
257
+ const live = liveMachine("web", "nginx:1");
258
+ (live.config as Record<string, unknown>).mounts = [{ volume: "vol_r68lmz2doox9myd4", name: "data", path: "/data" }];
259
+ const actionFor = async (volume: string) =>
260
+ (
261
+ await flyPlan(
262
+ { environment: "prod", buildOutput: planWith(volume), entityNames: [...ents.keys()], entities: ents, endpoint: ENDPOINT },
263
+ fakeHttp([live]),
264
+ )
265
+ ).changeSet.entries.find((e) => e.name === "web")?.action;
266
+ expect(await actionFor("data")).toBe("noop");
267
+ expect(await actionFor("logs")).toBe("update");
268
+ });
269
+
243
270
  test("an unreadable plan makes every declared entity unobserved, not a create (#1089)", async () => {
244
271
  const { changeSet } = await flyPlan(
245
272
  { environment: "prod", buildOutput: "not json", entityNames: [...ENTS.keys()], entities: ENTS, endpoint: ENDPOINT },
@@ -52,6 +52,8 @@ import {
52
52
  resolveApp,
53
53
  isChantOwned,
54
54
  configEqual,
55
+ alignMountsWithLive,
56
+ volumeAliases,
55
57
  listMachines,
56
58
  listVolumes,
57
59
  listIps,
@@ -501,8 +503,11 @@ export async function flyPlan(
501
503
 
502
504
  // Declared config per entity, for the machine update/noop refinement.
503
505
  const declaredConfig = new Map<string, unknown>();
506
+ let aliases = new Map<string, string>();
504
507
  try {
505
- for (const [entityName, req] of Object.entries(parsePlan(options.buildOutput))) {
508
+ const plan = parsePlan(options.buildOutput);
509
+ aliases = volumeAliases(plan);
510
+ for (const [entityName, req] of Object.entries(plan)) {
506
511
  if (isMachineRequest(req)) declaredConfig.set(entityName, req.body.config);
507
512
  }
508
513
  } catch {
@@ -514,7 +519,9 @@ export async function flyPlan(
514
519
  const declaredCfg = declaredConfig.get(entry.name);
515
520
  if (declaredCfg === undefined) continue; // not a declared machine
516
521
  const liveCfg = (observedNow[entry.name]?.attributes as { config?: unknown } | undefined)?.config;
517
- if (!configEqual(declaredCfg, liveCfg)) {
522
+ // A declared mount names its volume; the live one has the id flyApply
523
+ // looked up, and the name beside it (#4717).
524
+ if (!configEqual(alignMountsWithLive(declaredCfg, liveCfg, aliases), liveCfg)) {
518
525
  entry.action = "update";
519
526
  }
520
527
  }
@@ -43,7 +43,7 @@ export const flyAuditCatalog: Record<string, RuleMeta> = {
43
43
  "merge-worthy",
44
44
  "correctness",
45
45
  "Machine mount references an undeclared volume",
46
- "Declare the Volume in the same stack, or point the mount at one that is.",
46
+ "Declare the Volume in the same stack, point the mount at one that is, or mount an existing volume by its id (vol_...).",
47
47
  ),
48
48
  FLY012: rule(
49
49
  "FLY012",
@@ -79,6 +79,37 @@ describe("FLY011: mount references a declared volume", () => {
79
79
  expect(diags).toHaveLength(0);
80
80
  });
81
81
 
82
+ test("passes a mount given a volume id, with no Volume declared (#4717)", () => {
83
+ const diags = fly011.check(
84
+ ctx(
85
+ ["app", new App({ name: "my-app" })],
86
+ [
87
+ "web",
88
+ new Machine({
89
+ config: new MachineConfig({
90
+ image: "nginx",
91
+ mounts: [new MachineMount({ volume: "vol_r68lmz2doox9myd4", name: "control_data", path: "/data" })],
92
+ }),
93
+ }),
94
+ ],
95
+ ),
96
+ );
97
+ expect(diags).toHaveLength(0);
98
+ });
99
+
100
+ test("a name that only starts like an id is still a name", () => {
101
+ const diags = fly011.check(
102
+ ctx([
103
+ "web",
104
+ new Machine({
105
+ config: new MachineConfig({ image: "nginx", mounts: [new MachineMount({ volume: "vol_", path: "/data" })] }),
106
+ }),
107
+ ]),
108
+ );
109
+ expect(diags).toHaveLength(1);
110
+ expect(diags[0].message).toContain("vol_...");
111
+ });
112
+
82
113
  test("does not flag a machine without mounts", () => {
83
114
  const diags = fly011.check(
84
115
  ctx(["web", new Machine({ config: new MachineConfig({ image: "nginx" }) })]),
@@ -6,17 +6,22 @@
6
6
  * apply time, so catch it at synth. This is a whole-stack check: it reads all
7
7
  * entities in the build (`ctx.entities`), so the mount and the Volume can be
8
8
  * declared in different files.
9
+ *
10
+ * A mount given a volume id (`vol_...`) names a volume that already exists on
11
+ * Fly, which the stack need not declare, so it passes (#4717). `flyApply`
12
+ * sends it as it is, and looks a name up to send its id.
9
13
  */
10
14
 
11
15
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
16
  import { readProps, entityTypeOf } from "./fly-helpers";
17
+ import { isVolumeId } from "../../volume-id";
13
18
 
14
19
  const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
15
20
  const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
16
21
 
17
22
  export const fly011: PostSynthCheck = {
18
23
  id: "FLY011",
19
- description: "A machine mount's volume must resolve to a Volume declared in the stack",
24
+ description: "A machine mount's volume must resolve to a Volume declared in the stack, or be a volume id (vol_...)",
20
25
 
21
26
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
27
  const diagnostics: PostSynthDiagnostic[] = [];
@@ -44,11 +49,13 @@ export const fly011: PostSynthCheck = {
44
49
  // reference to a Volume that exists in the stack by construction, so it
45
50
  // never dangles — skip it to avoid false positives.
46
51
  if (typeof volume !== "string" || volume.length === 0) continue;
52
+ // A volume id names a volume that exists outside the stack.
53
+ if (isVolumeId(volume)) continue;
47
54
  if (!declaredVolumes.has(volume)) {
48
55
  diagnostics.push({
49
56
  checkId: "FLY011",
50
57
  severity: "error",
51
- message: `Machine "${machineName}" mounts volume "${volume}", which is not declared as a Volume in the stack.`,
58
+ message: `Machine "${machineName}" mounts volume "${volume}", which is not declared as a Volume in the stack. Declare the Volume, or mount an existing volume by its id (vol_...).`,
52
59
  entity: machineName,
53
60
  lexicon: "fly",
54
61
  });