@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
@@ -19,12 +19,16 @@
19
19
  * release says so), and report the release and config it replaced. A
20
20
  * scheduled Machine (`config.schedule`, #4696) is updated without starting:
21
21
  * it runs on its schedule. The plan's other Machines can take images of
22
- * their own (`images`).
22
+ * their own (`images`). Given `checks` (#4719), each Machine it starts must
23
+ * pass its Fly checks before the next is updated, so a group of serving
24
+ * Machines rolls one at a time.
23
25
  * - {@link flyMachineExec}: run a command inside the Machine (a migration).
24
26
  * - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
25
27
  * wait for the Machine to settle.
26
28
  * - {@link flyMachineVerify}: the Machine is started with this release in its
27
- * metadata, and, given a URL, its health endpoint answers.
29
+ * metadata, and, given a URL, its health endpoint answers. With `checks`,
30
+ * its Fly checks pass too.
31
+ * - {@link flyMachineChecks}: the Machine is started and its Fly checks pass.
28
32
  * - {@link flyMachineRestore}: put a recorded Machine config back (restore on
29
33
  * a failed release, and rollback).
30
34
  *
@@ -32,7 +36,7 @@
32
36
  * (`FLY_FLAPS_BASE_URL`, `FLY_API_TOKEN`), so the same code reaches mudflaps
33
37
  * or a real Fly org, and an injectable `http` for tests (./machines-fake.ts).
34
38
  */
35
- import { appNameFromRequest, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, skipsLaunch, waitForMachine, waitForUnlaunched, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
39
+ import { appNameFromRequest, configEqual, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, resolveMounts, skipsLaunch, waitForMachine, waitForUnlaunched, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
36
40
  import { parsePlan } from "./fly-apply.js";
37
41
  import { readFileSync } from "node:fs";
38
42
  import { readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
@@ -77,6 +81,66 @@ async function mustFindMachine(ctx, app, name, http, signal) {
77
81
  return m;
78
82
  }
79
83
  const machinePath = (ctx, app, id) => `${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
84
+ /** Five minutes, as `fly deploy`'s `--wait-timeout`. */
85
+ export const DEFAULT_CHECKS_TIMEOUT_MS = 300000;
86
+ /**
87
+ * True when a Machine config declares a Fly check: one under `checks`, or
88
+ * one on any of its `services`. Pure.
89
+ */
90
+ export function declaresChecks(config) {
91
+ const c = (config ?? {});
92
+ if (c.checks && typeof c.checks === "object" && Object.keys(c.checks).length > 0)
93
+ return true;
94
+ const services = Array.isArray(c.services) ? c.services : [];
95
+ return services.some((svc) => Array.isArray(svc?.checks) && svc.checks.length > 0);
96
+ }
97
+ /**
98
+ * Why a Machine's Fly checks are not all passing, or undefined when they are
99
+ * or it has none. A Machine whose config declares checks that have not
100
+ * reported yet is not passing.
101
+ *
102
+ * Given `since`, the Machine's own `updated_at` as the call that changed it
103
+ * answered it, a check counts as passing only when its `updated_at` is at or
104
+ * after that time: a `passing` left over from before the change is stale.
105
+ * Both times come from flaps, so the local clock plays no part. A check
106
+ * that reports no `updated_at` cannot be told stale and counts on its status.
107
+ * Pure.
108
+ */
109
+ export function checksPending(m, since) {
110
+ const checks = m.checks ?? [];
111
+ if (checks.length === 0)
112
+ return declaresChecks(m.config) ? "no check has reported yet" : undefined;
113
+ const from = since ? Date.parse(since) : NaN;
114
+ const stale = (c) => {
115
+ const at = c.updated_at ? Date.parse(c.updated_at) : NaN;
116
+ return !Number.isNaN(from) && !Number.isNaN(at) && at < from;
117
+ };
118
+ const failing = checks.filter((c) => c.status !== "passing" || stale(c));
119
+ if (failing.length === 0)
120
+ return undefined;
121
+ return failing
122
+ .map((c) => {
123
+ if (c.status === "passing")
124
+ return `check ${c.name} has not reported since the update at ${since} (last at ${c.updated_at})`;
125
+ const output = (c.output ?? "").trim().slice(0, 200);
126
+ return `check ${c.name} is ${c.status || "unknown"}${output ? ` (${output})` : ""}`;
127
+ })
128
+ .join("; ");
129
+ }
130
+ /**
131
+ * A release that failed partway through its apply (#4719): a Machine that
132
+ * did not settle or whose checks did not pass. `applied` holds the Machines
133
+ * it applied before the failure, the failing one included when its update
134
+ * went through, so the caller can put each back.
135
+ */
136
+ export class MachineReleaseError extends Error {
137
+ constructor(reason, applied) {
138
+ super(reason instanceof Error ? reason.message : String(reason));
139
+ this.name = "MachineReleaseError";
140
+ this.applied = applied;
141
+ this.reason = reason;
142
+ }
143
+ }
80
144
  /**
81
145
  * Upload and start: apply the declared plan (the App, its Volumes, IPs,
82
146
  * Secrets) with the Machine serving `release`. The Machine is updated in place
@@ -88,6 +152,11 @@ const machinePath = (ctx, app, id) => `${ctx.base}/v1/apps/${encodeURIComponent(
88
152
  * another in the plan, is created or updated with `skip_launch` and left
89
153
  * unstarted: Fly starts it on its schedule. Fly counts the interval from the
90
154
  * update, so each release pushes the next run back by up to one interval.
155
+ *
156
+ * Given `checks` (#4719), each Machine the apply starts must pass its Fly
157
+ * checks, reported since its update, before the next one is updated. With or
158
+ * without them, a failure partway through the apply throws a
159
+ * {@link MachineReleaseError} naming the Machines it changed so far.
91
160
  */
92
161
  export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args.token)) {
93
162
  const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
@@ -134,7 +203,36 @@ export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args
134
203
  const found = await findMachine(ctx, m.app, m.name, http, signal).catch(() => undefined);
135
204
  before.set(m.entity, found ? (found.config ?? {}) : null);
136
205
  }
137
- const applied = await flyApply({ plan: { ...plan, ...overrides, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait }, signal, http);
206
+ const rolled = [];
207
+ const afterMachine = async (m) => {
208
+ const serving = m.entity === target.entity;
209
+ const previous = serving ? (live ? (liveConfig ?? {}) : null) : (before.get(m.entity) ?? null);
210
+ rolled.push({ app: m.app, name: m.name, id: m.id, action: m.action, unlaunched: m.unlaunched, previous, serving, ...(m.updatedAt ? { updatedAt: m.updatedAt } : {}) });
211
+ if (args.checks && m.action !== "noop" && !m.unlaunched) {
212
+ await waitForChecks(ctx, m.app, m.name, http, signal, args.checks, m.updatedAt);
213
+ }
214
+ };
215
+ let applied;
216
+ try {
217
+ applied = await flyApply({ plan: { ...plan, ...overrides, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait, afterMachine }, signal, http);
218
+ }
219
+ catch (err) {
220
+ // A Machine whose update went through but which never settled is not in
221
+ // `rolled` yet: find it by its config, so it is put back too.
222
+ const reached = new Set(rolled.map((m) => `${m.app}/${m.name}`));
223
+ for (const m of planMachines(plan)) {
224
+ if (reached.has(`${m.app}/${m.name}`))
225
+ continue;
226
+ const serving = m.entity === target.entity;
227
+ const was = serving ? (live ? (liveConfig ?? {}) : null) : (before.get(m.entity) ?? null);
228
+ const now = await findMachine(ctx, m.app, m.name, http, signal).catch(() => undefined);
229
+ if (!now || (was && configEqual(now.config, was)))
230
+ continue;
231
+ const unlaunched = skipsLaunch((serving ? request : (overrides[m.entity] ?? m.request)).body);
232
+ rolled.push({ app: m.app, name: m.name, id: now.id, action: was ? "updated" : "created", unlaunched, previous: was, serving });
233
+ }
234
+ throw new MachineReleaseError(err, rolled);
235
+ }
138
236
  const actionOf = (app, name) => applied.machines.find((m) => m.app === app && m.name === name)?.action ?? "noop";
139
237
  const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
140
238
  return {
@@ -152,6 +250,7 @@ export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args
152
250
  unlaunched: skipsLaunch((overrides[m.entity] ?? m.request).body),
153
251
  previous: before.get(m.entity) ?? null,
154
252
  })),
253
+ applied: rolled,
155
254
  };
156
255
  }
157
256
  function requirePlanPath(planPath) {
@@ -230,6 +329,11 @@ export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.
230
329
  if (release?.digest !== args.digest) {
231
330
  throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
232
331
  }
332
+ if (args.checks && m.state === "started") {
333
+ const pending = checksPending(m, args.checksSince);
334
+ if (pending)
335
+ throw new Error(`machine ${args.app}/${args.machine} (${m.id}): ${pending}`);
336
+ }
233
337
  if (args.url) {
234
338
  const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
235
339
  const res = await fetchImpl(url, { signal: AbortSignal.timeout(5000) });
@@ -262,27 +366,62 @@ export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.
262
366
  * under a lease and wait for it to be started. Used on a failed release (the
263
367
  * config it replaced) and by a rollback (the config an earlier release applied).
264
368
  * A scheduled config (#4696) goes back with `skip_launch`, and the wait is for
265
- * the Machine to rest unstarted.
369
+ * the Machine to rest unstarted. A mount that names a volume goes back with
370
+ * the volume's id, looked up as `flyApply` looks it up (#4717); a mount with an
371
+ * id (a config read from the live Machine) goes back as it is.
266
372
  */
267
373
  export async function flyMachineRestore(args, signal, http = defaultFlyHttp(args.token)) {
268
374
  const ctx = { base: resolveEndpoint(args) };
269
375
  const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
270
376
  const unlaunched = skipsLaunch({ config: args.config });
271
- const body = { name: m.name, config: args.config, ...(unlaunched ? { skip_launch: true } : {}) };
377
+ const config = await resolveMounts(ctx, args.app, args.config, { machineId: m.id, region: m.region }, http, signal);
378
+ const body = { name: m.name, config, ...(unlaunched ? { skip_launch: true } : {}) };
272
379
  const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", machinePath(ctx, args.app, m.id), body, { [LEASE_NONCE_HEADER]: nonce }, signal));
273
380
  if (res.status >= 300)
274
381
  throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
275
382
  let instance = "";
383
+ let updatedAt;
276
384
  try {
277
- instance = JSON.parse(res.text).instance_id ?? "";
385
+ const answered = JSON.parse(res.text);
386
+ instance = answered.instance_id ?? "";
387
+ updatedAt = answered.updated_at;
278
388
  }
279
389
  catch {
280
390
  instance = "";
281
391
  }
282
392
  if (unlaunched)
283
393
  await waitForUnlaunched(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
284
- else
394
+ else {
285
395
  await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
396
+ if (args.checks)
397
+ await waitForChecks(ctx, args.app, args.machine, http, signal, args.checks, updatedAt);
398
+ }
286
399
  const release = readMachineRelease(args.config.metadata);
287
400
  return { id: m.id, ...(release ? { release } : {}) };
288
401
  }
402
+ /**
403
+ * Wait until the Machine is started and every one of its Fly checks
404
+ * (`checks[].status` on the Machine) is `passing`, and, given `since`, has
405
+ * reported since then. A Machine with no checks is done once started. Throws after `timeoutMs`, naming the Machine and each
406
+ * check that is not passing.
407
+ */
408
+ export async function flyMachineChecks(args, signal, http = defaultFlyHttp(args.token)) {
409
+ return waitForChecks({ base: resolveEndpoint(args) }, args.app, args.machine, http, signal, args, args.since);
410
+ }
411
+ async function waitForChecks(ctx, app, name, http, signal, opts, since) {
412
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CHECKS_TIMEOUT_MS;
413
+ const interval = opts.intervalMs ?? 1000;
414
+ const deadline = Date.now() + timeoutMs;
415
+ for (;;) {
416
+ if (signal?.aborted)
417
+ throw new Error(`waiting for the checks of ${app}/${name} aborted`);
418
+ const m = await mustFindMachine(ctx, app, name, http, signal);
419
+ const why = m.state === "started" ? checksPending(m, since) : `it is ${m.state}`;
420
+ if (!why)
421
+ return { id: m.id };
422
+ if (Date.now() + interval > deadline) {
423
+ throw new Error(`machine ${app}/${name} (${m.id}) did not pass its Fly checks within ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`}: ${why}`);
424
+ }
425
+ await new Promise((r) => setTimeout(r, interval));
426
+ }
427
+ }
@@ -22,6 +22,18 @@ export interface FakeMachine {
22
22
  config: Record<string, unknown> & {
23
23
  metadata?: Record<string, string>;
24
24
  };
25
+ /** When the machine was last created or updated (RFC 3339), as flaps reports it. */
26
+ updated_at?: string;
27
+ }
28
+ /** One volume the fake holds, as flaps lists it. */
29
+ export interface FakeVolume {
30
+ id: string;
31
+ name: string;
32
+ region?: string;
33
+ size_gb?: number;
34
+ state: string;
35
+ /** The Machine whose config mounts it by id; null when none does. */
36
+ attached_machine_id: string | null;
25
37
  }
26
38
  /** What an `exec` answers: the Machines API's exec envelope. */
27
39
  export interface FakeExecResult {
@@ -33,6 +45,8 @@ export interface MachinesFake {
33
45
  http: FlyHttp;
34
46
  apps: Set<string>;
35
47
  machines: Map<string, FakeMachine[]>;
48
+ /** Each app's volumes, in creation order, with ids `vol_...` (#4717). */
49
+ volumes: Map<string, FakeVolume[]>;
36
50
  /** Each app's certificate hostnames, in creation order. */
37
51
  certs: Map<string, string[]>;
38
52
  /** Every exec, in order: the app, the machine id and the command. */
@@ -51,6 +65,22 @@ export interface MachinesFakeOptions {
51
65
  exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult | Promise<FakeExecResult>;
52
66
  /** The state a created or updated machine settles in. Default `started`. */
53
67
  settle?: (machine: FakeMachine) => string;
68
+ /** The clock a create or update stamps `updated_at` with. Default: the wall clock. */
69
+ now?: () => Date;
70
+ /**
71
+ * The Fly checks a machine reports each time it is read (#4719), as the
72
+ * Machines API's `checks`: asked on every GET of the machine or its list.
73
+ * Default: none.
74
+ */
75
+ checks?: (app: string, machine: FakeMachine) => FakeCheck[] | undefined;
76
+ }
77
+ /** One Fly check's status on a machine, as the Machines API reports it. */
78
+ export interface FakeCheck {
79
+ name: string;
80
+ status: "passing" | "warning" | "critical";
81
+ output?: string;
82
+ /** When the check last reported (RFC 3339). */
83
+ updated_at?: string;
54
84
  }
55
85
  /** Fly's certificate-list page size: 25 when no `limit` is given, 500 at most. */
56
86
  export declare const CERT_PAGE_DEFAULT = 25;
@@ -1 +1 @@
1
- {"version":3,"file":"machines-fake.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machines-fake.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,kCAAkC;AAClC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;CACzE;AAED,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACrC,2DAA2D;IAC3D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7D,wDAAwD;IACxD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,sDAAsD;IACtD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gIAAgI;IAChI,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1G,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;CAC3C;AAID,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,aAAa,MAAM,CAAC;AAWjC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAkKlF"}
1
+ {"version":3,"file":"machines-fake.d.ts","sourceRoot":"","sources":["../../../src/op/activities/machines-fake.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,kCAAkC;AAClC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;IACxE,oFAAoF;IACpF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,oDAAoD;AACpD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,gEAAgE;AAChE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACrC,yEAAyE;IACzE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC;IACnC,2DAA2D;IAC3D,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAC7D,wDAAwD;IACxD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,sDAAsD;IACtD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7D;AAED,MAAM,WAAW,mBAAmB;IAClC,gIAAgI;IAChI,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1G,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IAC1C,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,SAAS,EAAE,GAAG,SAAS,CAAC;CACzE;AAED,2EAA2E;AAC3E,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,aAAa,MAAM,CAAC;AAWjC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAwNlF"}
@@ -32,12 +32,30 @@ const nextId = (prefix) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
32
32
  export function createMachinesFake(options = {}) {
33
33
  const apps = new Set();
34
34
  const machines = new Map();
35
+ const volumes = new Map();
35
36
  const certs = new Map();
36
37
  const execs = [];
37
38
  const calls = [];
38
39
  const settle = options.settle ?? (() => "started");
40
+ const stamp = () => (options.now?.() ?? new Date()).toISOString();
39
41
  const list = (app) => machines.get(app) ?? [];
40
42
  const find = (app, id) => list(app).find((m) => m.id === id);
43
+ const withChecks = (app, m) => {
44
+ const checks = options.checks?.(app, m);
45
+ return checks ? { ...m, checks } : m;
46
+ };
47
+ const vols = (app) => volumes.get(app) ?? [];
48
+ // A volume is attached to the Machine whose config mounts its id, as Fly
49
+ // attaches it. A mount naming no volume id attaches nothing.
50
+ const attach = (app, m, id) => {
51
+ const mounted = new Set((m?.config.mounts ?? []).map((x) => x.volume).filter((v) => typeof v === "string"));
52
+ for (const v of vols(app)) {
53
+ if (v.attached_machine_id === id && !mounted.has(v.id))
54
+ v.attached_machine_id = null;
55
+ if (m && mounted.has(v.id))
56
+ v.attached_machine_id = id;
57
+ }
58
+ };
41
59
  const http = async (method, url, body) => {
42
60
  const { pathname, searchParams } = new URL(url);
43
61
  calls.push(`${method} ${pathname}`);
@@ -63,6 +81,7 @@ export function createMachinesFake(options = {}) {
63
81
  if (method === "DELETE") {
64
82
  apps.delete(app);
65
83
  machines.delete(app);
84
+ volumes.delete(app);
66
85
  certs.delete(app);
67
86
  return json(202, undefined);
68
87
  }
@@ -75,7 +94,7 @@ export function createMachinesFake(options = {}) {
75
94
  const action = seg[5];
76
95
  if (!id) {
77
96
  if (method === "GET")
78
- return json(200, list(app));
97
+ return json(200, list(app).map((m) => withChecks(app, m)));
79
98
  if (method === "POST") {
80
99
  const m = {
81
100
  id: nextId("m"),
@@ -83,10 +102,12 @@ export function createMachinesFake(options = {}) {
83
102
  state: "created",
84
103
  instance_id: nextId("I"),
85
104
  config: structuredClone((b.config ?? {})),
105
+ updated_at: stamp(),
86
106
  };
87
107
  // skip_launch: the Machine is created and not booted (#4696).
88
108
  m.state = b.skip_launch === true ? "created" : settle(m);
89
109
  machines.set(app, [...list(app), m]);
110
+ attach(app, m, m.id);
90
111
  return json(200, m);
91
112
  }
92
113
  }
@@ -95,10 +116,12 @@ export function createMachinesFake(options = {}) {
95
116
  return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
96
117
  if (!action) {
97
118
  if (method === "GET")
98
- return json(200, m);
119
+ return json(200, withChecks(app, m));
99
120
  if (method === "POST") {
100
121
  m.config = structuredClone((b.config ?? {}));
101
122
  m.instance_id = nextId("I");
123
+ m.updated_at = stamp();
124
+ attach(app, m, m.id);
102
125
  // skip_launch: the new config isn't started (#4696). A Machine that
103
126
  // never ran stays created; one that ran is stopped.
104
127
  m.state = b.skip_launch === true ? (m.state === "created" ? "created" : "stopped") : settle(m);
@@ -113,6 +136,7 @@ export function createMachinesFake(options = {}) {
113
136
  });
114
137
  }
115
138
  machines.set(app, list(app).filter((x) => x !== m));
139
+ attach(app, undefined, m.id);
116
140
  return json(200, { ok: true });
117
141
  }
118
142
  }
@@ -144,10 +168,42 @@ export function createMachinesFake(options = {}) {
144
168
  return method === "GET" ? json(200, { ips: [] }) : json(200, {});
145
169
  if (kind === "certificates")
146
170
  return certificates(app, method, seg.slice(4), b, searchParams);
147
- if (kind === "volumes" || kind === "secrets")
171
+ if (kind === "volumes")
172
+ return volumeCall(app, method, seg[4], b);
173
+ if (kind === "secrets")
148
174
  return method === "GET" ? json(200, []) : json(200, {});
149
175
  return json(404, { error: "not found" });
150
176
  };
177
+ // Volumes (#4717): created with a `vol_` id, listed with their region and
178
+ // the Machine they are attached to, read and deleted by id.
179
+ function volumeCall(app, method, id, b) {
180
+ if (!id) {
181
+ if (method === "GET")
182
+ return json(200, vols(app));
183
+ if (method === "POST") {
184
+ const v = {
185
+ id: nextId("vol_"),
186
+ name: String(b.name ?? ""),
187
+ ...(typeof b.region === "string" ? { region: b.region } : {}),
188
+ ...(typeof b.size_gb === "number" ? { size_gb: b.size_gb } : {}),
189
+ state: "created",
190
+ attached_machine_id: null,
191
+ };
192
+ volumes.set(app, [...vols(app), v]);
193
+ return json(200, v);
194
+ }
195
+ }
196
+ const v = vols(app).find((x) => x.id === id);
197
+ if (!v)
198
+ return json(404, { error: "volume not found" });
199
+ if (method === "GET")
200
+ return json(200, v);
201
+ if (method === "DELETE") {
202
+ volumes.set(app, vols(app).filter((x) => x !== v));
203
+ return json(200, { ...v, state: "destroyed" });
204
+ }
205
+ return json(404, { error: `no ${method} on a volume` });
206
+ }
151
207
  // Certificates, as the real API routes them (#3114): listed at
152
208
  // `.../certificates`, created only at `.../certificates/acme`, read and
153
209
  // deleted at `.../certificates/{hostname}`. A POST to the bare list path is
@@ -198,6 +254,7 @@ export function createMachinesFake(options = {}) {
198
254
  http,
199
255
  apps,
200
256
  machines,
257
+ volumes,
201
258
  certs,
202
259
  execs,
203
260
  calls,
@@ -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
  });
@@ -89,13 +89,37 @@ export const web: Component = {
89
89
  };
90
90
  ```
91
91
 
92
- The step updates the Machine in place with the release in its `config.metadata`: `chant-release-digest`, `chant-release-git-sha` (the commit, `git rev-parse HEAD` unless given), and `chant-release-previous-digest` (the release it replaced). Each migration then runs inside the Machine through the Machines API's exec, once per environment: its receipt is kept on `chant/lifecycle` at `<env>/receipts/`, and a migration fires again only when its `sha` (or command) changes. After a migration fires, the Machine restarts, and the step checks that it is started with this release and, given a `url`, that its health endpoint answers with this commit or digest. If anything fails after the Machine changed, the step puts back the config the Machine served before (on a first release, it stops the Machine) and fails.
92
+ The step updates the Machine in place with the release in its `config.metadata`: `chant-release-digest`, `chant-release-git-sha` (the commit, `git rev-parse HEAD` unless given), and `chant-release-previous-digest` (the release it replaced). Each migration then runs inside the Machine through the Machines API's exec, once per environment: its receipt is kept on `chant/lifecycle` at `<env>/receipts/`, and a migration fires again only when its `sha` (or command) changes. After a migration fires, the Machine restarts, and the step checks that it is started with this release and, given a `url`, that its health endpoint answers with this commit or digest. If anything fails after the Machine changed, the step puts back the config the Machine served before (on a first release, it stops the Machine) and fails. That includes an apply that fails partway: the Machines it had already updated go back too.
93
93
 
94
94
  The step's output carries `uri` and `digest`, so `chant run --components web --env prod` records the release in the ledger. `chant components status prod --live` reads the Machine's metadata back and compares its digest with the ledger's: the row is `reconciled` when they agree and `drifted` when the Machine serves something the ledger does not name. The component joins the Machine by name: name the component after the Machine entity, or list it in `liveNames`.
95
95
 
96
96
  Each release's Machine config is kept on `chant/lifecycle` at `<env>/fly/<app>/<machine>/<digest>.json`. `fly-rollback` puts back the config of the release the serving one replaced (or the digest given as `to`), checks it, and outputs that digest so the ledger records it again. `fly-release`'s own saga compensation does the same.
97
97
 
98
- The steps are also Op activities, for an Op that composes them itself: `flyMachineRelease`, `flyMachineExec`, `flyMachineRestart`, `flyMachineStop`, `flyMachineVerify` and `flyMachineRestore`. Wrap a migration's `flyMachineExec` in `effect()` so it fires once.
98
+ ### Several serving Machines with Fly checks
99
+
100
+ An app served by more than one Machine, such as two Machines behind Flycast with no public address, declares them all in the plan. `fly-release` releases the one named by `machine` and gives the others their image through `images`:
101
+
102
+ ```ts
103
+ {
104
+ kind: "fly-release",
105
+ plan: "dist/fly.json",
106
+ machine: "platform-1",
107
+ digest: "@Publish.digest",
108
+ image: "@Publish.uri",
109
+ images: { "platform-2": "@Publish.uri" },
110
+ checks: { timeoutMs: 300_000 },
111
+ }
112
+ ```
113
+
114
+ Without `checks`, the apply updates the Machines in plan order and waits for each to be `started` only, and verify reads the serving Machine alone.
115
+
116
+ With `checks`, the apply updates the Machines one at a time, as `fly deploy` does. Once a Machine is started, the step reads its Fly checks from the Machines API (`checks[].status` on the Machine) until every one is `passing`, and only then updates the next Machine. A check counts only when its `updated_at` is at or after the Machine's own `updated_at` as the update call answered it, so a `passing` left over from before the update does not count. Both times are flaps' own, so the local clock plays no part; a check with no `updated_at` counts on its status. A Machine whose config declares checks that have not reported yet is still waiting. `checks.timeoutMs` (default 300000, five minutes) bounds the wait for each Machine, and `checks.intervalMs` (default 1000) is how often it looks; `checks: true` takes both defaults. A Machine with no checks configured is done once it is started, and a scheduled Machine is not waited for. The wait comes right after each Machine's update, before migrations run, so a release whose new code can't pass its checks until a migration has run should leave `checks` off.
117
+
118
+ Under `checks`, a Machine whose checks do not pass in time fails the release with an error naming the Machine and each check that is not passing, for example `machine platform/platform-2 (e784...) did not pass its Fly checks within 300s: check servicecheck-00-http-8080 is critical (503 Service Unavailable)`. The Machines after it are left alone. Every Machine the release changed goes back to its old config, the last one changed first, and each restore waits for its Machine to be started with its checks passing before the next one. On a first release the serving Machine is stopped instead.
119
+
120
+ Under `checks`, verify then covers every Machine the release started: the serving one is started with this release and its checks passing (and its health URL answering, when given), and each other one is started with its checks passing, within `verify.timeoutMs`. A failure there restores them all in the same way.
121
+
122
+ The steps are also Op activities, for an Op that composes them itself: `flyMachineRelease`, `flyMachineExec`, `flyMachineRestart`, `flyMachineStop`, `flyMachineVerify` and `flyMachineRestore`. Wrap a migration's `flyMachineExec` in `effect()` so it fires once. `flyMachineRelease` and `flyMachineRestore` wait for Fly checks when given `checks`, and `flyMachineVerify` reads them with `checks: true`; without them they wait for `started` only.
99
123
 
100
124
  ### A source tree instead of an image
101
125
 
@@ -10,9 +10,11 @@ Beyond the App and Machine covered in `chant-fly`, the lexicon models `Volume`,
10
10
 
11
11
  ## Volumes and mounts
12
12
 
13
- `flyApply` applies volumes before machines, because a machine's `config.mounts[]` references a volume by name, so the volume must exist first. A volume is created if absent (idempotent by name); a re-apply of an existing volume is a no-op.
13
+ `flyApply` applies volumes before machines. A volume is created if absent (idempotent by name); a re-apply of an existing volume is a no-op.
14
14
 
15
- The FLY011 build check enforces the link statically: every machine mount must reference a `Volume` declared in the stack, checked across files. A mount that points at an undeclared volume fails `chant build` before anything reaches the API.
15
+ A mount can name its volume. The Machines API takes the volume's id in `config.mounts[].volume`, so before it creates or updates a Machine, `flyApply` looks the name up among the app's live volumes and sends `{ volume: "vol_...", name: "data", path }`, as flyctl does. A volume declared in the same apply is created first, so its id is there to look up. Fly lets several volumes share a name (one per Machine), so the lookup takes the one already attached to the Machine, else the one in the Machine's region that no Machine has. No volume of that name, or more than one that fits, fails the apply naming the candidates; give the mount the id to settle it. A mount given an id (`vol_...`) is sent as it is. `flyMachineRestore` and the `fly-release` capability do the same lookup. The build output and a release's recorded config keep the name.
16
+
17
+ The FLY011 build check enforces the link statically: every machine mount must reference a `Volume` declared in the stack, checked across files, or give a volume id. A mount that points at an undeclared volume fails `chant build` before anything reaches the API.
16
18
 
17
19
  ```ts
18
20
  import { App, Machine, MachineConfig, MachineGuest, Volume, Fly } from "@intentius/chant-lexicon-fly";
@@ -72,7 +72,7 @@ Build synthesizes the flaps plan and runs the lint rules before anything reaches
72
72
  | FLY002 | Guest sizing (`cpu_kind` / `cpus` / `memory_mb`) is not a valid combination |
73
73
  | FLY004 | A secret value written inline in machine config |
74
74
  | FLY010 | A machine config with no `image` |
75
- | FLY011 | A machine mount that references a `Volume` not declared in the stack (checked across files) |
75
+ | FLY011 | A machine mount that references a `Volume` not declared in the stack (checked across files); a volume id (`vol_...`) passes |
76
76
  | FLY012 | A machine `schedule` other than `hourly`, `daily`, `weekly` or `monthly` |
77
77
 
78
78
  Fix every reported violation before applying. Secret values belong in a `Secret` or a reference, never inline (FLY004).
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A Fly volume's id, as the Machines API gives it (`vol_r68lmz2doox9myd4`).
3
+ * A Machine's `config.mounts[].volume` takes this id; chant also lets it name
4
+ * a volume, and `flyApply` looks the name up (#4717). Kept apart from the
5
+ * applier so the FLY011 lint check can use it without loading the applier.
6
+ */
7
+ /** True when `value` is a volume id (`vol_...`), not a volume's name. Pure. */
8
+ export declare function isVolumeId(value: string): boolean;
9
+ //# sourceMappingURL=volume-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"volume-id.d.ts","sourceRoot":"","sources":["../src/volume-id.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEjD"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A Fly volume's id, as the Machines API gives it (`vol_r68lmz2doox9myd4`).
3
+ * A Machine's `config.mounts[].volume` takes this id; chant also lets it name
4
+ * a volume, and `flyApply` looks the name up (#4717). Kept apart from the
5
+ * applier so the FLY011 lint check can use it without loading the applier.
6
+ */
7
+ /** True when `value` is a volume id (`vol_...`), not a volume's name. Pure. */
8
+ export function isVolumeId(value) {
9
+ return /^vol_[A-Za-z0-9]+$/.test(value);
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fly",
3
- "version": "0.124.67",
3
+ "version": "0.124.68",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -58,7 +58,7 @@
58
58
  "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
59
59
  },
60
60
  "dependencies": {
61
- "@intentius/chant-lexicon-otel": "^0.124.67",
61
+ "@intentius/chant-lexicon-otel": "^0.124.68",
62
62
  "ws": "^8.18.0"
63
63
  },
64
64
  "devDependencies": {
@@ -69,7 +69,7 @@
69
69
  "zod": "^4.3.6"
70
70
  },
71
71
  "peerDependencies": {
72
- "@intentius/chant": "^0.124.67",
72
+ "@intentius/chant": "^0.124.68",
73
73
  "zod": "^4.3.6"
74
74
  },
75
75
  "description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",