@intentius/chant-lexicon-fly 0.91.0 → 0.93.0
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 +27 -0
- package/dist/components/fly-release.d.ts +51 -4
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +86 -0
- package/dist/composites/fly-site.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fly-release-step.d.ts +24 -0
- package/dist/op/activities/fly-release-step.d.ts.map +1 -0
- package/dist/op/activities/fly-rollback-step.d.ts +25 -0
- package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +7 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +16 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +4 -2
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-local-cli.d.ts +20 -0
- package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
- package/dist/op/activities/machines-local.d.ts +107 -0
- package/dist/op/activities/machines-local.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +12 -3
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +107 -0
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +18 -1
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +29 -9
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +62 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/builders.d.ts +32 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/skills/chant-fly-ops.md +67 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/fly-release.test.ts +135 -1
- package/src/components/fly-release.ts +125 -8
- package/src/composites/catalog.test.ts +2 -1
- package/src/composites/catalog.ts +98 -0
- package/src/composites/fly-site.test.ts +66 -0
- package/src/composites/fly-site.ts +140 -0
- package/src/index.ts +8 -0
- package/src/op/activities/fly-release-step.ts +32 -0
- package/src/op/activities/fly-rollback-step.ts +33 -0
- package/src/op/activities/index.ts +25 -0
- package/src/op/activities/machine-release.ts +22 -0
- package/src/op/activities/machines-fake.ts +5 -3
- package/src/op/activities/machines-local-cli.ts +73 -0
- package/src/op/activities/machines-local.test.ts +225 -0
- package/src/op/activities/machines-local.ts +437 -0
- package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
- package/src/op/activities/sprite-fs.test.ts +55 -0
- package/src/op/activities/sprite-fs.ts +14 -5
- package/src/op/activities/sprite-service-converge.test.ts +268 -0
- package/src/op/activities/sprite-service-converge.ts +268 -0
- package/src/op/activities/sprites-fake.ts +51 -8
- package/src/op/activities/sprites.integration.test.ts +30 -0
- package/src/op/activities/sprites.test.ts +20 -0
- package/src/op/activities/sprites.ts +50 -10
- package/src/op/activity-contracts.test.ts +71 -0
- package/src/op/activity-contracts.ts +67 -0
- package/src/op/builders.ts +32 -0
- package/src/skills/chant-fly-ops.md +67 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { archiveSourceTree } from "@intentius/chant/op/source-archive";
|
|
3
5
|
import { tmpdir } from "node:os";
|
|
4
6
|
import { join } from "node:path";
|
|
5
7
|
import { afterEach, beforeEach } from "vitest";
|
|
@@ -103,6 +105,29 @@ describe("fly-release", () => {
|
|
|
103
105
|
expect(out.gitSha).toBe("0000000000000000000000000000000000000000");
|
|
104
106
|
});
|
|
105
107
|
|
|
108
|
+
test("the commit goes into the Machine's APP_REVISION env var, as chud's fly-site.mjs set it (#2834)", async () => {
|
|
109
|
+
const s = setup();
|
|
110
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
111
|
+
const m = s.fake.machine("shop", "web")!;
|
|
112
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("aaaaaaa");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a caller's own env.APP_REVISION overrides the commit (#2834)", async () => {
|
|
116
|
+
const s = setup();
|
|
117
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { env: { APP_REVISION: "custom" } }));
|
|
118
|
+
const m = s.fake.machine("shop", "web")!;
|
|
119
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("custom");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a rollback restores the recorded APP_REVISION with the rest of the config (#2834)", async () => {
|
|
123
|
+
const s = setup();
|
|
124
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
125
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb"));
|
|
126
|
+
await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
127
|
+
const m = s.fake.machine("shop", "web")!;
|
|
128
|
+
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("aaaaaaa");
|
|
129
|
+
});
|
|
130
|
+
|
|
106
131
|
test("each migration fires once per environment, then the Machine restarts", async () => {
|
|
107
132
|
const s = setup();
|
|
108
133
|
const migrations = [
|
|
@@ -242,3 +267,112 @@ describe("components status --live over a Fly release", () => {
|
|
|
242
267
|
});
|
|
243
268
|
});
|
|
244
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("fly-release with a source tree (#2782)", () => {
|
|
272
|
+
function archived() {
|
|
273
|
+
const repo = join(dir, "repo");
|
|
274
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: repo, stdio: "pipe" });
|
|
275
|
+
mkdirSync(join(repo, "app/migrations"), { recursive: true });
|
|
276
|
+
writeFileSync(join(repo, "app/server.js"), "console.log('serving');\n");
|
|
277
|
+
writeFileSync(join(repo, "app/migrations/0001_init.sql"), "CREATE TABLE t (id INTEGER);\n");
|
|
278
|
+
git("init", "-q", "-b", "main");
|
|
279
|
+
git("-c", "user.email=t@t", "-c", "user.name=t", "add", "-A");
|
|
280
|
+
git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "app");
|
|
281
|
+
return archiveSourceTree({ dir: "app", cwd: repo });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
test("puts the approved tree on the Machine with its start command, and a retry changes nothing", async () => {
|
|
285
|
+
const s = setup();
|
|
286
|
+
const a = archived();
|
|
287
|
+
const source = { archive: a.archive, digest: a.digest, dir: a.dir, start: "node server.js" };
|
|
288
|
+
const first = await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source }));
|
|
289
|
+
const m = s.fake.machine("shop", "web")!;
|
|
290
|
+
const files = m.config.files as Array<{ guest_path: string; raw_value: string }>;
|
|
291
|
+
expect(files.map((f) => f.guest_path).sort()).toEqual(["/srv/app/migrations/0001_init.sql", "/srv/app/server.js"]);
|
|
292
|
+
expect(Buffer.from(files.find((f) => f.guest_path === "/srv/app/server.js")!.raw_value, "base64").toString()).toBe("console.log('serving');\n");
|
|
293
|
+
expect(m.config.init).toEqual({ cmd: ["sh", "-c", "cd /srv/app && exec node server.js"] });
|
|
294
|
+
expect(m.config.image).toBe("shop:declared");
|
|
295
|
+
expect(first.digest).toBe("sha256:aaaaaaaaaaaa");
|
|
296
|
+
|
|
297
|
+
const calls = s.fake.calls.length;
|
|
298
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source }));
|
|
299
|
+
// The same release again: the Machine's config is unchanged, so nothing is updated.
|
|
300
|
+
expect(s.fake.calls.slice(calls).filter((c) => c.startsWith("POST") && !c.endsWith("/wait"))).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("an archive that is not the approved digest is refused before the Machine changes", async () => {
|
|
304
|
+
const s = setup();
|
|
305
|
+
const a = archived();
|
|
306
|
+
await expect(
|
|
307
|
+
s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { archive: a.archive, digest: "sha256:0000", dir: a.dir, start: "node server.js" } })),
|
|
308
|
+
).rejects.toThrow(/not the approved sha256:0000/);
|
|
309
|
+
expect(s.fake.calls.filter((c) => c.startsWith("POST"))).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("fly-rollback with a source tree (#2800)", () => {
|
|
314
|
+
/** Two commits of an app, each archived. */
|
|
315
|
+
function twoReleases() {
|
|
316
|
+
const repo = join(dir, "repo2");
|
|
317
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd: repo, stdio: "pipe" });
|
|
318
|
+
mkdirSync(join(repo, "app"), { recursive: true });
|
|
319
|
+
git("init", "-q", "-b", "main");
|
|
320
|
+
const commit = (body: string, out: string) => {
|
|
321
|
+
writeFileSync(join(repo, "app/server.js"), body);
|
|
322
|
+
git("add", "-A");
|
|
323
|
+
git("commit", "-q", "-m", body);
|
|
324
|
+
return archiveSourceTree({ dir: "app", cwd: repo, out });
|
|
325
|
+
};
|
|
326
|
+
return { a: commit("console.log('a');\n", "a.tar"), b: commit("console.log('b');\n", "b.tar") };
|
|
327
|
+
}
|
|
328
|
+
const src = (x: { archive: string; digest: string; dir: string }) => ({ archive: x.archive, digest: x.digest, dir: x.dir });
|
|
329
|
+
const rollbackInput = (to: string, source: ReturnType<typeof src>) => ({ plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 }, to, source });
|
|
330
|
+
|
|
331
|
+
test("puts back the recorded config of the release whose tree it is, and a rerun changes nothing", async () => {
|
|
332
|
+
const s = setup();
|
|
333
|
+
const { a, b } = twoReleases();
|
|
334
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
335
|
+
const one = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
336
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
337
|
+
expect(s.fake.machine("shop", "web")!.config).not.toEqual(one);
|
|
338
|
+
|
|
339
|
+
const back = await s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)));
|
|
340
|
+
expect(back).toMatchObject({ digest: "sha256:aaaaaaaaaaaa", previous: { digest: "sha256:bbbbbbbbbbbb" } });
|
|
341
|
+
const m = s.fake.machine("shop", "web")!;
|
|
342
|
+
expect(m.config).toEqual(one);
|
|
343
|
+
const server = (m.config.files as Array<{ guest_path: string; raw_value: string }>).find((f) => f.guest_path === "/srv/app/server.js")!;
|
|
344
|
+
expect(Buffer.from(server.raw_value, "base64").toString()).toBe("console.log('a');\n");
|
|
345
|
+
|
|
346
|
+
const calls = s.fake.calls.length;
|
|
347
|
+
await s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)));
|
|
348
|
+
expect(s.fake.calls.slice(calls).filter((c) => c.startsWith("POST") && !c.endsWith("/wait"))).toEqual([]);
|
|
349
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(one);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("an archive that is not its digest is refused before any flaps call", async () => {
|
|
353
|
+
const s = setup();
|
|
354
|
+
const { a, b } = twoReleases();
|
|
355
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
356
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
357
|
+
const calls = s.fake.calls.length;
|
|
358
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", { ...src(a), digest: b.digest }))).rejects.toThrow(/not the approved/);
|
|
359
|
+
expect(s.fake.calls.length).toBe(calls);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a recorded config that does not carry the tree is refused, and the Machine is left as it is", async () => {
|
|
363
|
+
const s = setup();
|
|
364
|
+
const { a, b } = twoReleases();
|
|
365
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa", { source: { ...src(a), start: "node server.js" } }));
|
|
366
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
367
|
+
const serving = structuredClone(s.fake.machine("shop", "web")!.config);
|
|
368
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(b)))).rejects.toThrow(/does not carry the tree .*\/srv\/app\/server\.js has other bytes/);
|
|
369
|
+
expect(s.fake.machine("shop", "web")!.config).toEqual(serving);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("a release with no recorded Machine config is refused by name", async () => {
|
|
373
|
+
const s = setup();
|
|
374
|
+
const { a, b } = twoReleases();
|
|
375
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { source: { ...src(b), start: "node server.js" } }));
|
|
376
|
+
await expect(s.rollback.run(CTX, rollbackInput("sha256:aaaaaaaaaaaa", src(a)))).rejects.toThrow("fly-rollback: no recorded Machine config for sha256:aaaaaaaaaaaa on shop/web");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* (../op/activities/machine-release.ts):
|
|
15
15
|
*
|
|
16
16
|
* 1. upload and start: apply the plan with the Machine serving the release,
|
|
17
|
-
* its digest and commit in the Machine's metadata
|
|
17
|
+
* its digest and commit in the Machine's metadata, and the commit in its
|
|
18
|
+
* `APP_REVISION` env var (#2834, as chud's fly-site.mjs set it), so the
|
|
19
|
+
* app's own `/health` can report it. A caller's `env.APP_REVISION`
|
|
20
|
+
* overrides it. `fly-rollback` restores it for free: it puts back the
|
|
21
|
+
* whole recorded config, env included;
|
|
18
22
|
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
19
23
|
* witnessed by a receipt in chant's lifecycle receipt store
|
|
20
24
|
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
@@ -32,7 +36,9 @@
|
|
|
32
36
|
* Each release's Machine config is kept on the lifecycle branch
|
|
33
37
|
* (../release-store.ts). `fly-rollback` puts back the config of the release
|
|
34
38
|
* the serving one replaced (or the digest it is given), which is also the
|
|
35
|
-
* saga compensation `fly-release` declares.
|
|
39
|
+
* saga compensation `fly-release` declares. Given the source tree of that
|
|
40
|
+
* release (#2800), it checks the archive against its digest before any flaps
|
|
41
|
+
* call and refuses a recorded config that does not carry exactly that tree.
|
|
36
42
|
*/
|
|
37
43
|
|
|
38
44
|
import { createHash } from "node:crypto";
|
|
@@ -48,6 +54,7 @@ import {
|
|
|
48
54
|
flyMachineVerify,
|
|
49
55
|
readMachineRelease,
|
|
50
56
|
releaseTarget,
|
|
57
|
+
type MachineFile,
|
|
51
58
|
type MachineRelease,
|
|
52
59
|
} from "../op/activities/machine-release";
|
|
53
60
|
import { defaultFlyHttp, parsePlan, resolveEndpoint, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
@@ -64,6 +71,55 @@ export interface FlyMigration {
|
|
|
64
71
|
sha?: string;
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
/** A source tree a release puts on the Machine. */
|
|
75
|
+
export interface FlySource {
|
|
76
|
+
/** The archive (`sourceArchive`'s `archive`, a `git archive` tar). */
|
|
77
|
+
archive: string;
|
|
78
|
+
/** The sha256 the archive must have (`sha256:...`). */
|
|
79
|
+
digest: string;
|
|
80
|
+
/** The directory the archive holds, stripped from each path (`sourceArchive`'s `dir`). */
|
|
81
|
+
dir?: string;
|
|
82
|
+
/** Where the files go on the Machine. Default `/srv/app`. */
|
|
83
|
+
into?: string;
|
|
84
|
+
/** The command that starts the app, run in `into`: an argv, or a string run with `sh -c`. */
|
|
85
|
+
start: string[] | string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Most bytes of files, base64, a release puts in a Machine's config (as chud's fly-site.mjs capped them). */
|
|
89
|
+
export const SOURCE_FILES_LIMIT = 1024 * 1024;
|
|
90
|
+
|
|
91
|
+
/** Where a source tree goes on the Machine, without a trailing slash. */
|
|
92
|
+
const intoOf = (source: { into?: string }) => (source.into ?? "/srv/app").replace(/\/+$/, "");
|
|
93
|
+
|
|
94
|
+
/** A source tree's files as a Machine config carries them, once its archive is the approved one. */
|
|
95
|
+
async function sourceTreeFiles(source: Omit<FlySource, "start">, kind: string): Promise<MachineFile[]> {
|
|
96
|
+
const { readSourceArchive } = await import("@intentius/chant/op/source-archive");
|
|
97
|
+
const into = intoOf(source);
|
|
98
|
+
const files: MachineFile[] = readSourceArchive(source).map((f) => ({
|
|
99
|
+
guest_path: `${into}/${f.path}`,
|
|
100
|
+
raw_value: f.data.toString("base64"),
|
|
101
|
+
...(f.executable ? { mode: 0o755 } : {}),
|
|
102
|
+
}));
|
|
103
|
+
if (files.length === 0) throw new Error(`${kind}: ${source.archive} holds no files`);
|
|
104
|
+
return files;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A source tree as Machine files and the start command, once its archive is the approved one. */
|
|
108
|
+
export async function sourceMachineFiles(source: FlySource): Promise<{ files: MachineFile[]; cmd: string[] }> {
|
|
109
|
+
const into = intoOf(source);
|
|
110
|
+
const files = await sourceTreeFiles(source, "fly-release");
|
|
111
|
+
const bytes = files.reduce((n, f) => n + f.raw_value.length, 0);
|
|
112
|
+
if (bytes > SOURCE_FILES_LIMIT) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`fly-release: the source tree is ${Math.round(bytes / 1024)} KiB encoded; a Machine's config carries at most ${SOURCE_FILES_LIMIT / 1024} KiB of files. Ship a larger app as an image (\`image\`).`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const start = typeof source.start === "string" ? source.start : source.start.map(shellQuote).join(" ");
|
|
118
|
+
return { files, cmd: ["sh", "-c", `cd ${shellQuote(into)} && exec ${start}`] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const shellQuote = (v: string) => (/^[A-Za-z0-9_./:=@%+-]+$/.test(v) ? v : `'${v.replaceAll("'", "'\\''")}'`);
|
|
122
|
+
|
|
67
123
|
/** Health check after the release serves. */
|
|
68
124
|
export interface FlyVerify {
|
|
69
125
|
/** The app's public URL. Without it, only the Machine's state and metadata are checked. */
|
|
@@ -87,8 +143,15 @@ export interface FlyReleaseInput {
|
|
|
87
143
|
release?: string;
|
|
88
144
|
/** An image to run in place of the declared one. */
|
|
89
145
|
image?: string;
|
|
90
|
-
/** Env added to the declared Machine's env. */
|
|
146
|
+
/** Env added to the declared Machine's env, over `APP_REVISION` (the commit, #2834) unless this names its own. */
|
|
91
147
|
env?: Record<string, string>;
|
|
148
|
+
/**
|
|
149
|
+
* A source tree to put on the Machine (#2782), for an app shipped as its
|
|
150
|
+
* own files on a declared runtime image rather than as an image of its own.
|
|
151
|
+
* The archive is read only once its bytes hash to `digest` (the digest the
|
|
152
|
+
* release plan carries), so the Machine never gets a tree nobody approved.
|
|
153
|
+
*/
|
|
154
|
+
source?: FlySource;
|
|
92
155
|
/** Migrations, in order. */
|
|
93
156
|
migrations?: FlyMigration[];
|
|
94
157
|
verify?: FlyVerify;
|
|
@@ -110,12 +173,22 @@ export interface FlyReleaseOutput {
|
|
|
110
173
|
migrations: Array<{ name: string; fired: boolean }>;
|
|
111
174
|
}
|
|
112
175
|
|
|
176
|
+
/** The source tree a rollback puts back (#2800): the archive of the release it goes back to. */
|
|
177
|
+
export type FlyRollbackSource = Omit<FlySource, "start">;
|
|
178
|
+
|
|
113
179
|
export interface FlyRollbackInput {
|
|
114
180
|
/** Path to the fly build output, naming the App and Machine. */
|
|
115
181
|
plan: string;
|
|
116
182
|
machine?: string;
|
|
117
183
|
/** The release digest to go back to. Default: the one the serving release replaced. */
|
|
118
184
|
to?: string;
|
|
185
|
+
/**
|
|
186
|
+
* The source tree of the release it goes back to (#2800). The archive is
|
|
187
|
+
* read only once its bytes hash to `digest`, before any flaps call, and the
|
|
188
|
+
* Machine config recorded for `to` must carry exactly that tree under
|
|
189
|
+
* `into`, or the rollback is refused.
|
|
190
|
+
*/
|
|
191
|
+
source?: FlyRollbackSource;
|
|
119
192
|
verify?: FlyVerify;
|
|
120
193
|
endpoint?: string;
|
|
121
194
|
wait?: WaitOpts;
|
|
@@ -210,6 +283,13 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
210
283
|
const { plan } = loadTarget(input.plan, input.machine);
|
|
211
284
|
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
212
285
|
const configs = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
286
|
+
// The tree is checked against its digest before the Machine changes.
|
|
287
|
+
const source = input.source ? await sourceMachineFiles(input.source) : undefined;
|
|
288
|
+
|
|
289
|
+
// APP_REVISION carries the commit into the Machine's env, as chud's
|
|
290
|
+
// fly-site.mjs did (chant#2834), so the app's own /health can report
|
|
291
|
+
// it. A caller's own APP_REVISION (input.env) wins over the commit.
|
|
292
|
+
const env = { APP_REVISION: gitSha, ...(input.env ?? {}) };
|
|
213
293
|
|
|
214
294
|
const released = await flyMachineRelease(
|
|
215
295
|
{
|
|
@@ -217,7 +297,8 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
217
297
|
machine: input.machine,
|
|
218
298
|
release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
|
|
219
299
|
image: input.image,
|
|
220
|
-
env
|
|
300
|
+
env,
|
|
301
|
+
...(source ? { files: source.files, cmd: source.cmd } : {}),
|
|
221
302
|
endpoint: input.endpoint,
|
|
222
303
|
wait: input.wait,
|
|
223
304
|
},
|
|
@@ -296,12 +377,18 @@ export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capabili
|
|
|
296
377
|
const log = deps.log ?? ((line: string) => console.error(line));
|
|
297
378
|
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
298
379
|
|
|
299
|
-
async function
|
|
380
|
+
async function recordedConfig(ctx: DeployContext, app: string, machine: string, digest: string): Promise<Record<string, unknown>> {
|
|
300
381
|
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
301
382
|
const config = await store.read({ app, machine, digest });
|
|
302
383
|
if (!config) throw new Error(`fly-rollback: no recorded Machine config for ${digest} on ${app}/${machine}`);
|
|
384
|
+
return config;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function restoreTo(ctx: DeployContext, input: FlyRollbackInput, app: string, machine: string, digest: string, config?: Record<string, unknown>, serving?: string) {
|
|
388
|
+
config ??= await recordedConfig(ctx, app, machine, digest);
|
|
303
389
|
const http = httpFor();
|
|
304
|
-
|
|
390
|
+
// Already serving it (a rerun after the rollback finished): leave the Machine as it is.
|
|
391
|
+
if (serving !== digest) await flyMachineRestore({ app, machine, config, endpoint: input.endpoint, wait: input.wait }, undefined, http);
|
|
305
392
|
await flyMachineVerify({ app, machine, digest, endpoint: input.endpoint, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
306
393
|
return readMachineRelease((config as { metadata?: Record<string, string> }).metadata);
|
|
307
394
|
}
|
|
@@ -310,6 +397,8 @@ export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capabili
|
|
|
310
397
|
kind: "fly-rollback",
|
|
311
398
|
rollbackPolicy: "native",
|
|
312
399
|
async run(ctx, input) {
|
|
400
|
+
// The tree is checked against its digest before any flaps call.
|
|
401
|
+
const tree = input.source ? await sourceTreeFiles(input.source, "fly-rollback") : undefined;
|
|
313
402
|
const http = httpFor();
|
|
314
403
|
const { target } = loadTarget(input.plan, input.machine);
|
|
315
404
|
const live = await findMachine({ base: resolveEndpoint(input) }, target.app, target.name, http);
|
|
@@ -317,8 +406,19 @@ export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capabili
|
|
|
317
406
|
const serving = readMachineRelease(live.config?.metadata);
|
|
318
407
|
const to = input.to ?? serving?.previousDigest;
|
|
319
408
|
if (!to) throw new Error(`fly-rollback: ${target.app}/${target.name} serves ${serving?.digest ?? "no release"}, with no release before it to roll back to`);
|
|
320
|
-
const
|
|
321
|
-
|
|
409
|
+
const config = await recordedConfig(ctx, target.app, target.name, to);
|
|
410
|
+
if (tree) {
|
|
411
|
+
const differs = treeDifference(config, tree, intoOf(input.source!));
|
|
412
|
+
if (differs) {
|
|
413
|
+
throw new Error(`fly-rollback: the Machine config recorded for ${to} does not carry the tree ${input.source!.digest} (${differs}); refusing to restore it`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const restored = await restoreTo(ctx, input, target.app, target.name, to, config, serving?.digest);
|
|
417
|
+
log(
|
|
418
|
+
serving?.digest === to
|
|
419
|
+
? `fly-rollback: ${target.app}/${target.name} already serves ${to}`
|
|
420
|
+
: `fly-rollback: ${target.app}/${target.name} serves ${to} again (was ${serving?.digest ?? "no release"})`,
|
|
421
|
+
);
|
|
322
422
|
return {
|
|
323
423
|
uri: uriOf(target.app, target.name),
|
|
324
424
|
digest: to,
|
|
@@ -335,6 +435,23 @@ export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capabili
|
|
|
335
435
|
};
|
|
336
436
|
}
|
|
337
437
|
|
|
438
|
+
/**
|
|
439
|
+
* How a recorded Machine config's files under `into` differ from a source
|
|
440
|
+
* tree, or undefined when they are the same files with the same bytes. Pure.
|
|
441
|
+
*/
|
|
442
|
+
export function treeDifference(config: Record<string, unknown>, tree: MachineFile[], into: string): string | undefined {
|
|
443
|
+
const prefix = `${into}/`;
|
|
444
|
+
const recorded = new Map(((config.files as MachineFile[] | undefined) ?? []).filter((f) => f.guest_path.startsWith(prefix)).map((f) => [f.guest_path, f.raw_value]));
|
|
445
|
+
for (const f of tree) {
|
|
446
|
+
const bytes = recorded.get(f.guest_path);
|
|
447
|
+
if (bytes === undefined) return `${f.guest_path} is not in it`;
|
|
448
|
+
if (bytes !== f.raw_value) return `${f.guest_path} has other bytes`;
|
|
449
|
+
recorded.delete(f.guest_path);
|
|
450
|
+
}
|
|
451
|
+
const extra = [...recorded.keys()][0];
|
|
452
|
+
return extra ? `it also carries ${extra}` : undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
338
455
|
/** The default `fly-release` capability. */
|
|
339
456
|
export const flyReleaseCapability = createFlyReleaseCapability();
|
|
340
457
|
/** The default `fly-rollback` capability. */
|
|
@@ -11,10 +11,11 @@ import { describe, expect, test } from "vitest";
|
|
|
11
11
|
import { isCompositeDefinition } from "@intentius/chant";
|
|
12
12
|
import * as flyDeployModule from "./fly-deploy";
|
|
13
13
|
import * as flyOtelCollectorModule from "./fly-otel-collector";
|
|
14
|
+
import * as flySiteModule from "./fly-site";
|
|
14
15
|
import { compositeCatalog } from "./catalog";
|
|
15
16
|
import { flyPlugin } from "../plugin";
|
|
16
17
|
|
|
17
|
-
const exported = Object.entries({ ...flyDeployModule, ...flyOtelCollectorModule })
|
|
18
|
+
const exported = Object.entries({ ...flyDeployModule, ...flyOtelCollectorModule, ...flySiteModule })
|
|
18
19
|
.filter(([, value]) => isCompositeDefinition(value))
|
|
19
20
|
.map(([name]) => name)
|
|
20
21
|
.sort();
|
|
@@ -79,5 +79,103 @@ export const compositeCatalog: CompositeEntry[] = [
|
|
|
79
79
|
"description": "Per-member defaults for fine-grained overrides."
|
|
80
80
|
}
|
|
81
81
|
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "FlySite",
|
|
85
|
+
"lexicon": "fly",
|
|
86
|
+
"description": "Create a FlySite composite.",
|
|
87
|
+
"bundles": [
|
|
88
|
+
"App",
|
|
89
|
+
"IPAddress",
|
|
90
|
+
"Machine",
|
|
91
|
+
"Secret",
|
|
92
|
+
"Volume"
|
|
93
|
+
],
|
|
94
|
+
"params": [
|
|
95
|
+
{
|
|
96
|
+
"name": "app",
|
|
97
|
+
"type": "string",
|
|
98
|
+
"required": true,
|
|
99
|
+
"description": "The App's name."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "org",
|
|
103
|
+
"type": "string | Declarable",
|
|
104
|
+
"required": false,
|
|
105
|
+
"description": "The owning org (default: `Fly.OrgSlug`, which the build resolves from `FLY_ORG`)."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "region",
|
|
109
|
+
"type": "string | Declarable",
|
|
110
|
+
"required": false,
|
|
111
|
+
"description": "Region of the Machine and the Volume (default: `Fly.Region`, from `FLY_REGION`)."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "machine",
|
|
115
|
+
"type": "string",
|
|
116
|
+
"required": false,
|
|
117
|
+
"description": "The Machine's name (default: \"web\")."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"name": "image",
|
|
121
|
+
"type": "string",
|
|
122
|
+
"required": true,
|
|
123
|
+
"description": "The image the Machine runs until a release replaces it."
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "port",
|
|
127
|
+
"type": "number",
|
|
128
|
+
"required": false,
|
|
129
|
+
"description": "The port the app listens on inside the Machine (default: 8080)."
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "env",
|
|
133
|
+
"type": "Record<string, string>",
|
|
134
|
+
"required": false,
|
|
135
|
+
"description": "The Machine's env."
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "cpuKind",
|
|
139
|
+
"type": "string",
|
|
140
|
+
"required": false,
|
|
141
|
+
"description": "Guest CPU kind (default: \"shared\")."
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"name": "cpus",
|
|
145
|
+
"type": "number",
|
|
146
|
+
"required": false,
|
|
147
|
+
"description": "Guest CPUs (default: 1)."
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "memoryMb",
|
|
151
|
+
"type": "number",
|
|
152
|
+
"required": false,
|
|
153
|
+
"description": "Guest memory in MB (default: 256)."
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "volume",
|
|
157
|
+
"type": "{ name: string; sizeGb: number; path: string }",
|
|
158
|
+
"required": false,
|
|
159
|
+
"description": "A Volume for the app's data, mounted at `path`."
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"name": "ip",
|
|
163
|
+
"type": "\"shared_v4\" | \"v4\" | \"v6\"",
|
|
164
|
+
"required": false,
|
|
165
|
+
"description": "A public IP of this type (for example \"shared_v4\")."
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"name": "secrets",
|
|
169
|
+
"type": "Record<string, string | undefined>",
|
|
170
|
+
"required": false,
|
|
171
|
+
"description": "The app's Secrets, by name."
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"name": "defaults",
|
|
175
|
+
"type": "{ app?: Partial<Record<string, unknown>>; machine?: Partial<Record<string, unknown>>; volume?: Partial<Record<string,...",
|
|
176
|
+
"required": false,
|
|
177
|
+
"description": "Per-member defaults for fine-grained overrides."
|
|
178
|
+
}
|
|
179
|
+
]
|
|
82
180
|
}
|
|
83
181
|
];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FlySite composite (#2809): the App and the Machine a release ships to,
|
|
3
|
+
* with the Volume, IP and Secrets it is asked for, serialized to the flaps
|
|
4
|
+
* requests the applier and the `fly-release` steps read.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import type { Declarable } from "@intentius/chant";
|
|
8
|
+
import { flySerializer } from "../serializer";
|
|
9
|
+
import { releaseTarget } from "../op/activities/machine-release";
|
|
10
|
+
import { FlySite } from "./fly-site";
|
|
11
|
+
|
|
12
|
+
function plan(members: Record<string, Declarable>): Record<string, { endpoint: string; body: Record<string, unknown> }> {
|
|
13
|
+
return JSON.parse(flySerializer.serialize(new Map(Object.entries(members).map(([k, v]) => [`flySite_${k}`, v]))) as string);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("FlySite", () => {
|
|
17
|
+
test("declares the App, the Volume, the IP, the Secret and the one Machine, mounted and behind 443 and 80", () => {
|
|
18
|
+
const site = FlySite({
|
|
19
|
+
app: "notes",
|
|
20
|
+
org: "acme",
|
|
21
|
+
region: "iad",
|
|
22
|
+
image: "node:22-slim",
|
|
23
|
+
env: { PORT: "8080", APP_DATA: "/data" },
|
|
24
|
+
volume: { name: "data", sizeGb: 1, path: "/data" },
|
|
25
|
+
ip: "shared_v4",
|
|
26
|
+
secrets: { APP_SECRET: undefined },
|
|
27
|
+
});
|
|
28
|
+
expect(Object.keys(site.members).sort()).toEqual(["app", "appSecret", "ip", "machine", "volume"]);
|
|
29
|
+
|
|
30
|
+
const out = plan(site.members);
|
|
31
|
+
expect(out.flySite_app.body).toEqual({ app_name: "notes", org_slug: "acme" });
|
|
32
|
+
expect(out.flySite_volume).toMatchObject({ endpoint: "/v1/apps/notes/volumes", body: { name: "data", region: "iad", size_gb: 1 } });
|
|
33
|
+
expect(out.flySite_ip.endpoint).toBe("/v1/apps/notes/ip_assignments");
|
|
34
|
+
expect(out.flySite_appSecret.endpoint).toBe("/v1/apps/notes/secrets/APP_SECRET");
|
|
35
|
+
expect(out.flySite_machine).toMatchObject({
|
|
36
|
+
endpoint: "/v1/apps/notes/machines",
|
|
37
|
+
body: {
|
|
38
|
+
name: "web",
|
|
39
|
+
region: "iad",
|
|
40
|
+
config: {
|
|
41
|
+
image: "node:22-slim",
|
|
42
|
+
guest: { cpu_kind: "shared", cpus: 1, memory_mb: 256 },
|
|
43
|
+
mounts: [{ volume: "data", path: "/data" }],
|
|
44
|
+
services: [{ protocol: "tcp", internal_port: 8080, ports: [{ port: 443, handlers: ["tls", "http"] }, { port: 80, handlers: ["http"] }] }],
|
|
45
|
+
env: { PORT: "8080", APP_DATA: "/data" },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A release finds the Machine without being told its name.
|
|
51
|
+
expect(releaseTarget(out as never)).toMatchObject({ app: "notes", entity: "flySite_machine", name: "web" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("declares only the App and the Machine when nothing else is asked for", () => {
|
|
55
|
+
const site = FlySite({ app: "bare", org: "acme", region: "ord", machine: "api", image: "nginx:1", port: 80 });
|
|
56
|
+
expect(Object.keys(site.members).sort()).toEqual(["app", "machine"]);
|
|
57
|
+
const out = plan(site.members);
|
|
58
|
+
expect(out.flySite_machine.body).toMatchObject({ name: "api", config: { image: "nginx:1", services: [{ internal_port: 80 }] } });
|
|
59
|
+
expect((out.flySite_machine.body.config as Record<string, unknown>).mounts).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a Secret never takes another member's name", () => {
|
|
63
|
+
const site = FlySite({ app: "x", org: "acme", region: "iad", image: "i", secrets: { APP: "v", DB_URL: "u" } });
|
|
64
|
+
expect(Object.keys(site.members).sort()).toEqual(["app", "appSecret", "dbUrl", "machine"]);
|
|
65
|
+
});
|
|
66
|
+
});
|