@intentius/chant-lexicon-fly 0.124.63 → 0.124.65
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 -0
- package/dist/components/fly-release.d.ts +14 -0
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +38 -2
- package/dist/integrity.json +5 -4
- package/dist/lint/audit-catalog.d.ts +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +2 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/audit-lineage.js +3 -0
- package/dist/lint/post-synth/fly012-machine-schedule.d.ts +11 -0
- package/dist/lint/post-synth/fly012-machine-schedule.d.ts.map +1 -0
- package/dist/lint/post-synth/fly012-machine-schedule.js +37 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/FLY012.md +15 -0
- package/dist/okf/types/Machine.md +1 -0
- package/dist/op/activities/fly-apply.d.ts +25 -0
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +59 -3
- package/dist/op/activities/machine-release.d.ts +35 -3
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +70 -18
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +5 -2
- package/dist/rules/fly012-machine-schedule.ts +39 -0
- package/dist/skills/chant-fly-ops.md +47 -0
- package/dist/skills/chant-fly.md +3 -1
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +79 -0
- package/src/components/fly-release.ts +41 -2
- package/src/lint/audit-catalog.ts +8 -1
- package/src/lint/audit-lineage.ts +3 -0
- package/src/lint/post-synth/fly012-machine-schedule.test.ts +30 -0
- package/src/lint/post-synth/fly012-machine-schedule.ts +39 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/op/activities/fly-apply.test.ts +84 -0
- package/src/op/activities/fly-apply.ts +70 -3
- package/src/op/activities/machine-release.ts +86 -17
- package/src/op/activities/machines-fake.ts +5 -2
- package/src/skills/chant-fly-ops.md +47 -0
- package/src/skills/chant-fly.md +3 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLY012: a Machine's `schedule` is one the Machines API runs (#4696).
|
|
3
|
+
*
|
|
4
|
+
* Fly runs a scheduled Machine `hourly`, `daily`, `weekly` or `monthly`, and
|
|
5
|
+
* rejects anything else when the Machine is created or updated. A cron
|
|
6
|
+
* expression is the usual mistake, so name the four values at build time
|
|
7
|
+
* rather than at the first release.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { readProps, entityTypeOf } from "./fly-helpers";
|
|
12
|
+
|
|
13
|
+
const SCHEDULES = ["hourly", "daily", "weekly", "monthly"];
|
|
14
|
+
|
|
15
|
+
export const fly012: PostSynthCheck = {
|
|
16
|
+
id: "FLY012",
|
|
17
|
+
description: "Machine schedule must be hourly, daily, weekly or monthly",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
for (const [name, entity] of ctx.entities) {
|
|
22
|
+
if (entityTypeOf(entity) !== "Fly::Machines::Machine") continue;
|
|
23
|
+
const config = readProps(entity).config;
|
|
24
|
+
if (!config) continue;
|
|
25
|
+
const schedule = readProps(config).schedule;
|
|
26
|
+
if (schedule === undefined) continue;
|
|
27
|
+
if (typeof schedule !== "string" || !SCHEDULES.includes(schedule)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "FLY012",
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: `Machine "${name}" has schedule ${JSON.stringify(schedule)}; Fly runs a Machine ${SCHEDULES.join(", ")}`,
|
|
32
|
+
entity: name,
|
|
33
|
+
lexicon: "fly",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
3
3
|
import { fly010 } from "./fly010-machine-requires-image";
|
|
4
4
|
import { fly011 } from "./fly011-mount-references-declared-volume";
|
|
5
|
+
import { fly012 } from "./fly012-machine-schedule";
|
|
5
6
|
|
|
6
7
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
7
8
|
fly010,
|
|
8
9
|
fly011,
|
|
10
|
+
fly012,
|
|
9
11
|
];
|
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
acquireLease,
|
|
38
38
|
withLease,
|
|
39
39
|
waitForMachine,
|
|
40
|
+
skipsLaunch,
|
|
41
|
+
machineSchedule,
|
|
40
42
|
flyApply,
|
|
41
43
|
flyDelete,
|
|
42
44
|
toApplyResult,
|
|
@@ -655,6 +657,88 @@ describe("flyApply creates certificates at /certificates/acme (#3114)", () => {
|
|
|
655
657
|
});
|
|
656
658
|
});
|
|
657
659
|
|
|
660
|
+
/**
|
|
661
|
+
* #4696: a scheduled Machine is stopped between runs. The applier creates and
|
|
662
|
+
* updates it with `skip_launch`, so Fly doesn't start it, and waits for it to
|
|
663
|
+
* rest unstarted instead of waiting for `started`.
|
|
664
|
+
*/
|
|
665
|
+
describe("a scheduled Machine is applied without starting it (#4696)", () => {
|
|
666
|
+
const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
|
|
667
|
+
const dump = (image: string, extra: Record<string, unknown> = {}) => ({
|
|
668
|
+
endpoint: "/v1/apps/demo/machines",
|
|
669
|
+
method: "POST",
|
|
670
|
+
body: { name: "dump", config: { image, schedule: "hourly", metadata: { ...OWNED_META } }, ...extra },
|
|
671
|
+
});
|
|
672
|
+
const run = (fake: ReturnType<typeof createMachinesFake>, entries: Record<string, unknown>, prune = false) =>
|
|
673
|
+
flyApply({ plan: entries as never, endpoint: CTX.base, wait: NO_WAIT, prune }, undefined, fake.http);
|
|
674
|
+
/** The bodies of the machine creates and updates the fake saw. */
|
|
675
|
+
function recording() {
|
|
676
|
+
const fake = createMachinesFake();
|
|
677
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
678
|
+
const http: FlyHttp = (method, url, body, headers, signal) => {
|
|
679
|
+
if (method === "POST" && /\/machines(\/[^/]+)?$/.test(new URL(url).pathname)) bodies.push(body as Record<string, unknown>);
|
|
680
|
+
return fake.http(method, url, body, headers, signal);
|
|
681
|
+
};
|
|
682
|
+
return { fake: { ...fake, http }, raw: fake, bodies };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
test("which Machines skip launch: a schedule, unless skip_launch says otherwise", () => {
|
|
686
|
+
expect(skipsLaunch({ config: { image: "x" } })).toBe(false);
|
|
687
|
+
expect(skipsLaunch({ config: { image: "x", schedule: "daily" } })).toBe(true);
|
|
688
|
+
expect(skipsLaunch({ config: { image: "x", schedule: "daily" }, skip_launch: false })).toBe(false);
|
|
689
|
+
expect(skipsLaunch({ config: { image: "x" }, skip_launch: true })).toBe(true);
|
|
690
|
+
expect(machineSchedule({ schedule: "hourly" })).toBe("hourly");
|
|
691
|
+
expect(machineSchedule({ schedule: "" })).toBeUndefined();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("created with skip_launch and left created; updated with skip_launch and left stopped; a re-apply is a no-op", async () => {
|
|
695
|
+
const { fake, raw, bodies } = recording();
|
|
696
|
+
const first = await run(fake, { app, dump: dump("dump:1") });
|
|
697
|
+
expect(first.machines).toEqual([{ app: "demo", name: "dump", action: "created" }]);
|
|
698
|
+
expect(bodies[0]).toMatchObject({ name: "dump", skip_launch: true });
|
|
699
|
+
expect(raw.machine("demo", "dump")!.state).toBe("created");
|
|
700
|
+
expect(raw.calls.some((c) => c.endsWith("/wait"))).toBe(false);
|
|
701
|
+
|
|
702
|
+
raw.machine("demo", "dump")!.state = "stopped"; // it ran once, on its schedule
|
|
703
|
+
const second = await run(fake, { app, dump: dump("dump:2") });
|
|
704
|
+
expect(second.machines).toEqual([{ app: "demo", name: "dump", action: "updated" }]);
|
|
705
|
+
expect(bodies[1]).toMatchObject({ skip_launch: true });
|
|
706
|
+
expect(raw.machine("demo", "dump")).toMatchObject({ state: "stopped", config: { image: "dump:2", schedule: "hourly" } });
|
|
707
|
+
|
|
708
|
+
const third = await run(fake, { app, dump: dump("dump:2") });
|
|
709
|
+
expect(third.machines[0].action).toBe("noop");
|
|
710
|
+
expect(bodies).toHaveLength(2);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("skip_launch: false starts a scheduled Machine, as a plain create does", async () => {
|
|
714
|
+
const { fake, raw, bodies } = recording();
|
|
715
|
+
await run(fake, { app, dump: dump("dump:1", { skip_launch: false }) });
|
|
716
|
+
expect(bodies[0].skip_launch).toBe(false);
|
|
717
|
+
expect(raw.machine("demo", "dump")!.state).toBe("started");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("a Machine that never rests unstarted fails the apply at the deadline, naming its state", async () => {
|
|
721
|
+
// A flaps that starts every Machine, skip_launch or not.
|
|
722
|
+
const fake = createMachinesFake();
|
|
723
|
+
const http: FlyHttp = async (method, url, body, headers, signal) => {
|
|
724
|
+
const out = await fake.http(method, url, body, headers, signal);
|
|
725
|
+
for (const m of fake.machines.get("demo") ?? []) m.state = "started";
|
|
726
|
+
return out;
|
|
727
|
+
};
|
|
728
|
+
await expect(
|
|
729
|
+
flyApply({ plan: { app, dump: dump("dump:1") } as never, endpoint: CTX.base, wait: { intervalMs: 0, deadlineMs: 50 } }, undefined, http),
|
|
730
|
+
).rejects.toThrow(/did not settle unstarted .*last started/);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("prune keeps a declared scheduled Machine", async () => {
|
|
734
|
+
const fake = createMachinesFake();
|
|
735
|
+
await run(fake, { app, dump: dump("dump:1") });
|
|
736
|
+
const out = await run(fake, { app, dump: dump("dump:1") }, true);
|
|
737
|
+
expect(out.pruned).toEqual([]);
|
|
738
|
+
expect(fake.machine("demo", "dump")).toBeDefined();
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
658
742
|
/**
|
|
659
743
|
* #3224: Fly pages the certificate list (25 by default, 500 at most, with a
|
|
660
744
|
* `next_cursor`), and the in-memory flaps does the same. Every reader goes
|
|
@@ -426,6 +426,65 @@ export async function waitForMachine(
|
|
|
426
426
|
throw new Error(`machine ${app}/${id} did not reach ${state} within the deadline`);
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
// ── Scheduled Machines (#4696) ──────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The Machines API's `schedule` values: Fly runs the Machine once per
|
|
433
|
+
* interval, counted from when the Machine was created or last updated.
|
|
434
|
+
*/
|
|
435
|
+
export const MACHINE_SCHEDULES = ["hourly", "daily", "weekly", "monthly"] as const;
|
|
436
|
+
|
|
437
|
+
/** A Machine config's `schedule`, or undefined when it runs no schedule. Pure. */
|
|
438
|
+
export function machineSchedule(config: unknown): string | undefined {
|
|
439
|
+
const schedule = (config as { schedule?: unknown } | undefined)?.schedule;
|
|
440
|
+
return typeof schedule === "string" && schedule ? schedule : undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* True when creating or updating the Machine leaves it unstarted: its
|
|
445
|
+
* request sets `skip_launch: true`, or its config has a `schedule` and the
|
|
446
|
+
* request doesn't set `skip_launch: false`. A scheduled Machine is stopped
|
|
447
|
+
* between runs, so an apply moves it to its new config and leaves the next
|
|
448
|
+
* run to Fly. Pure.
|
|
449
|
+
*/
|
|
450
|
+
export function skipsLaunch(body: Record<string, unknown>): boolean {
|
|
451
|
+
if (typeof body.skip_launch === "boolean") return body.skip_launch;
|
|
452
|
+
return machineSchedule(body.config) !== undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The states a Machine rests in when nothing started it. */
|
|
456
|
+
const UNLAUNCHED_STATES = new Set(["created", "stopped", "suspended"]);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Poll the Machine until it rests unstarted (`created`, `stopped` or
|
|
460
|
+
* `suspended`) at `version` (its new `instance_id`, when known): what a
|
|
461
|
+
* create or update with `skip_launch` settles in. flaps' `/wait` can't wait
|
|
462
|
+
* for this, since a Machine created without launching stays `created`.
|
|
463
|
+
*/
|
|
464
|
+
export async function waitForUnlaunched(
|
|
465
|
+
ctx: ApplyCtx,
|
|
466
|
+
app: string,
|
|
467
|
+
id: string,
|
|
468
|
+
version: string,
|
|
469
|
+
http: FlyHttp,
|
|
470
|
+
signal?: AbortSignal,
|
|
471
|
+
opts: WaitOpts = {},
|
|
472
|
+
): Promise<string> {
|
|
473
|
+
const interval = opts.intervalMs ?? 1_000;
|
|
474
|
+
const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
|
|
475
|
+
let seen = "";
|
|
476
|
+
while (Date.now() < deadline) {
|
|
477
|
+
if (signal?.aborted) throw new Error("waitForUnlaunched aborted");
|
|
478
|
+
const res = await http("GET", machineUrl(ctx.base, app, id), undefined, undefined, signal);
|
|
479
|
+
if (res.status >= 300) throw new Error(`machine ${app}/${id} lookup failed (${res.status}): ${res.text}`);
|
|
480
|
+
const m = parseMachine(res.text);
|
|
481
|
+
seen = m.state;
|
|
482
|
+
if (UNLAUNCHED_STATES.has(m.state) && (!version || m.instance_id === version)) return m.state;
|
|
483
|
+
await sleep(interval, signal);
|
|
484
|
+
}
|
|
485
|
+
throw new Error(`machine ${app}/${id} did not settle unstarted within the deadline (last ${seen || "unknown"})`);
|
|
486
|
+
}
|
|
487
|
+
|
|
429
488
|
// ── Resource operations ────────────────────────────────────────────────────────
|
|
430
489
|
|
|
431
490
|
/** Create the app if absent (idempotent). A create-time conflict means it exists. */
|
|
@@ -466,6 +525,9 @@ export async function listMachines(
|
|
|
466
525
|
* machine's new `instance_id`. Updates go through a lease. The declared machine
|
|
467
526
|
* is identified by name (falling back to the plan entity name), so re-applying
|
|
468
527
|
* an unchanged machine is a no-op.
|
|
528
|
+
*
|
|
529
|
+
* A Machine that {@link skipsLaunch} (a scheduled one, #4696) is created and
|
|
530
|
+
* updated with `skip_launch: true`, and the wait is for it to rest unstarted.
|
|
469
531
|
*/
|
|
470
532
|
export async function applyMachine(
|
|
471
533
|
ctx: ApplyCtx,
|
|
@@ -477,8 +539,13 @@ export async function applyMachine(
|
|
|
477
539
|
opts: WaitOpts = {},
|
|
478
540
|
): Promise<{ action: "created" | "updated" | "noop"; id: string; name: string }> {
|
|
479
541
|
const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
480
|
-
const
|
|
542
|
+
const unlaunched = skipsLaunch(req.body);
|
|
543
|
+
const body = { ...req.body, name, ...(unlaunched ? { skip_launch: true } : {}) };
|
|
481
544
|
const desiredConfig = req.body.config;
|
|
545
|
+
const settle = (m: FlapsMachine) =>
|
|
546
|
+
unlaunched
|
|
547
|
+
? waitForUnlaunched(ctx, app, m.id, m.instance_id, http, signal, opts)
|
|
548
|
+
: waitForMachine(ctx, app, m.id, m.instance_id, http, signal, opts);
|
|
482
549
|
|
|
483
550
|
const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
|
|
484
551
|
|
|
@@ -486,7 +553,7 @@ export async function applyMachine(
|
|
|
486
553
|
const res = await http("POST", machinesUrl(ctx.base, app), body, undefined, signal);
|
|
487
554
|
if (res.status >= 300) throw new Error(`machine ${app}/${name} create failed (${res.status}): ${res.text}`);
|
|
488
555
|
const m = parseMachine(res.text);
|
|
489
|
-
await
|
|
556
|
+
await settle(m);
|
|
490
557
|
return { action: "created", id: m.id, name };
|
|
491
558
|
}
|
|
492
559
|
|
|
@@ -499,7 +566,7 @@ export async function applyMachine(
|
|
|
499
566
|
);
|
|
500
567
|
if (res.status >= 300) throw new Error(`machine ${app}/${name} update failed (${res.status}): ${res.text}`);
|
|
501
568
|
const m = parseMachine(res.text);
|
|
502
|
-
await
|
|
569
|
+
await settle(m);
|
|
503
570
|
return { action: "updated", id: m.id, name };
|
|
504
571
|
}
|
|
505
572
|
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
*
|
|
17
17
|
* - {@link flyMachineRelease}: upload and start. Apply the declared plan with
|
|
18
18
|
* the Machine serving the release (its image or env overridden when the
|
|
19
|
-
* release says so), and report the release and config it replaced.
|
|
19
|
+
* release says so), and report the release and config it replaced. A
|
|
20
|
+
* scheduled Machine (`config.schedule`, #4696) is updated without starting:
|
|
21
|
+
* it runs on its schedule. The plan's other Machines can take images of
|
|
22
|
+
* their own (`images`).
|
|
20
23
|
* - {@link flyMachineExec}: run a command inside the Machine (a migration).
|
|
21
24
|
* - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
|
|
22
25
|
* wait for the Machine to settle.
|
|
@@ -40,7 +43,9 @@ import {
|
|
|
40
43
|
machineAppSegment,
|
|
41
44
|
resolveApp,
|
|
42
45
|
resolveEndpoint,
|
|
46
|
+
skipsLaunch,
|
|
43
47
|
waitForMachine,
|
|
48
|
+
waitForUnlaunched,
|
|
44
49
|
withLease,
|
|
45
50
|
LEASE_NONCE_HEADER,
|
|
46
51
|
type ApplyCtx,
|
|
@@ -67,20 +72,26 @@ export interface ReleaseTarget {
|
|
|
67
72
|
request: FlapsRequest;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
* names (an entity name or a Machine name). Pure.
|
|
73
|
-
*/
|
|
74
|
-
export function releaseTarget(plan: FlyPlan, machine?: string): ReleaseTarget {
|
|
75
|
+
/** Every Machine the plan declares, with its App resolved. Pure. */
|
|
76
|
+
export function planMachines(plan: FlyPlan): ReleaseTarget[] {
|
|
75
77
|
const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
|
|
76
78
|
const soleApp = appNames.length === 1 ? appNames[0] : undefined;
|
|
77
|
-
|
|
79
|
+
return Object.entries(plan)
|
|
78
80
|
.filter(([, req]) => isMachineRequest(req))
|
|
79
81
|
.map(([entity, request]) => ({
|
|
82
|
+
app: resolveApp(machineAppSegment(request.endpoint), soleApp),
|
|
80
83
|
entity,
|
|
81
84
|
request,
|
|
82
85
|
name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
|
|
83
86
|
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The Machine a release targets: the plan's one Machine, or the one `machine`
|
|
91
|
+
* names (an entity name or a Machine name). Pure.
|
|
92
|
+
*/
|
|
93
|
+
export function releaseTarget(plan: FlyPlan, machine?: string): ReleaseTarget {
|
|
94
|
+
const machines = planMachines(plan);
|
|
84
95
|
const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
|
|
85
96
|
if (picked.length !== 1) {
|
|
86
97
|
throw new Error(
|
|
@@ -89,8 +100,7 @@ export function releaseTarget(plan: FlyPlan, machine?: string): ReleaseTarget {
|
|
|
89
100
|
: `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`,
|
|
90
101
|
);
|
|
91
102
|
}
|
|
92
|
-
|
|
93
|
-
return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
|
|
103
|
+
return picked[0];
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
const TERMINAL = new Set(["destroyed", "destroying"]);
|
|
@@ -147,6 +157,12 @@ export interface FlyMachineReleaseArgs extends FlapsTarget {
|
|
|
147
157
|
files?: MachineFile[];
|
|
148
158
|
/** The command the Machine starts, in place of the image's: an argv, or a string run with `sh -c`. */
|
|
149
159
|
cmd?: string[] | string;
|
|
160
|
+
/**
|
|
161
|
+
* Images for the plan's other Machines, by entity or Machine name (#4696):
|
|
162
|
+
* a scheduled Machine beside the one that serves, say, built from the same
|
|
163
|
+
* commit. A name that is no other Machine in the plan is refused.
|
|
164
|
+
*/
|
|
165
|
+
images?: Record<string, string>;
|
|
150
166
|
wait?: WaitOpts;
|
|
151
167
|
}
|
|
152
168
|
|
|
@@ -170,6 +186,13 @@ export interface FlyMachineReleaseResult {
|
|
|
170
186
|
config: MachineConfig;
|
|
171
187
|
/** What the Machine served before, when it existed: its release (if it named one) and its config. */
|
|
172
188
|
previous: { release?: MachineRelease; config: MachineConfig } | null;
|
|
189
|
+
/** True when the Machine was left unstarted: a scheduled Machine, which runs on its schedule (#4696). */
|
|
190
|
+
unlaunched: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* The plan's other Machines: what the apply did to each, whether it was
|
|
193
|
+
* left unstarted, and the config it had before (null when it was created).
|
|
194
|
+
*/
|
|
195
|
+
others: Array<{ name: string; app: string; action: "created" | "updated" | "noop"; unlaunched: boolean; previous: MachineConfig | null }>;
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
/**
|
|
@@ -178,6 +201,11 @@ export interface FlyMachineReleaseResult {
|
|
|
178
201
|
* under a lease, so the release serves from here on. A Machine that already
|
|
179
202
|
* serves this release keeps the `previousDigest` it shipped with, so running a
|
|
180
203
|
* release twice never makes it its own predecessor.
|
|
204
|
+
*
|
|
205
|
+
* A scheduled Machine (its config has a `schedule`, #4696), the target or
|
|
206
|
+
* another in the plan, is created or updated with `skip_launch` and left
|
|
207
|
+
* unstarted: Fly starts it on its schedule. Fly counts the interval from the
|
|
208
|
+
* update, so each release pushes the next run back by up to one interval.
|
|
181
209
|
*/
|
|
182
210
|
export async function flyMachineRelease(
|
|
183
211
|
args: FlyMachineReleaseArgs,
|
|
@@ -211,20 +239,53 @@ export async function flyMachineRelease(
|
|
|
211
239
|
release,
|
|
212
240
|
);
|
|
213
241
|
const request: FlapsRequest = { ...target.request, body: { ...target.request.body, config } };
|
|
242
|
+
|
|
243
|
+
// The plan's other Machines, each with its own image when `images` names it.
|
|
244
|
+
const others = planMachines(plan).filter((m) => m.entity !== target.entity);
|
|
245
|
+
const images = { ...(args.images ?? {}) };
|
|
246
|
+
const overrides: FlyPlan = {};
|
|
247
|
+
for (const m of others) {
|
|
248
|
+
const image = images[m.entity] ?? images[m.name];
|
|
249
|
+
delete images[m.entity];
|
|
250
|
+
delete images[m.name];
|
|
251
|
+
if (image === undefined) continue;
|
|
252
|
+
const declaredOther = (m.request.body.config ?? {}) as MachineConfig;
|
|
253
|
+
overrides[m.entity] = { ...m.request, body: { ...m.request.body, config: { ...declaredOther, image } } };
|
|
254
|
+
}
|
|
255
|
+
const unknown = Object.keys(images);
|
|
256
|
+
if (unknown.length > 0) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`images names ${unknown.map((n) => `"${n}"`).join(", ")}, which the fly plan declares as no Machine other than the one that serves (others: ${others.map((m) => m.entity).join(", ") || "none"}); the serving Machine's image is \`image\``,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const before = new Map<string, MachineConfig | null>();
|
|
262
|
+
for (const m of others) {
|
|
263
|
+
const found = await findMachine(ctx, m.app, m.name, http, signal).catch(() => undefined);
|
|
264
|
+
before.set(m.entity, found ? ((found.config as MachineConfig | undefined) ?? {}) : null);
|
|
265
|
+
}
|
|
266
|
+
|
|
214
267
|
const applied = await flyApply(
|
|
215
|
-
{ plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait },
|
|
268
|
+
{ plan: { ...plan, ...overrides, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait },
|
|
216
269
|
signal,
|
|
217
270
|
http,
|
|
218
271
|
);
|
|
219
|
-
const
|
|
272
|
+
const actionOf = (app: string, name: string) => applied.machines.find((m) => m.app === app && m.name === name)?.action ?? "noop";
|
|
220
273
|
const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
|
|
221
274
|
return {
|
|
222
275
|
app: target.app,
|
|
223
276
|
machine: { id: now.id, name: target.name },
|
|
224
|
-
action,
|
|
277
|
+
action: actionOf(target.app, target.name),
|
|
225
278
|
release,
|
|
226
279
|
config,
|
|
227
280
|
previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
|
|
281
|
+
unlaunched: skipsLaunch(request.body),
|
|
282
|
+
others: others.map((m) => ({
|
|
283
|
+
name: m.name,
|
|
284
|
+
app: m.app,
|
|
285
|
+
action: actionOf(m.app, m.name),
|
|
286
|
+
unlaunched: skipsLaunch((overrides[m.entity] ?? m.request).body),
|
|
287
|
+
previous: before.get(m.entity) ?? null,
|
|
288
|
+
})),
|
|
228
289
|
};
|
|
229
290
|
}
|
|
230
291
|
|
|
@@ -343,8 +404,9 @@ export interface FlyMachineVerifyArgs extends FlapsTarget {
|
|
|
343
404
|
}
|
|
344
405
|
|
|
345
406
|
/**
|
|
346
|
-
* The Machine is started and its metadata names release `digest`.
|
|
347
|
-
*
|
|
407
|
+
* The Machine is started and its metadata names release `digest`. A scheduled
|
|
408
|
+
* Machine (#4696) may also be resting unstarted (`created`, `stopped` or
|
|
409
|
+
* `suspended`) between runs. Given a `url`, its health endpoint answers 2xx too, and when that answer is JSON
|
|
348
410
|
* naming a `revision` or `digest`, it is this release's (the commit or the
|
|
349
411
|
* digest). Retries until `timeoutMs`, then throws the last reason.
|
|
350
412
|
*/
|
|
@@ -362,7 +424,9 @@ export async function flyMachineVerify(
|
|
|
362
424
|
if (signal?.aborted) throw new Error("flyMachineVerify aborted");
|
|
363
425
|
try {
|
|
364
426
|
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
365
|
-
|
|
427
|
+
const scheduled = skipsLaunch({ config: m.config });
|
|
428
|
+
const resting = scheduled && ["created", "stopped", "suspended"].includes(m.state);
|
|
429
|
+
if (m.state !== "started" && !resting) throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
|
|
366
430
|
const release = readMachineRelease(m.config?.metadata);
|
|
367
431
|
if (release?.digest !== args.digest) {
|
|
368
432
|
throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
|
|
@@ -405,6 +469,8 @@ export interface FlyMachineRestoreArgs extends FlapsTarget {
|
|
|
405
469
|
* Put a recorded Machine config back: update the Machine to exactly `config`
|
|
406
470
|
* under a lease and wait for it to be started. Used on a failed release (the
|
|
407
471
|
* config it replaced) and by a rollback (the config an earlier release applied).
|
|
472
|
+
* A scheduled config (#4696) goes back with `skip_launch`, and the wait is for
|
|
473
|
+
* the Machine to rest unstarted.
|
|
408
474
|
*/
|
|
409
475
|
export async function flyMachineRestore(
|
|
410
476
|
args: FlyMachineRestoreArgs,
|
|
@@ -413,8 +479,10 @@ export async function flyMachineRestore(
|
|
|
413
479
|
): Promise<{ id: string; release?: MachineRelease }> {
|
|
414
480
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
415
481
|
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
482
|
+
const unlaunched = skipsLaunch({ config: args.config });
|
|
483
|
+
const body = { name: m.name, config: args.config, ...(unlaunched ? { skip_launch: true } : {}) };
|
|
416
484
|
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
|
|
417
|
-
http("POST", machinePath(ctx, args.app, m.id),
|
|
485
|
+
http("POST", machinePath(ctx, args.app, m.id), body, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
418
486
|
);
|
|
419
487
|
if (res.status >= 300) throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
420
488
|
let instance = "";
|
|
@@ -423,7 +491,8 @@ export async function flyMachineRestore(
|
|
|
423
491
|
} catch {
|
|
424
492
|
instance = "";
|
|
425
493
|
}
|
|
426
|
-
await
|
|
494
|
+
if (unlaunched) await waitForUnlaunched(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
495
|
+
else await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
427
496
|
const release = readMachineRelease(args.config.metadata);
|
|
428
497
|
return { id: m.id, ...(release ? { release } : {}) };
|
|
429
498
|
}
|
|
@@ -125,7 +125,8 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
|
|
|
125
125
|
instance_id: nextId("I"),
|
|
126
126
|
config: structuredClone((b.config ?? {}) as FakeMachine["config"]),
|
|
127
127
|
};
|
|
128
|
-
|
|
128
|
+
// skip_launch: the Machine is created and not booted (#4696).
|
|
129
|
+
m.state = b.skip_launch === true ? "created" : settle(m);
|
|
129
130
|
machines.set(app, [...list(app), m]);
|
|
130
131
|
return json(200, m);
|
|
131
132
|
}
|
|
@@ -137,7 +138,9 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
|
|
|
137
138
|
if (method === "POST") {
|
|
138
139
|
m.config = structuredClone((b.config ?? {}) as FakeMachine["config"]);
|
|
139
140
|
m.instance_id = nextId("I");
|
|
140
|
-
|
|
141
|
+
// skip_launch: the new config isn't started (#4696). A Machine that
|
|
142
|
+
// never ran stays created; one that ran is stopped.
|
|
143
|
+
m.state = b.skip_launch === true ? (m.state === "created" ? "created" : "stopped") : settle(m);
|
|
141
144
|
return json(200, m);
|
|
142
145
|
}
|
|
143
146
|
if (method === "DELETE") {
|
|
@@ -31,6 +31,8 @@ If a machine never reaches `started`:
|
|
|
31
31
|
| Wait fails with a non-408 status | flaps rejected the machine (bad config the build check did not catch, or an org-side limit) | Read the error body; fix the config and re-apply |
|
|
32
32
|
| Wait times out at the deadline | The machine cannot reach `started` in time | Inspect the machine directly on the org, then re-apply once the cause is fixed |
|
|
33
33
|
|
|
34
|
+
A scheduled machine is the exception: it isn't started, so the applier polls the machine until it rests `created` or `stopped` at its new config instead (see "A scheduled Machine" below).
|
|
35
|
+
|
|
34
36
|
## Lease conflicts
|
|
35
37
|
|
|
36
38
|
Mutating an existing machine (update or destroy) is gated behind a Machines API lease. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and releases the lease afterward. A leaked lease expires on its own TTL, so release is best-effort.
|
|
@@ -161,3 +163,48 @@ export default Op({
|
|
|
161
163
|
```
|
|
162
164
|
|
|
163
165
|
A rollback's own ledger record is not counted as a release, so running the Op again plans the same rollback, leaves the Machine as it is and records nothing. `to: "sha256:..."` on `releaseRollbackPlan` picks another release. Migrations are not undone.
|
|
166
|
+
|
|
167
|
+
### A scheduled Machine
|
|
168
|
+
|
|
169
|
+
A Machine with a `schedule` in its config runs once an hour, day, week or month (`hourly`, `daily`, `weekly`, `monthly`; FLY012 refuses anything else, a cron expression included) and is stopped between runs. A backup or a dump is the usual case. Declare it beside the Machine that serves, with no process group of its own:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { App, Machine, MachineConfig } from "@intentius/chant-lexicon-fly";
|
|
173
|
+
|
|
174
|
+
export const platform = new App({ name: "platform", org_slug: "acme" });
|
|
175
|
+
|
|
176
|
+
export const web = new Machine({
|
|
177
|
+
name: "web",
|
|
178
|
+
region: "iad",
|
|
179
|
+
config: new MachineConfig({ image: "registry.fly.io/platform:declared" }),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const dump = new Machine({
|
|
183
|
+
name: "platform-dump",
|
|
184
|
+
region: "iad",
|
|
185
|
+
config: new MachineConfig({
|
|
186
|
+
image: "registry.fly.io/platform:dump-declared",
|
|
187
|
+
schedule: "hourly",
|
|
188
|
+
metadata: { role: "platform-dump" },
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`flyApply` creates and updates a scheduled Machine with `skip_launch: true`, so an apply or a release moves it to its new config and image and never starts it. The next run is Fly's. Set `skip_launch: false` on the Machine to have each apply start it as well. Fly counts the interval from the Machine's last update, so each apply that changes it pushes the next run back by up to one interval: under `daily`, releases less than a day apart can keep it from ever running. `hourly`, with the job skipping a run when its last output is recent enough, keeps a daily job daily.
|
|
194
|
+
|
|
195
|
+
`fly-release` releases the Machine named by `machine` and applies the rest of the plan with it. `images` gives the plan's other Machines images of their own, by entity or Machine name; a name that is no other Machine in the plan is refused before anything changes:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
{
|
|
199
|
+
kind: "fly-release",
|
|
200
|
+
plan: "dist/fly.json",
|
|
201
|
+
machine: "web",
|
|
202
|
+
digest: "@Publish.digest",
|
|
203
|
+
image: "@Publish.uri",
|
|
204
|
+
images: { dump: "@PublishDump.uri" },
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The serving Machine is started and verified as usual. The scheduled one is updated and left stopped. If the release fails after the Machines changed, the serving Machine goes back to what it served and the scheduled Machine goes back to its old config, still stopped. Only the serving Machine's configs are recorded, so `fly-rollback` and the step's saga rollback put back the serving Machine alone.
|
|
209
|
+
|
|
210
|
+
A scheduled Machine can be the one released, too (`machine: "platform-dump"`, or the plan's only Machine). It's updated and left stopped, its metadata names the release, and verify accepts it stopped, or started in the middle of a run, as long as it names this release. Such a release takes no `migrations` and no `verify.url`, since nothing runs in the Machine between runs to exec into or answer a health check; either is refused before the Machine changes. A restore or rollback puts its old config back without starting it.
|
package/src/skills/chant-fly.md
CHANGED
|
@@ -73,6 +73,7 @@ Build synthesizes the flaps plan and runs the lint rules before anything reaches
|
|
|
73
73
|
| FLY004 | A secret value written inline in machine config |
|
|
74
74
|
| FLY010 | A machine config with no `image` |
|
|
75
75
|
| FLY011 | A machine mount that references a `Volume` not declared in the stack (checked across files) |
|
|
76
|
+
| FLY012 | A machine `schedule` other than `hourly`, `daily`, `weekly` or `monthly` |
|
|
76
77
|
|
|
77
78
|
Fix every reported violation before applying. Secret values belong in a `Secret` or a reference, never inline (FLY004).
|
|
78
79
|
|
|
@@ -81,6 +82,7 @@ Fix every reported violation before applying. Secret values belong in a `Secret`
|
|
|
81
82
|
`flyApply` reads the serialized plan and applies it to flaps in dependency order: app, then volumes, then machines, then IPs, certificates, and secrets. Per machine it does a GET-then-create or update, then waits.
|
|
82
83
|
|
|
83
84
|
- Create or update: POST the machine, then poll `GET .../wait` until it reaches `started` at its new `instance_id`. flaps caps its own long-poll at 60 seconds and answers 408 on expiry, so the client re-polls until its deadline (default 300 seconds).
|
|
85
|
+
- A scheduled machine (`config.schedule`) is created and updated with `skip_launch: true`, and the wait is for it to rest unstarted (`created` or `stopped`) at its new `instance_id`. Fly starts it on its schedule. `skip_launch: false` on the Machine starts it like any other.
|
|
84
86
|
- No-op on no drift: a re-apply of an unchanged machine (config structurally equal to live) does nothing.
|
|
85
87
|
- Leases: mutating an existing machine goes through the Machines API lease protocol. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and re-acquires and retries once if the lease was lost. Concurrent operators stay out of each other's way.
|
|
86
88
|
|
|
@@ -112,7 +114,7 @@ export default flyDeploy({ app: "local-fly-demo" });
|
|
|
112
114
|
|
|
113
115
|
| Command | Description |
|
|
114
116
|
|---------|-------------|
|
|
115
|
-
| `chant build src/` | Synthesize the flaps plan and run lint (FLY001/FLY002/FLY004/FLY010/FLY011) |
|
|
117
|
+
| `chant build src/` | Synthesize the flaps plan and run lint (FLY001/FLY002/FLY004/FLY010/FLY011/FLY012) |
|
|
116
118
|
| `chant run fly` | Run the deploy Op (boot, build, apply, verify, teardown) |
|
|
117
119
|
| `FLY_FLAPS_BASE_URL=...` | Point the same code at mudflaps or a real Fly org |
|
|
118
120
|
| `FLY_API_TOKEN=...` | Bearer token for a real Fly org (mudflaps ignores it) |
|