@intentius/chant-lexicon-fly 0.124.66 → 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.
- package/README.md +1 -1
- package/dist/components/fly-release.d.ts +22 -3
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +86 -38
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +8 -3
- package/dist/integrity.json +6 -6
- package/dist/lint/audit-catalog.js +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +4 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +10 -2
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/FLY011.md +2 -2
- package/dist/okf/types/Machine.md +1 -1
- package/dist/okf/types/Volume.md +1 -1
- package/dist/op/activities/fly-apply.d.ts +91 -1
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +165 -7
- package/dist/op/activities/machine-release.d.ts +105 -4
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +147 -8
- package/dist/op/activities/machines-fake.d.ts +30 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +60 -3
- package/dist/rules/fly011-mount-references-declared-volume.ts +9 -2
- package/dist/skills/chant-fly-ops.md +26 -2
- package/dist/skills/chant-fly-patterns.md +4 -2
- package/dist/skills/chant-fly.md +1 -1
- package/dist/volume-id.d.ts +9 -0
- package/dist/volume-id.d.ts.map +1 -0
- package/dist/volume-id.js +10 -0
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +231 -0
- package/src/components/fly-release.ts +106 -39
- package/src/describe-resources.test.ts +27 -0
- package/src/describe-resources.ts +9 -2
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +31 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +9 -2
- package/src/op/activities/fly-apply.test.ts +170 -2
- package/src/op/activities/fly-apply.ts +229 -9
- package/src/op/activities/machine-release.test.ts +175 -1
- package/src/op/activities/machine-release.ts +222 -12
- package/src/op/activities/machines-fake.ts +89 -3
- package/src/op/activities/machines-local.test.ts +1 -0
- package/src/skills/chant-fly-ops.md +26 -2
- package/src/skills/chant-fly-patterns.md +4 -2
- package/src/skills/chant-fly.md +1 -1
- 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
|
|
33
|
-
*
|
|
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
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
});
|