@intentius/chant-lexicon-fly 0.124.67 → 0.124.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/fly-release.d.ts +22 -3
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +86 -38
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +8 -3
- package/dist/integrity.json +6 -6
- package/dist/lint/audit-catalog.js +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +4 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +10 -2
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/FLY011.md +2 -2
- package/dist/okf/types/Machine.md +1 -1
- package/dist/okf/types/Volume.md +1 -1
- package/dist/op/activities/fly-apply.d.ts +91 -1
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +165 -7
- package/dist/op/activities/machine-release.d.ts +105 -4
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +147 -8
- package/dist/op/activities/machines-fake.d.ts +30 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +60 -3
- package/dist/rules/fly011-mount-references-declared-volume.ts +9 -2
- package/dist/skills/chant-fly-ops.md +26 -2
- package/dist/skills/chant-fly-patterns.md +4 -2
- package/dist/skills/chant-fly.md +1 -1
- package/dist/volume-id.d.ts +9 -0
- package/dist/volume-id.d.ts.map +1 -0
- package/dist/volume-id.js +10 -0
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +231 -0
- package/src/components/fly-release.ts +106 -39
- package/src/describe-resources.test.ts +27 -0
- package/src/describe-resources.ts +9 -2
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +31 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +9 -2
- package/src/op/activities/fly-apply.test.ts +170 -2
- package/src/op/activities/fly-apply.ts +229 -9
- package/src/op/activities/machine-release.test.ts +175 -1
- package/src/op/activities/machine-release.ts +222 -12
- package/src/op/activities/machines-fake.ts +89 -3
- package/src/op/activities/machines-local.test.ts +1 -0
- package/src/skills/chant-fly-ops.md +26 -2
- package/src/skills/chant-fly-patterns.md +4 -2
- package/src/skills/chant-fly.md +1 -1
- package/src/volume-id.ts +11 -0
|
@@ -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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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";
|
package/src/skills/chant-fly.md
CHANGED
|
@@ -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).
|
package/src/volume-id.ts
ADDED
|
@@ -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
|
+
}
|