@intentius/chant-lexicon-fly 0.124.66 → 0.124.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/components/fly-release.d.ts +22 -3
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/components/fly-release.js +86 -38
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +8 -3
- package/dist/integrity.json +6 -6
- package/dist/lint/audit-catalog.js +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +4 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -1
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +10 -2
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/FLY011.md +2 -2
- package/dist/okf/types/Machine.md +1 -1
- package/dist/okf/types/Volume.md +1 -1
- package/dist/op/activities/fly-apply.d.ts +91 -1
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.js +165 -7
- package/dist/op/activities/machine-release.d.ts +105 -4
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machine-release.js +147 -8
- package/dist/op/activities/machines-fake.d.ts +30 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.js +60 -3
- package/dist/rules/fly011-mount-references-declared-volume.ts +9 -2
- package/dist/skills/chant-fly-ops.md +26 -2
- package/dist/skills/chant-fly-patterns.md +4 -2
- package/dist/skills/chant-fly.md +1 -1
- package/dist/volume-id.d.ts +9 -0
- package/dist/volume-id.d.ts.map +1 -0
- package/dist/volume-id.js +10 -0
- package/package.json +3 -3
- package/src/components/fly-release.test.ts +231 -0
- package/src/components/fly-release.ts +106 -39
- package/src/describe-resources.test.ts +27 -0
- package/src/describe-resources.ts +9 -2
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +31 -0
- package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +9 -2
- package/src/op/activities/fly-apply.test.ts +170 -2
- package/src/op/activities/fly-apply.ts +229 -9
- package/src/op/activities/machine-release.test.ts +175 -1
- package/src/op/activities/machine-release.ts +222 -12
- package/src/op/activities/machines-fake.ts +89 -3
- package/src/op/activities/machines-local.test.ts +1 -0
- package/src/skills/chant-fly-ops.md +26 -2
- package/src/skills/chant-fly-patterns.md +4 -2
- package/src/skills/chant-fly.md +1 -1
- package/src/volume-id.ts +11 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
RELEASE_METADATA_KEYS,
|
|
4
|
+
MachineReleaseError,
|
|
5
|
+
checksPending,
|
|
6
|
+
declaresChecks,
|
|
7
|
+
flyMachineChecks,
|
|
4
8
|
flyMachineExec,
|
|
5
9
|
flyMachineRelease,
|
|
6
10
|
flyMachineRestart,
|
|
@@ -12,7 +16,7 @@ import {
|
|
|
12
16
|
withReleaseMetadata,
|
|
13
17
|
} from "./machine-release";
|
|
14
18
|
import { createMachinesFake } from "./machines-fake";
|
|
15
|
-
import type
|
|
19
|
+
import { flyApply, type FlyHttp, type FlyPlan } from "./fly-apply";
|
|
16
20
|
|
|
17
21
|
const ENDPOINT = "http://flaps.test";
|
|
18
22
|
const NO_WAIT = { intervalMs: 0, deadlineMs: 2_000 };
|
|
@@ -136,3 +140,173 @@ describe("Machines activities against the fake", () => {
|
|
|
136
140
|
expect(fake.machine("shop", "web")!.config).toEqual(one.config);
|
|
137
141
|
});
|
|
138
142
|
});
|
|
143
|
+
|
|
144
|
+
/** #4719: Fly checks on the Machines a release rolls. */
|
|
145
|
+
describe("Fly checks", () => {
|
|
146
|
+
const CHECKED = { services: [{ internal_port: 8080, checks: [{ type: "http", port: 8080, path: "/platform.json" }] }] };
|
|
147
|
+
const TWO: FlyPlan = {
|
|
148
|
+
shop: PLAN.shop,
|
|
149
|
+
web: { endpoint: "/v1/apps/shop/machines", method: "POST", body: { name: "web", config: { image: "shop:1", ...CHECKED } } },
|
|
150
|
+
web2: { endpoint: "/v1/apps/shop/machines", method: "POST", body: { name: "web2", config: { image: "shop:1", ...CHECKED } } },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
test("declaresChecks reads top-level and service checks", () => {
|
|
154
|
+
expect(declaresChecks({ image: "x" })).toBe(false);
|
|
155
|
+
expect(declaresChecks({ checks: {} })).toBe(false);
|
|
156
|
+
expect(declaresChecks({ checks: { alive: { type: "tcp", port: 8080 } } })).toBe(true);
|
|
157
|
+
expect(declaresChecks(CHECKED)).toBe(true);
|
|
158
|
+
expect(declaresChecks({ services: [{ internal_port: 8080 }] })).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("checksPending names each check that is not passing", () => {
|
|
162
|
+
expect(checksPending({ config: {} })).toBeUndefined();
|
|
163
|
+
expect(checksPending({ config: CHECKED })).toBe("no check has reported yet");
|
|
164
|
+
expect(checksPending({ config: CHECKED, checks: [{ name: "a", status: "passing" }] })).toBeUndefined();
|
|
165
|
+
expect(
|
|
166
|
+
checksPending({ config: CHECKED, checks: [{ name: "a", status: "passing" }, { name: "b", status: "critical", output: " 503 Service Unavailable\n" }] }),
|
|
167
|
+
).toBe("check b is critical (503 Service Unavailable)");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("checksPending counts a passing check only when it reported at or after `since`", () => {
|
|
171
|
+
const since = "2026-11-06T12:00:00Z";
|
|
172
|
+
const at = (updated_at?: string) => ({ config: CHECKED, checks: [{ name: "a", status: "passing", ...(updated_at ? { updated_at } : {}) }] });
|
|
173
|
+
expect(checksPending(at("2026-11-06T11:59:59Z"), since)).toBe(
|
|
174
|
+
"check a has not reported since the update at 2026-11-06T12:00:00Z (last at 2026-11-06T11:59:59Z)",
|
|
175
|
+
);
|
|
176
|
+
expect(checksPending(at("2026-11-06T12:00:00Z"), since)).toBeUndefined();
|
|
177
|
+
expect(checksPending(at("2026-11-06T12:00:05Z"), since)).toBeUndefined();
|
|
178
|
+
// No time on either side: the status alone decides.
|
|
179
|
+
expect(checksPending(at(), since)).toBeUndefined();
|
|
180
|
+
expect(checksPending(at("2026-11-06T11:59:59Z"))).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("flyApply's afterMachine runs after each Machine settles, and a throw stops the apply there", async () => {
|
|
184
|
+
const fake = createMachinesFake();
|
|
185
|
+
const seen: string[] = [];
|
|
186
|
+
await expect(
|
|
187
|
+
flyApply(
|
|
188
|
+
{
|
|
189
|
+
plan: TWO,
|
|
190
|
+
endpoint: ENDPOINT,
|
|
191
|
+
wait: NO_WAIT,
|
|
192
|
+
afterMachine: async (m) => {
|
|
193
|
+
seen.push(`${m.name}:${m.action}`);
|
|
194
|
+
if (m.name === "web") throw new Error("stop here");
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
undefined,
|
|
198
|
+
fake.http,
|
|
199
|
+
),
|
|
200
|
+
).rejects.toThrow("stop here");
|
|
201
|
+
expect(seen).toEqual(["web:created"]);
|
|
202
|
+
expect(fake.machine("shop", "web2")).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("flyMachineChecks waits for every check to pass, and names the Machine and the check when one does not", async () => {
|
|
206
|
+
let reads = 0;
|
|
207
|
+
const fake = createMachinesFake({
|
|
208
|
+
checks: (_app, m) => (m.name === "web" ? [{ name: "servicecheck-00-http-8080", status: ++reads > 3 ? "passing" : "critical", output: "503" }] : undefined),
|
|
209
|
+
});
|
|
210
|
+
await flyApply({ plan: TWO, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
|
|
211
|
+
await expect(flyMachineChecks({ app: "shop", machine: "web", endpoint: ENDPOINT, intervalMs: 1, timeoutMs: 1_000 }, undefined, fake.http)).resolves.toBeDefined();
|
|
212
|
+
expect(reads).toBeGreaterThan(3);
|
|
213
|
+
// web2 declares checks that never report.
|
|
214
|
+
await expect(flyMachineChecks({ app: "shop", machine: "web2", endpoint: ENDPOINT, intervalMs: 1, timeoutMs: 20 }, undefined, fake.http)).rejects.toThrow(
|
|
215
|
+
/machine shop\/web2 \(m[0-9a-f]+\) did not pass its Fly checks within 20ms: no check has reported yet/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("flyMachineVerify with checks needs them passing; without, it reads only state and release", async () => {
|
|
220
|
+
let status: "passing" | "critical" = "critical";
|
|
221
|
+
const fake = createMachinesFake({ checks: () => [{ name: "alive", status }] });
|
|
222
|
+
await flyMachineRelease({ plan: TWO, machine: "web", endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, fake.http);
|
|
223
|
+
const base = { app: "shop", machine: "web", endpoint: ENDPOINT, digest: "sha256:one", intervalMs: 1, timeoutMs: 20 };
|
|
224
|
+
await expect(flyMachineVerify(base, undefined, fake.http)).resolves.toBeDefined();
|
|
225
|
+
await expect(flyMachineVerify({ ...base, checks: true }, undefined, fake.http)).rejects.toThrow(/verify shop\/web: machine shop\/web \(m[0-9a-f]+\): check alive is critical/);
|
|
226
|
+
status = "passing";
|
|
227
|
+
await expect(flyMachineVerify({ ...base, checks: true }, undefined, fake.http)).resolves.toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("flyMachineRelease with checks: a Machine that does not pass stops the roll and names what it applied", async () => {
|
|
231
|
+
const fake = createMachinesFake({ checks: (_app, m) => [{ name: "alive", status: m.config.image === "shop:bad" ? "critical" : "passing" }] });
|
|
232
|
+
const args = { plan: TWO, machine: "web", endpoint: ENDPOINT, wait: NO_WAIT, checks: { intervalMs: 1, timeoutMs: 20 } };
|
|
233
|
+
const first = await flyMachineRelease({ ...args, release: { digest: "sha256:one" } }, undefined, fake.http);
|
|
234
|
+
expect(first.applied.map((m) => [m.name, m.action, m.serving])).toEqual([
|
|
235
|
+
["web", "created", true],
|
|
236
|
+
["web2", "created", false],
|
|
237
|
+
]);
|
|
238
|
+
const err = await flyMachineRelease({ ...args, release: { digest: "sha256:two" }, image: "shop:bad", images: { web2: "shop:bad" } }, undefined, fake.http).catch((e) => e);
|
|
239
|
+
expect(err).toBeInstanceOf(MachineReleaseError);
|
|
240
|
+
expect(err.message).toMatch(/machine shop\/web .* did not pass its Fly checks within 20ms: check alive is critical/);
|
|
241
|
+
// The roll stopped at web: web2 was never updated.
|
|
242
|
+
expect((err as MachineReleaseError).applied.map((m) => [m.name, m.action])).toEqual([["web", "updated"]]);
|
|
243
|
+
expect((err as MachineReleaseError).applied[0].previous?.image).toBe("shop:1");
|
|
244
|
+
expect(fake.machine("shop", "web2")!.config.image).toBe("shop:1");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("restore with checks waits for the restored Machine's checks", async () => {
|
|
248
|
+
const fake = createMachinesFake({ checks: (_app, m) => [{ name: "alive", status: m.config.image === "shop:1" ? "passing" : "critical" }] });
|
|
249
|
+
const args = { plan: TWO, machine: "web", endpoint: ENDPOINT, wait: NO_WAIT };
|
|
250
|
+
const one = await flyMachineRelease({ ...args, release: { digest: "sha256:one" } }, undefined, fake.http);
|
|
251
|
+
await flyMachineRelease({ ...args, release: { digest: "sha256:two" }, image: "shop:2" }, undefined, fake.http);
|
|
252
|
+
const restore = { app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT, checks: { intervalMs: 1, timeoutMs: 20 } };
|
|
253
|
+
await expect(flyMachineRestore({ ...restore, config: one.config }, undefined, fake.http)).resolves.toMatchObject({ release: { digest: "sha256:one" } });
|
|
254
|
+
await expect(flyMachineRestore({ ...restore, config: { ...one.config, image: "shop:2" } }, undefined, fake.http)).rejects.toThrow(/check alive is critical/);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("a release's mounts name their volume, and Fly is sent its id (#4717)", () => {
|
|
259
|
+
const MOUNTED: FlyPlan = {
|
|
260
|
+
shop: PLAN.shop,
|
|
261
|
+
data: { endpoint: "/v1/apps/shop/volumes", method: "POST", body: { name: "data", region: "iad", size_gb: 1 } },
|
|
262
|
+
web: {
|
|
263
|
+
endpoint: "/v1/apps/shop/machines",
|
|
264
|
+
method: "POST",
|
|
265
|
+
body: { name: "web", region: "iad", config: { image: "shop:1", mounts: [{ volume: "data", path: "/data" }], metadata: { "managed-by": "chant" } } },
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const recording = (fake: ReturnType<typeof createMachinesFake>) => {
|
|
269
|
+
const posts: Array<Record<string, unknown>> = [];
|
|
270
|
+
const http: FlyHttp = async (method, url, body, headers, signal) => {
|
|
271
|
+
if (method === "POST" && /\/machines(\/[^/]+)?$/.test(new URL(url).pathname)) posts.push(body as Record<string, unknown>);
|
|
272
|
+
return fake.http(method, url, body, headers, signal);
|
|
273
|
+
};
|
|
274
|
+
return { http, posts };
|
|
275
|
+
};
|
|
276
|
+
const mountsOf = (body: Record<string, unknown> | undefined) => (body?.config as { mounts?: unknown[] } | undefined)?.mounts;
|
|
277
|
+
|
|
278
|
+
test("release sends the id; the recorded config keeps the name; restore sends the id again", async () => {
|
|
279
|
+
const fake = createMachinesFake();
|
|
280
|
+
const { http, posts } = recording(fake);
|
|
281
|
+
const args = { plan: MOUNTED, endpoint: ENDPOINT, wait: NO_WAIT };
|
|
282
|
+
const one = await flyMachineRelease({ ...args, release: { digest: "sha256:one" } }, undefined, http);
|
|
283
|
+
const id = fake.volumes.get("shop")![0].id;
|
|
284
|
+
expect(mountsOf(posts[0])).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
285
|
+
expect(one.config.mounts).toEqual([{ volume: "data", path: "/data" }]);
|
|
286
|
+
|
|
287
|
+
const two = await flyMachineRelease({ ...args, release: { digest: "sha256:two" }, image: "shop:2" }, undefined, http);
|
|
288
|
+
expect(two.action).toBe("updated");
|
|
289
|
+
expect(mountsOf(posts[1])).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
290
|
+
// What the Machine had before, read back from it, has the id.
|
|
291
|
+
expect(two.previous?.config.mounts).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
292
|
+
|
|
293
|
+
await flyMachineRestore({ app: "shop", machine: "web", config: one.config, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, http);
|
|
294
|
+
expect(mountsOf(posts[2])).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
295
|
+
expect(fake.machine("shop", "web")!.config.image).toBe("shop:1");
|
|
296
|
+
|
|
297
|
+
// A config that already has the id goes back as it is.
|
|
298
|
+
await flyMachineRestore({ app: "shop", machine: "web", config: two.previous!.config, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, http);
|
|
299
|
+
expect(mountsOf(posts[3])).toEqual([{ volume: id, name: "data", path: "/data" }]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("restore refuses a config naming a volume the app no longer has, and changes nothing", async () => {
|
|
303
|
+
const fake = createMachinesFake();
|
|
304
|
+
const { http, posts } = recording(fake);
|
|
305
|
+
const one = await flyMachineRelease({ plan: MOUNTED, endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, http);
|
|
306
|
+
const config = { ...one.config, mounts: [{ volume: "olddata", path: "/data" }] };
|
|
307
|
+
await expect(flyMachineRestore({ app: "shop", machine: "web", config, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, http)).rejects.toThrow(
|
|
308
|
+
/no volume named "olddata" in app shop/,
|
|
309
|
+
);
|
|
310
|
+
expect(posts).toHaveLength(1);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -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
|
*
|
|
@@ -35,6 +39,7 @@
|
|
|
35
39
|
|
|
36
40
|
import {
|
|
37
41
|
appNameFromRequest,
|
|
42
|
+
configEqual,
|
|
38
43
|
defaultFlyHttp,
|
|
39
44
|
flyApply,
|
|
40
45
|
isAppRequest,
|
|
@@ -43,12 +48,15 @@ import {
|
|
|
43
48
|
machineAppSegment,
|
|
44
49
|
resolveApp,
|
|
45
50
|
resolveEndpoint,
|
|
51
|
+
resolveMounts,
|
|
46
52
|
skipsLaunch,
|
|
47
53
|
waitForMachine,
|
|
48
54
|
waitForUnlaunched,
|
|
49
55
|
withLease,
|
|
50
56
|
LEASE_NONCE_HEADER,
|
|
57
|
+
type AppliedMachine,
|
|
51
58
|
type ApplyCtx,
|
|
59
|
+
type FlapsCheckStatus,
|
|
52
60
|
type FlapsMachine,
|
|
53
61
|
type FlapsRequest,
|
|
54
62
|
type FlyHttp,
|
|
@@ -126,6 +134,61 @@ async function mustFindMachine(ctx: ApplyCtx, app: string, name: string, http: F
|
|
|
126
134
|
const machinePath = (ctx: ApplyCtx, app: string, id: string) =>
|
|
127
135
|
`${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
|
|
128
136
|
|
|
137
|
+
// ── Fly checks (#4719) ─────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** How long a Machine's Fly checks have to pass, and how often to look. */
|
|
140
|
+
export interface MachineChecksOpts {
|
|
141
|
+
/** How long to wait for every check to pass. Default {@link DEFAULT_CHECKS_TIMEOUT_MS}. */
|
|
142
|
+
timeoutMs?: number;
|
|
143
|
+
/** Delay between looks. Default 1s. */
|
|
144
|
+
intervalMs?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Five minutes, as `fly deploy`'s `--wait-timeout`. */
|
|
148
|
+
export const DEFAULT_CHECKS_TIMEOUT_MS = 300_000;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when a Machine config declares a Fly check: one under `checks`, or
|
|
152
|
+
* one on any of its `services`. Pure.
|
|
153
|
+
*/
|
|
154
|
+
export function declaresChecks(config: unknown): boolean {
|
|
155
|
+
const c = (config ?? {}) as { checks?: unknown; services?: unknown };
|
|
156
|
+
if (c.checks && typeof c.checks === "object" && Object.keys(c.checks).length > 0) return true;
|
|
157
|
+
const services = Array.isArray(c.services) ? (c.services as Array<{ checks?: unknown }>) : [];
|
|
158
|
+
return services.some((svc) => Array.isArray(svc?.checks) && svc.checks.length > 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Why a Machine's Fly checks are not all passing, or undefined when they are
|
|
163
|
+
* or it has none. A Machine whose config declares checks that have not
|
|
164
|
+
* reported yet is not passing.
|
|
165
|
+
*
|
|
166
|
+
* Given `since`, the Machine's own `updated_at` as the call that changed it
|
|
167
|
+
* answered it, a check counts as passing only when its `updated_at` is at or
|
|
168
|
+
* after that time: a `passing` left over from before the change is stale.
|
|
169
|
+
* Both times come from flaps, so the local clock plays no part. A check
|
|
170
|
+
* that reports no `updated_at` cannot be told stale and counts on its status.
|
|
171
|
+
* Pure.
|
|
172
|
+
*/
|
|
173
|
+
export function checksPending(m: Pick<FlapsMachine, "config" | "checks">, since?: string): string | undefined {
|
|
174
|
+
const checks = m.checks ?? [];
|
|
175
|
+
if (checks.length === 0) return declaresChecks(m.config) ? "no check has reported yet" : undefined;
|
|
176
|
+
const from = since ? Date.parse(since) : NaN;
|
|
177
|
+
const stale = (c: FlapsCheckStatus) => {
|
|
178
|
+
const at = c.updated_at ? Date.parse(c.updated_at) : NaN;
|
|
179
|
+
return !Number.isNaN(from) && !Number.isNaN(at) && at < from;
|
|
180
|
+
};
|
|
181
|
+
const failing = checks.filter((c) => c.status !== "passing" || stale(c));
|
|
182
|
+
if (failing.length === 0) return undefined;
|
|
183
|
+
return failing
|
|
184
|
+
.map((c) => {
|
|
185
|
+
if (c.status === "passing") return `check ${c.name} has not reported since the update at ${since} (last at ${c.updated_at})`;
|
|
186
|
+
const output = (c.output ?? "").trim().slice(0, 200);
|
|
187
|
+
return `check ${c.name} is ${c.status || "unknown"}${output ? ` (${output})` : ""}`;
|
|
188
|
+
})
|
|
189
|
+
.join("; ");
|
|
190
|
+
}
|
|
191
|
+
|
|
129
192
|
/** Where to reach flaps, as every activity here takes it. */
|
|
130
193
|
export interface FlapsTarget {
|
|
131
194
|
/** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
|
|
@@ -164,6 +227,47 @@ export interface FlyMachineReleaseArgs extends FlapsTarget {
|
|
|
164
227
|
*/
|
|
165
228
|
images?: Record<string, string>;
|
|
166
229
|
wait?: WaitOpts;
|
|
230
|
+
/**
|
|
231
|
+
* Wait for each Machine the apply starts to pass its Fly checks before the
|
|
232
|
+
* next is updated (#4719). A Machine with no checks is done once started,
|
|
233
|
+
* and a scheduled one is not waited for. Without it, only `started` is
|
|
234
|
+
* waited for.
|
|
235
|
+
*/
|
|
236
|
+
checks?: MachineChecksOpts;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** One Machine a release applied, in the order it was applied. */
|
|
240
|
+
export interface ReleasedMachine {
|
|
241
|
+
app: string;
|
|
242
|
+
name: string;
|
|
243
|
+
id: string;
|
|
244
|
+
action: "created" | "updated" | "noop";
|
|
245
|
+
/** True when it was left unstarted (a scheduled Machine, #4696). */
|
|
246
|
+
unlaunched: boolean;
|
|
247
|
+
/** Its config before the release, or null when the release created it. */
|
|
248
|
+
previous: MachineConfig | null;
|
|
249
|
+
/** True for the Machine that serves the release (`machine`). */
|
|
250
|
+
serving: boolean;
|
|
251
|
+
/** Its `updated_at` as the create or update call answered it: checks older than this are stale. */
|
|
252
|
+
updatedAt?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A release that failed partway through its apply (#4719): a Machine that
|
|
257
|
+
* did not settle or whose checks did not pass. `applied` holds the Machines
|
|
258
|
+
* it applied before the failure, the failing one included when its update
|
|
259
|
+
* went through, so the caller can put each back.
|
|
260
|
+
*/
|
|
261
|
+
export class MachineReleaseError extends Error {
|
|
262
|
+
readonly applied: ReleasedMachine[];
|
|
263
|
+
/** The failure itself. */
|
|
264
|
+
readonly reason: unknown;
|
|
265
|
+
constructor(reason: unknown, applied: ReleasedMachine[]) {
|
|
266
|
+
super(reason instanceof Error ? reason.message : String(reason));
|
|
267
|
+
this.name = "MachineReleaseError";
|
|
268
|
+
this.applied = applied;
|
|
269
|
+
this.reason = reason;
|
|
270
|
+
}
|
|
167
271
|
}
|
|
168
272
|
|
|
169
273
|
/** One file a Machine's config carries, as the Machines API takes it. */
|
|
@@ -182,7 +286,12 @@ export interface FlyMachineReleaseResult {
|
|
|
182
286
|
action: "created" | "updated" | "noop";
|
|
183
287
|
/** The release the Machine serves now. */
|
|
184
288
|
release: MachineRelease;
|
|
185
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* The Machine config applied: what a later rollback to this release puts
|
|
291
|
+
* back. Its mounts name their volumes as declared, not by the ids the apply
|
|
292
|
+
* sent, so the record doesn't change with which volume a name resolved to;
|
|
293
|
+
* {@link flyMachineRestore} looks the ids up again (#4717).
|
|
294
|
+
*/
|
|
186
295
|
config: MachineConfig;
|
|
187
296
|
/** What the Machine served before, when it existed: its release (if it named one) and its config. */
|
|
188
297
|
previous: { release?: MachineRelease; config: MachineConfig } | null;
|
|
@@ -193,6 +302,8 @@ export interface FlyMachineReleaseResult {
|
|
|
193
302
|
* left unstarted, and the config it had before (null when it was created).
|
|
194
303
|
*/
|
|
195
304
|
others: Array<{ name: string; app: string; action: "created" | "updated" | "noop"; unlaunched: boolean; previous: MachineConfig | null }>;
|
|
305
|
+
/** Every Machine in the plan, the serving one included, in the order the apply reached them (#4719). */
|
|
306
|
+
applied: ReleasedMachine[];
|
|
196
307
|
}
|
|
197
308
|
|
|
198
309
|
/**
|
|
@@ -206,6 +317,11 @@ export interface FlyMachineReleaseResult {
|
|
|
206
317
|
* another in the plan, is created or updated with `skip_launch` and left
|
|
207
318
|
* unstarted: Fly starts it on its schedule. Fly counts the interval from the
|
|
208
319
|
* update, so each release pushes the next run back by up to one interval.
|
|
320
|
+
*
|
|
321
|
+
* Given `checks` (#4719), each Machine the apply starts must pass its Fly
|
|
322
|
+
* checks, reported since its update, before the next one is updated. With or
|
|
323
|
+
* without them, a failure partway through the apply throws a
|
|
324
|
+
* {@link MachineReleaseError} naming the Machines it changed so far.
|
|
209
325
|
*/
|
|
210
326
|
export async function flyMachineRelease(
|
|
211
327
|
args: FlyMachineReleaseArgs,
|
|
@@ -264,11 +380,37 @@ export async function flyMachineRelease(
|
|
|
264
380
|
before.set(m.entity, found ? ((found.config as MachineConfig | undefined) ?? {}) : null);
|
|
265
381
|
}
|
|
266
382
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
383
|
+
const rolled: ReleasedMachine[] = [];
|
|
384
|
+
const afterMachine = async (m: AppliedMachine) => {
|
|
385
|
+
const serving = m.entity === target.entity;
|
|
386
|
+
const previous = serving ? (live ? (liveConfig ?? {}) : null) : (before.get(m.entity) ?? null);
|
|
387
|
+
rolled.push({ app: m.app, name: m.name, id: m.id, action: m.action, unlaunched: m.unlaunched, previous, serving, ...(m.updatedAt ? { updatedAt: m.updatedAt } : {}) });
|
|
388
|
+
if (args.checks && m.action !== "noop" && !m.unlaunched) {
|
|
389
|
+
await waitForChecks(ctx, m.app, m.name, http, signal, args.checks, m.updatedAt);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
let applied: Awaited<ReturnType<typeof flyApply>>;
|
|
393
|
+
try {
|
|
394
|
+
applied = await flyApply(
|
|
395
|
+
{ plan: { ...plan, ...overrides, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait, afterMachine },
|
|
396
|
+
signal,
|
|
397
|
+
http,
|
|
398
|
+
);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// A Machine whose update went through but which never settled is not in
|
|
401
|
+
// `rolled` yet: find it by its config, so it is put back too.
|
|
402
|
+
const reached = new Set(rolled.map((m) => `${m.app}/${m.name}`));
|
|
403
|
+
for (const m of planMachines(plan)) {
|
|
404
|
+
if (reached.has(`${m.app}/${m.name}`)) continue;
|
|
405
|
+
const serving = m.entity === target.entity;
|
|
406
|
+
const was = serving ? (live ? (liveConfig ?? {}) : null) : (before.get(m.entity) ?? null);
|
|
407
|
+
const now = await findMachine(ctx, m.app, m.name, http, signal).catch(() => undefined);
|
|
408
|
+
if (!now || (was && configEqual(now.config, was))) continue;
|
|
409
|
+
const unlaunched = skipsLaunch((serving ? request : (overrides[m.entity] ?? m.request)).body);
|
|
410
|
+
rolled.push({ app: m.app, name: m.name, id: now.id, action: was ? "updated" : "created", unlaunched, previous: was, serving });
|
|
411
|
+
}
|
|
412
|
+
throw new MachineReleaseError(err, rolled);
|
|
413
|
+
}
|
|
272
414
|
const actionOf = (app: string, name: string) => applied.machines.find((m) => m.app === app && m.name === name)?.action ?? "noop";
|
|
273
415
|
const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
|
|
274
416
|
return {
|
|
@@ -286,6 +428,7 @@ export async function flyMachineRelease(
|
|
|
286
428
|
unlaunched: skipsLaunch((overrides[m.entity] ?? m.request).body),
|
|
287
429
|
previous: before.get(m.entity) ?? null,
|
|
288
430
|
})),
|
|
431
|
+
applied: rolled,
|
|
289
432
|
};
|
|
290
433
|
}
|
|
291
434
|
|
|
@@ -401,6 +544,10 @@ export interface FlyMachineVerifyArgs extends FlapsTarget {
|
|
|
401
544
|
timeoutMs?: number;
|
|
402
545
|
/** Delay between asks. Default 1s. */
|
|
403
546
|
intervalMs?: number;
|
|
547
|
+
/** The Machine's Fly checks must all pass too (#4719). A Machine with none passes on its state alone. */
|
|
548
|
+
checks?: boolean;
|
|
549
|
+
/** With `checks`, a check counts only when it reported at or after this time (the Machine's `updated_at` from the release's update). */
|
|
550
|
+
checksSince?: string;
|
|
404
551
|
}
|
|
405
552
|
|
|
406
553
|
/**
|
|
@@ -431,6 +578,10 @@ export async function flyMachineVerify(
|
|
|
431
578
|
if (release?.digest !== args.digest) {
|
|
432
579
|
throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
|
|
433
580
|
}
|
|
581
|
+
if (args.checks && m.state === "started") {
|
|
582
|
+
const pending = checksPending(m, args.checksSince);
|
|
583
|
+
if (pending) throw new Error(`machine ${args.app}/${args.machine} (${m.id}): ${pending}`);
|
|
584
|
+
}
|
|
434
585
|
if (args.url) {
|
|
435
586
|
const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
|
|
436
587
|
const res = await fetchImpl(url, { signal: AbortSignal.timeout(5_000) });
|
|
@@ -463,6 +614,8 @@ export interface FlyMachineRestoreArgs extends FlapsTarget {
|
|
|
463
614
|
/** The Machine config to put back, as a release applied it (its metadata names that release). */
|
|
464
615
|
config: MachineConfig;
|
|
465
616
|
wait?: WaitOpts;
|
|
617
|
+
/** Once it is started again, wait for its Fly checks to pass too (#4719). */
|
|
618
|
+
checks?: MachineChecksOpts;
|
|
466
619
|
}
|
|
467
620
|
|
|
468
621
|
/**
|
|
@@ -470,7 +623,9 @@ export interface FlyMachineRestoreArgs extends FlapsTarget {
|
|
|
470
623
|
* under a lease and wait for it to be started. Used on a failed release (the
|
|
471
624
|
* config it replaced) and by a rollback (the config an earlier release applied).
|
|
472
625
|
* A scheduled config (#4696) goes back with `skip_launch`, and the wait is for
|
|
473
|
-
* the Machine to rest unstarted.
|
|
626
|
+
* the Machine to rest unstarted. A mount that names a volume goes back with
|
|
627
|
+
* the volume's id, looked up as `flyApply` looks it up (#4717); a mount with an
|
|
628
|
+
* id (a config read from the live Machine) goes back as it is.
|
|
474
629
|
*/
|
|
475
630
|
export async function flyMachineRestore(
|
|
476
631
|
args: FlyMachineRestoreArgs,
|
|
@@ -480,19 +635,74 @@ export async function flyMachineRestore(
|
|
|
480
635
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
481
636
|
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
482
637
|
const unlaunched = skipsLaunch({ config: args.config });
|
|
483
|
-
const
|
|
638
|
+
const config = await resolveMounts(ctx, args.app, args.config, { machineId: m.id, region: m.region }, http, signal);
|
|
639
|
+
const body = { name: m.name, config, ...(unlaunched ? { skip_launch: true } : {}) };
|
|
484
640
|
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
|
|
485
641
|
http("POST", machinePath(ctx, args.app, m.id), body, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
486
642
|
);
|
|
487
643
|
if (res.status >= 300) throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
488
644
|
let instance = "";
|
|
645
|
+
let updatedAt: string | undefined;
|
|
489
646
|
try {
|
|
490
|
-
|
|
647
|
+
const answered = JSON.parse(res.text) as { instance_id?: string; updated_at?: string };
|
|
648
|
+
instance = answered.instance_id ?? "";
|
|
649
|
+
updatedAt = answered.updated_at;
|
|
491
650
|
} catch {
|
|
492
651
|
instance = "";
|
|
493
652
|
}
|
|
494
653
|
if (unlaunched) await waitForUnlaunched(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
495
|
-
else
|
|
654
|
+
else {
|
|
655
|
+
await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
656
|
+
if (args.checks) await waitForChecks(ctx, args.app, args.machine, http, signal, args.checks, updatedAt);
|
|
657
|
+
}
|
|
496
658
|
const release = readMachineRelease(args.config.metadata);
|
|
497
659
|
return { id: m.id, ...(release ? { release } : {}) };
|
|
498
660
|
}
|
|
661
|
+
|
|
662
|
+
// ── flyMachineChecks (#4719) ──────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
export interface FlyMachineChecksArgs extends FlapsTarget, MachineChecksOpts {
|
|
665
|
+
app: string;
|
|
666
|
+
/** The Machine's name or id. */
|
|
667
|
+
machine: string;
|
|
668
|
+
/** A check counts only when it reported at or after this time: the Machine's `updated_at` from the call that changed it. */
|
|
669
|
+
since?: string;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Wait until the Machine is started and every one of its Fly checks
|
|
674
|
+
* (`checks[].status` on the Machine) is `passing`, and, given `since`, has
|
|
675
|
+
* reported since then. A Machine with no checks is done once started. Throws after `timeoutMs`, naming the Machine and each
|
|
676
|
+
* check that is not passing.
|
|
677
|
+
*/
|
|
678
|
+
export async function flyMachineChecks(
|
|
679
|
+
args: FlyMachineChecksArgs,
|
|
680
|
+
signal?: AbortSignal,
|
|
681
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
682
|
+
): Promise<{ id: string }> {
|
|
683
|
+
return waitForChecks({ base: resolveEndpoint(args) }, args.app, args.machine, http, signal, args, args.since);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function waitForChecks(
|
|
687
|
+
ctx: ApplyCtx,
|
|
688
|
+
app: string,
|
|
689
|
+
name: string,
|
|
690
|
+
http: FlyHttp,
|
|
691
|
+
signal: AbortSignal | undefined,
|
|
692
|
+
opts: MachineChecksOpts,
|
|
693
|
+
since?: string,
|
|
694
|
+
): Promise<{ id: string }> {
|
|
695
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_CHECKS_TIMEOUT_MS;
|
|
696
|
+
const interval = opts.intervalMs ?? 1_000;
|
|
697
|
+
const deadline = Date.now() + timeoutMs;
|
|
698
|
+
for (;;) {
|
|
699
|
+
if (signal?.aborted) throw new Error(`waiting for the checks of ${app}/${name} aborted`);
|
|
700
|
+
const m = await mustFindMachine(ctx, app, name, http, signal);
|
|
701
|
+
const why = m.state === "started" ? checksPending(m, since) : `it is ${m.state}`;
|
|
702
|
+
if (!why) return { id: m.id };
|
|
703
|
+
if (Date.now() + interval > deadline) {
|
|
704
|
+
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}`);
|
|
705
|
+
}
|
|
706
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
707
|
+
}
|
|
708
|
+
}
|