@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
@@ -22,6 +22,19 @@ export interface FakeMachine {
22
22
  state: string;
23
23
  instance_id: string;
24
24
  config: Record<string, unknown> & { metadata?: Record<string, string> };
25
+ /** When the machine was last created or updated (RFC 3339), as flaps reports it. */
26
+ updated_at?: string;
27
+ }
28
+
29
+ /** One volume the fake holds, as flaps lists it. */
30
+ export interface FakeVolume {
31
+ id: string;
32
+ name: string;
33
+ region?: string;
34
+ size_gb?: number;
35
+ state: string;
36
+ /** The Machine whose config mounts it by id; null when none does. */
37
+ attached_machine_id: string | null;
25
38
  }
26
39
 
27
40
  /** What an `exec` answers: the Machines API's exec envelope. */
@@ -35,6 +48,8 @@ export interface MachinesFake {
35
48
  http: FlyHttp;
36
49
  apps: Set<string>;
37
50
  machines: Map<string, FakeMachine[]>;
51
+ /** Each app's volumes, in creation order, with ids `vol_...` (#4717). */
52
+ volumes: Map<string, FakeVolume[]>;
38
53
  /** Each app's certificate hostnames, in creation order. */
39
54
  certs: Map<string, string[]>;
40
55
  /** Every exec, in order: the app, the machine id and the command. */
@@ -50,6 +65,23 @@ export interface MachinesFakeOptions {
50
65
  exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult | Promise<FakeExecResult>;
51
66
  /** The state a created or updated machine settles in. Default `started`. */
52
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
+
78
+ /** One Fly check's status on a machine, as the Machines API reports it. */
79
+ export interface FakeCheck {
80
+ name: string;
81
+ status: "passing" | "warning" | "critical";
82
+ output?: string;
83
+ /** When the check last reported (RFC 3339). */
84
+ updated_at?: string;
53
85
  }
54
86
 
55
87
  const json = (status: number, body: unknown) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
@@ -75,12 +107,30 @@ const nextId = (prefix: string) => `${prefix}${(++seq).toString(16).padStart(10,
75
107
  export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesFake {
76
108
  const apps = new Set<string>();
77
109
  const machines = new Map<string, FakeMachine[]>();
110
+ const volumes = new Map<string, FakeVolume[]>();
78
111
  const certs = new Map<string, string[]>();
79
112
  const execs: MachinesFake["execs"] = [];
80
113
  const calls: string[] = [];
81
114
  const settle = options.settle ?? (() => "started");
115
+ const stamp = () => (options.now?.() ?? new Date()).toISOString();
82
116
  const list = (app: string) => machines.get(app) ?? [];
83
117
  const find = (app: string, id: string) => list(app).find((m) => m.id === id);
118
+ const withChecks = (app: string, m: FakeMachine) => {
119
+ const checks = options.checks?.(app, m);
120
+ return checks ? { ...m, checks } : m;
121
+ };
122
+ const vols = (app: string) => volumes.get(app) ?? [];
123
+ // A volume is attached to the Machine whose config mounts its id, as Fly
124
+ // attaches it. A mount naming no volume id attaches nothing.
125
+ const attach = (app: string, m: FakeMachine | undefined, id: string) => {
126
+ const mounted = new Set(
127
+ ((m?.config.mounts as Array<{ volume?: unknown }> | undefined) ?? []).map((x) => x.volume).filter((v) => typeof v === "string"),
128
+ );
129
+ for (const v of vols(app)) {
130
+ if (v.attached_machine_id === id && !mounted.has(v.id)) v.attached_machine_id = null;
131
+ if (m && mounted.has(v.id)) v.attached_machine_id = id;
132
+ }
133
+ };
84
134
 
85
135
  const http: FlyHttp = async (method, url, body) => {
86
136
  const { pathname, searchParams } = new URL(url);
@@ -105,6 +155,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
105
155
  if (method === "DELETE") {
106
156
  apps.delete(app);
107
157
  machines.delete(app);
158
+ volumes.delete(app);
108
159
  certs.delete(app);
109
160
  return json(202, undefined);
110
161
  }
@@ -116,7 +167,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
116
167
  const id = seg[4];
117
168
  const action = seg[5];
118
169
  if (!id) {
119
- if (method === "GET") return json(200, list(app));
170
+ if (method === "GET") return json(200, list(app).map((m) => withChecks(app, m)));
120
171
  if (method === "POST") {
121
172
  const m: FakeMachine = {
122
173
  id: nextId("m"),
@@ -124,20 +175,24 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
124
175
  state: "created",
125
176
  instance_id: nextId("I"),
126
177
  config: structuredClone((b.config ?? {}) as FakeMachine["config"]),
178
+ updated_at: stamp(),
127
179
  };
128
180
  // skip_launch: the Machine is created and not booted (#4696).
129
181
  m.state = b.skip_launch === true ? "created" : settle(m);
130
182
  machines.set(app, [...list(app), m]);
183
+ attach(app, m, m.id);
131
184
  return json(200, m);
132
185
  }
133
186
  }
134
187
  const m = id ? find(app, id) : undefined;
135
188
  if (!m) return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
136
189
  if (!action) {
137
- if (method === "GET") return json(200, m);
190
+ if (method === "GET") return json(200, withChecks(app, m));
138
191
  if (method === "POST") {
139
192
  m.config = structuredClone((b.config ?? {}) as FakeMachine["config"]);
140
193
  m.instance_id = nextId("I");
194
+ m.updated_at = stamp();
195
+ attach(app, m, m.id);
141
196
  // skip_launch: the new config isn't started (#4696). A Machine that
142
197
  // never ran stays created; one that ran is stopped.
143
198
  m.state = b.skip_launch === true ? (m.state === "created" ? "created" : "stopped") : settle(m);
@@ -152,6 +207,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
152
207
  });
153
208
  }
154
209
  machines.set(app, list(app).filter((x) => x !== m));
210
+ attach(app, undefined, m.id);
155
211
  return json(200, { ok: true });
156
212
  }
157
213
  }
@@ -180,10 +236,39 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
180
236
  }
181
237
  if (kind === "ip_assignments") return method === "GET" ? json(200, { ips: [] }) : json(200, {});
182
238
  if (kind === "certificates") return certificates(app, method, seg.slice(4), b, searchParams);
183
- if (kind === "volumes" || kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
239
+ if (kind === "volumes") return volumeCall(app, method, seg[4], b);
240
+ if (kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
184
241
  return json(404, { error: "not found" });
185
242
  };
186
243
 
244
+ // Volumes (#4717): created with a `vol_` id, listed with their region and
245
+ // the Machine they are attached to, read and deleted by id.
246
+ function volumeCall(app: string, method: string, id: string | undefined, b: Record<string, unknown>) {
247
+ if (!id) {
248
+ if (method === "GET") return json(200, vols(app));
249
+ if (method === "POST") {
250
+ const v: FakeVolume = {
251
+ id: nextId("vol_"),
252
+ name: String(b.name ?? ""),
253
+ ...(typeof b.region === "string" ? { region: b.region } : {}),
254
+ ...(typeof b.size_gb === "number" ? { size_gb: b.size_gb } : {}),
255
+ state: "created",
256
+ attached_machine_id: null,
257
+ };
258
+ volumes.set(app, [...vols(app), v]);
259
+ return json(200, v);
260
+ }
261
+ }
262
+ const v = vols(app).find((x) => x.id === id);
263
+ if (!v) return json(404, { error: "volume not found" });
264
+ if (method === "GET") return json(200, v);
265
+ if (method === "DELETE") {
266
+ volumes.set(app, vols(app).filter((x) => x !== v));
267
+ return json(200, { ...v, state: "destroyed" });
268
+ }
269
+ return json(404, { error: `no ${method} on a volume` });
270
+ }
271
+
187
272
  // Certificates, as the real API routes them (#3114): listed at
188
273
  // `.../certificates`, created only at `.../certificates/acme`, read and
189
274
  // deleted at `.../certificates/{hostname}`. A POST to the bare list path is
@@ -229,6 +314,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
229
314
  http,
230
315
  apps,
231
316
  machines,
317
+ volumes,
232
318
  certs,
233
319
  execs,
234
320
  calls,
@@ -127,6 +127,7 @@ describe("the running mode of the local Machines API (#2831)", () => {
127
127
  const wait = { intervalMs: 20, deadlineMs: 10_000 };
128
128
  const plan: FlyPlan = {
129
129
  shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
130
+ data: { endpoint: "/v1/apps/shop/volumes", method: "POST", body: { name: "data", region: "iad", size_gb: 1 } },
130
131
  web: {
131
132
  endpoint: "/v1/apps/shop/machines",
132
133
  method: "POST",
@@ -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,11 @@
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
+
8
+ /** True when `value` is a volume id (`vol_...`), not a volume's name. Pure. */
9
+ export function isVolumeId(value: string): boolean {
10
+ return /^vol_[A-Za-z0-9]+$/.test(value);
11
+ }