@intentius/chant-lexicon-fly 0.90.0 → 0.91.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.
Files changed (50) hide show
  1. package/dist/components/capability-plugin.d.ts +1 -0
  2. package/dist/components/capability-plugin.d.ts.map +1 -1
  3. package/dist/components/fly-release.d.ts +148 -0
  4. package/dist/components/fly-release.d.ts.map +1 -0
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/describe-resources.d.ts +11 -0
  8. package/dist/describe-resources.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +3 -3
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/fly-apply.d.ts +8 -22
  14. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  15. package/dist/op/activities/index.d.ts +2 -0
  16. package/dist/op/activities/index.d.ts.map +1 -1
  17. package/dist/op/activities/machine-release.d.ts +176 -0
  18. package/dist/op/activities/machine-release.d.ts.map +1 -0
  19. package/dist/op/activities/machines-contract.d.ts +8 -0
  20. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  21. package/dist/op/activities/machines-fake.d.ts +53 -0
  22. package/dist/op/activities/machines-fake.d.ts.map +1 -0
  23. package/dist/op/builders.d.ts +13 -0
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/release-metadata.d.ts +48 -0
  26. package/dist/release-metadata.d.ts.map +1 -0
  27. package/dist/release-store.d.ts +36 -0
  28. package/dist/release-store.d.ts.map +1 -0
  29. package/dist/skills/chant-fly-ops.md +33 -0
  30. package/package.json +2 -2
  31. package/src/components/capability-plugin.ts +11 -3
  32. package/src/components/fly-release.test.ts +244 -0
  33. package/src/components/fly-release.ts +341 -0
  34. package/src/components/index.ts +16 -0
  35. package/src/describe-resources.test.ts +18 -3
  36. package/src/describe-resources.ts +69 -5
  37. package/src/index.ts +12 -0
  38. package/src/op/activities/fly-apply.ts +15 -3
  39. package/src/op/activities/index.ts +23 -0
  40. package/src/op/activities/machine-release.integration.test.ts +113 -0
  41. package/src/op/activities/machine-release.test.ts +138 -0
  42. package/src/op/activities/machine-release.ts +407 -0
  43. package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
  44. package/src/op/activities/machines-contract.test.ts +11 -1
  45. package/src/op/activities/machines-contract.ts +17 -0
  46. package/src/op/activities/machines-fake.ts +165 -0
  47. package/src/op/builders.ts +25 -0
  48. package/src/release-metadata.ts +72 -0
  49. package/src/release-store.ts +70 -0
  50. package/src/skills/chant-fly-ops.md +33 -0
@@ -0,0 +1,113 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { flapsUp, flapsDown } from "./flaps";
6
+ import { defaultFlyHttp, deleteApp } from "./fly-apply";
7
+ import { findMachine } from "./machine-release";
8
+ import { readMachineRelease } from "../../release-metadata";
9
+ import { createFlyReleaseCapability, createFlyRollbackCapability } from "../../components/fly-release";
10
+ import { memoryMachineConfigStore } from "../../release-store";
11
+ import { describeResources } from "../../describe-resources";
12
+ import type { EffectReceiptRef } from "@intentius/chant/op/receipt-store";
13
+
14
+ // A release, a migration, a second release and a rollback against a live
15
+ // mudflaps container (#2736): the `fly-release` / `fly-rollback` capabilities
16
+ // over the real Machines wire protocol, then describeResources reading the
17
+ // release back the way `chant components status --live` does. Docker is
18
+ // required; the suite skips in CI unless a job opts in with FLY_IT=1, like
19
+ // ./fly-apply.integration.test.ts. No Fly credentials: mudflaps ignores tokens.
20
+
21
+ const APP = "chant-release-it";
22
+ const CONTAINER = "chant-mudflaps-release-it";
23
+ const PORT = 4284;
24
+ const WAIT = { intervalMs: 50, timeoutSecs: 5, deadlineMs: 30_000 };
25
+ const http = defaultFlyHttp();
26
+
27
+ let endpoint = "";
28
+ let available = false;
29
+ let tmp = "";
30
+
31
+ beforeAll(async () => {
32
+ if (process.env.CI && !process.env.FLY_IT) return;
33
+ tmp = mkdtempSync(join(tmpdir(), "fly-release-it-"));
34
+ try {
35
+ endpoint = (await flapsUp({ name: CONTAINER, port: PORT, timeoutMs: 30_000 })).endpoint;
36
+ available = true;
37
+ } catch {
38
+ available = false;
39
+ }
40
+ }, 60_000);
41
+
42
+ afterAll(async () => {
43
+ if (available) {
44
+ await deleteApp({ base: endpoint }, APP, http).catch(() => undefined);
45
+ await flapsDown({ name: CONTAINER });
46
+ }
47
+ if (tmp) rmSync(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ describe("fly-release against mudflaps", () => {
51
+ test("release, migrate, release again, roll back; the Machine's metadata names each release", async (ctx) => {
52
+ if (!available) ctx.skip();
53
+ const planPath = join(tmp, "fly.json");
54
+ writeFileSync(
55
+ planPath,
56
+ JSON.stringify({
57
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
58
+ web: {
59
+ endpoint: `/v1/apps/${APP}/machines`,
60
+ method: "POST",
61
+ body: { name: "web", region: "iad", config: { image: "nginx:1", metadata: { "managed-by": "chant" } } },
62
+ },
63
+ }),
64
+ );
65
+ const configs = memoryMachineConfigStore();
66
+ const receipts = new Map<string, string>();
67
+ const deps = {
68
+ http,
69
+ configStore: () => configs,
70
+ receiptStore: () => ({
71
+ read: async (r: EffectReceiptRef) => receipts.get(r.name),
72
+ write: async (r: EffectReceiptRef, v: string) => void receipts.set(r.name, v),
73
+ }),
74
+ headCommit: async () => "1111111111111111111111111111111111111111",
75
+ log: () => undefined,
76
+ };
77
+ const release = createFlyReleaseCapability(deps);
78
+ const rollback = createFlyRollbackCapability(deps);
79
+ const c = { env: "it", component: "web" };
80
+ const common = { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } };
81
+
82
+ const one = await release.run(c, { ...common, digest: "sha256:one", image: "nginx:1", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
83
+ expect(one.migrations).toEqual([{ name: "001.sql", fired: true }]);
84
+ const two = await release.run(c, { ...common, digest: "sha256:two", image: "nginx:2", migrations: [{ name: "001.sql", command: "echo migrate 001" }] });
85
+ expect(two.migrations).toEqual([{ name: "001.sql", fired: false }]);
86
+ expect(two.previous?.digest).toBe("sha256:one");
87
+
88
+ let m = await findMachine({ base: endpoint }, APP, "web", http);
89
+ expect(readMachineRelease(m?.config?.metadata)).toMatchObject({ digest: "sha256:two", previousDigest: "sha256:one" });
90
+ expect(m?.config?.image).toBe("nginx:2");
91
+
92
+ const observed = await describeResources(
93
+ {
94
+ environment: "it",
95
+ buildOutput: "",
96
+ entityNames: ["app", "web"],
97
+ entities: new Map([
98
+ ["app", { entityType: "Fly::Machines::App", props: { name: APP } }],
99
+ ["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } }],
100
+ ]),
101
+ endpoint,
102
+ },
103
+ http,
104
+ );
105
+ expect((observed.resources.web?.attributes as { digest?: string }).digest).toBe("sha256:two");
106
+
107
+ const back = await rollback.run(c, { plan: planPath, endpoint, wait: WAIT, verify: { intervalMs: 100, timeoutMs: 10_000 } });
108
+ expect(back.digest).toBe("sha256:one");
109
+ m = await findMachine({ base: endpoint }, APP, "web", http);
110
+ expect(m?.config?.image).toBe("nginx:1");
111
+ expect(readMachineRelease(m?.config?.metadata)?.digest).toBe("sha256:one");
112
+ }, 120_000);
113
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ RELEASE_METADATA_KEYS,
4
+ flyMachineExec,
5
+ flyMachineRelease,
6
+ flyMachineRestart,
7
+ flyMachineRestore,
8
+ flyMachineStop,
9
+ flyMachineVerify,
10
+ readMachineRelease,
11
+ releaseTarget,
12
+ withReleaseMetadata,
13
+ } from "./machine-release";
14
+ import { createMachinesFake } from "./machines-fake";
15
+ import type { FlyPlan } from "./fly-apply";
16
+
17
+ const ENDPOINT = "http://flaps.test";
18
+ const NO_WAIT = { intervalMs: 0, deadlineMs: 2_000 };
19
+
20
+ const PLAN: FlyPlan = {
21
+ shop: { endpoint: "/v1/apps", method: "POST", body: { app_name: "shop", org_slug: "personal" } },
22
+ web: {
23
+ endpoint: "/v1/apps/shop/machines",
24
+ method: "POST",
25
+ body: { name: "web", config: { image: "shop:1", env: { PORT: "8080" }, metadata: { "managed-by": "chant" } } },
26
+ },
27
+ };
28
+
29
+ describe("release metadata (pure)", () => {
30
+ test("withReleaseMetadata stamps the release and keeps other metadata", () => {
31
+ const config = withReleaseMetadata({ image: "x", metadata: { "managed-by": "chant", [RELEASE_METADATA_KEYS.release]: "old" } }, {
32
+ digest: "sha256:a",
33
+ gitSha: "abc1234",
34
+ });
35
+ expect(config.metadata).toEqual({
36
+ "managed-by": "chant",
37
+ [RELEASE_METADATA_KEYS.digest]: "sha256:a",
38
+ [RELEASE_METADATA_KEYS.gitSha]: "abc1234",
39
+ });
40
+ expect(readMachineRelease(config.metadata)).toEqual({ digest: "sha256:a", gitSha: "abc1234" });
41
+ });
42
+
43
+ test("readMachineRelease names no release without a digest", () => {
44
+ expect(readMachineRelease({ "managed-by": "chant" })).toBeUndefined();
45
+ expect(readMachineRelease(undefined)).toBeUndefined();
46
+ });
47
+
48
+ test("releaseTarget picks the plan's one Machine, or the one named", () => {
49
+ expect(releaseTarget(PLAN)).toMatchObject({ app: "shop", entity: "web", name: "web" });
50
+ expect(() => releaseTarget(PLAN, "worker")).toThrow(/no Machine named "worker"/);
51
+ const two: FlyPlan = { ...PLAN, worker: { endpoint: "/v1/apps/shop/machines", method: "POST", body: { name: "worker", config: {} } } };
52
+ expect(() => releaseTarget(two)).toThrow(/declares 2 Machines/);
53
+ expect(releaseTarget(two, "worker").name).toBe("worker");
54
+ });
55
+ });
56
+
57
+ describe("Machines activities against the fake", () => {
58
+ test("a first release creates the App and Machine with the release in its metadata", async () => {
59
+ const fake = createMachinesFake();
60
+ const r = await flyMachineRelease(
61
+ { plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one", gitSha: "1111111" }, image: "shop@sha256:img1", env: { MODE: "prod" }, wait: NO_WAIT },
62
+ undefined,
63
+ fake.http,
64
+ );
65
+ expect(r).toMatchObject({ app: "shop", action: "created", previous: null, release: { digest: "sha256:one", gitSha: "1111111" } });
66
+ const m = fake.machine("shop", "web")!;
67
+ expect(m.config.image).toBe("shop@sha256:img1");
68
+ expect(m.config.env).toEqual({ PORT: "8080", MODE: "prod" });
69
+ expect(readMachineRelease(m.config.metadata)).toEqual({ digest: "sha256:one", gitSha: "1111111" });
70
+ expect(m.config.metadata?.["managed-by"]).toBe("chant");
71
+ });
72
+
73
+ test("a second release records the one it replaced, and re-running it keeps that", async () => {
74
+ const fake = createMachinesFake();
75
+ const args = { plan: PLAN, endpoint: ENDPOINT, wait: NO_WAIT };
76
+ await flyMachineRelease({ ...args, release: { digest: "sha256:one" } }, undefined, fake.http);
77
+ const two = await flyMachineRelease({ ...args, release: { digest: "sha256:two" } }, undefined, fake.http);
78
+ expect(two.action).toBe("updated");
79
+ expect(two.release.previousDigest).toBe("sha256:one");
80
+ expect(two.previous?.release?.digest).toBe("sha256:one");
81
+ expect(readMachineRelease(two.previous?.config.metadata)?.digest).toBe("sha256:one");
82
+ const again = await flyMachineRelease({ ...args, release: { digest: "sha256:two" } }, undefined, fake.http);
83
+ expect(again.action).toBe("noop");
84
+ expect(again.release.previousDigest).toBe("sha256:one");
85
+ });
86
+
87
+ test("exec runs a command in the Machine and fails on a non-zero exit", async () => {
88
+ const fake = createMachinesFake({ exec: (_app, _m, cmd) => (cmd.join(" ").includes("bad") ? { exit_code: 3, stderr: "no table" } : { exit_code: 0, stdout: "ok" }) });
89
+ await flyMachineRelease({ plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, fake.http);
90
+ const ok = await flyMachineExec({ app: "shop", machine: "web", command: "migrate 001", endpoint: ENDPOINT }, undefined, fake.http);
91
+ expect(ok).toEqual({ exitCode: 0, stdout: "ok", stderr: "" });
92
+ expect(fake.execs[0].command).toEqual(["sh", "-c", "migrate 001"]);
93
+ await expect(flyMachineExec({ app: "shop", machine: "web", command: ["bad"], endpoint: ENDPOINT }, undefined, fake.http)).rejects.toThrow(
94
+ /exited 3.*no table/,
95
+ );
96
+ });
97
+
98
+ test("stop and restart settle the Machine under a lease", async () => {
99
+ const fake = createMachinesFake();
100
+ await flyMachineRelease({ plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one" }, wait: NO_WAIT }, undefined, fake.http);
101
+ await flyMachineStop({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
102
+ expect(fake.machine("shop", "web")!.state).toBe("stopped");
103
+ await flyMachineRestart({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
104
+ expect(fake.machine("shop", "web")!.state).toBe("started");
105
+ expect(fake.calls.filter((c) => c.endsWith("/lease"))).toHaveLength(4);
106
+ });
107
+
108
+ test("verify: started with the release, and the health endpoint reports it", async () => {
109
+ const fake = createMachinesFake();
110
+ await flyMachineRelease(
111
+ { plan: PLAN, endpoint: ENDPOINT, release: { digest: "sha256:one", gitSha: "1111111" }, wait: NO_WAIT },
112
+ undefined,
113
+ fake.http,
114
+ );
115
+ const health = (revision: string) => (async () => new Response(JSON.stringify({ status: "healthy", revision }))) as unknown as typeof fetch;
116
+ const base = { app: "shop", machine: "web", endpoint: ENDPOINT, intervalMs: 1, timeoutMs: 20 };
117
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one", url: "https://shop.test" }, undefined, fake.http, health("1111111"))).resolves.toMatchObject({
118
+ release: { digest: "sha256:one" },
119
+ });
120
+ await expect(flyMachineVerify({ ...base, digest: "sha256:two" }, undefined, fake.http)).rejects.toThrow(/serves sha256:one, expected sha256:two/);
121
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one", url: "https://shop.test" }, undefined, fake.http, health("2222222"))).rejects.toThrow(
122
+ /reports 2222222/,
123
+ );
124
+ await flyMachineStop({ app: "shop", machine: "web", endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
125
+ await expect(flyMachineVerify({ ...base, digest: "sha256:one" }, undefined, fake.http)).rejects.toThrow(/is stopped/);
126
+ });
127
+
128
+ test("restore puts a recorded Machine config back", async () => {
129
+ const fake = createMachinesFake();
130
+ const args = { plan: PLAN, endpoint: ENDPOINT, wait: NO_WAIT };
131
+ const one = await flyMachineRelease({ ...args, release: { digest: "sha256:one" }, image: "shop:1" }, undefined, fake.http);
132
+ await flyMachineRelease({ ...args, release: { digest: "sha256:two" }, image: "shop:2" }, undefined, fake.http);
133
+ expect(fake.machine("shop", "web")!.config.image).toBe("shop:2");
134
+ const restored = await flyMachineRestore({ app: "shop", machine: "web", config: one.config, endpoint: ENDPOINT, wait: NO_WAIT }, undefined, fake.http);
135
+ expect(restored.release?.digest).toBe("sha256:one");
136
+ expect(fake.machine("shop", "web")!.config).toEqual(one.config);
137
+ });
138
+ });
@@ -0,0 +1,407 @@
1
+ /**
2
+ * The release a Fly Machine serves, and the Machines activities that put one
3
+ * there (#2736, ws-056).
4
+ *
5
+ * chud's `Chud::FlySite` ran a site as one App served by one Machine and kept
6
+ * the release in the Machine's metadata. That moves here: the App and the
7
+ * Machine are this lexicon's own resources, and a release is the declared
8
+ * Machine with {@link RELEASE_METADATA_KEYS} stamped into its
9
+ * `config.metadata`. Because the record is on the Machine itself, any checkout
10
+ * can read which release is serving (`describeResources` reports it, and
11
+ * `chant components status --live` compares it with the release ledger).
12
+ *
13
+ * The site steps are activities over the Machines API, each usable from an
14
+ * Op by name and composed by the `fly-release` and `fly-rollback` capabilities
15
+ * (../../components/fly-release.ts):
16
+ *
17
+ * - {@link flyMachineRelease}: upload and start. Apply the declared plan with
18
+ * the Machine serving the release (its image or env overridden when the
19
+ * release says so), and report the release and config it replaced.
20
+ * - {@link flyMachineExec}: run a command inside the Machine (a migration).
21
+ * - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
22
+ * wait for the Machine to settle.
23
+ * - {@link flyMachineVerify}: the Machine is started with this release in its
24
+ * metadata, and, given a URL, its health endpoint answers.
25
+ * - {@link flyMachineRestore}: put a recorded Machine config back (restore on
26
+ * a failed release, and rollback).
27
+ *
28
+ * Every activity takes the flaps endpoint and token the way `flyApply` does
29
+ * (`FLY_FLAPS_BASE_URL`, `FLY_API_TOKEN`), so the same code reaches mudflaps
30
+ * or a real Fly org, and an injectable `http` for tests (./machines-fake.ts).
31
+ */
32
+
33
+ import {
34
+ appNameFromRequest,
35
+ defaultFlyHttp,
36
+ flyApply,
37
+ isAppRequest,
38
+ isMachineRequest,
39
+ listMachines,
40
+ machineAppSegment,
41
+ resolveApp,
42
+ resolveEndpoint,
43
+ waitForMachine,
44
+ withLease,
45
+ LEASE_NONCE_HEADER,
46
+ type ApplyCtx,
47
+ type FlapsMachine,
48
+ type FlapsRequest,
49
+ type FlyHttp,
50
+ type FlyPlan,
51
+ type WaitOpts,
52
+ } from "./fly-apply";
53
+ import { parsePlan } from "./fly-apply";
54
+ import { readFileSync } from "node:fs";
55
+ import { readMachineRelease, withReleaseMetadata, type MachineConfig, type MachineRelease } from "../../release-metadata";
56
+
57
+ export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "../../release-metadata";
58
+ export type { MachineRelease } from "../../release-metadata";
59
+
60
+ /** The App and Machine a release targets in a plan. */
61
+ export interface ReleaseTarget {
62
+ app: string;
63
+ /** The plan entity that declares the Machine. */
64
+ entity: string;
65
+ /** The Machine's name on Fly (its `name`, else the entity name). */
66
+ name: string;
67
+ request: FlapsRequest;
68
+ }
69
+
70
+ /**
71
+ * The Machine a release targets: the plan's one Machine, or the one `machine`
72
+ * names (an entity name or a Machine name). Pure.
73
+ */
74
+ export function releaseTarget(plan: FlyPlan, machine?: string): ReleaseTarget {
75
+ const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
76
+ const soleApp = appNames.length === 1 ? appNames[0] : undefined;
77
+ const machines = Object.entries(plan)
78
+ .filter(([, req]) => isMachineRequest(req))
79
+ .map(([entity, request]) => ({
80
+ entity,
81
+ request,
82
+ name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
83
+ }));
84
+ const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
85
+ if (picked.length !== 1) {
86
+ throw new Error(
87
+ machine
88
+ ? `the fly plan declares no Machine named "${machine}" (declared: ${machines.map((m) => m.entity).join(", ") || "none"})`
89
+ : `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`,
90
+ );
91
+ }
92
+ const [m] = picked;
93
+ return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
94
+ }
95
+
96
+ const TERMINAL = new Set(["destroyed", "destroying"]);
97
+
98
+ /** The live Machine named `name` (or with id `name`) in `app`; undefined when there is none. */
99
+ export async function findMachine(
100
+ ctx: ApplyCtx,
101
+ app: string,
102
+ name: string,
103
+ http: FlyHttp,
104
+ signal?: AbortSignal,
105
+ ): Promise<FlapsMachine | undefined> {
106
+ const live = (await listMachines(ctx, app, http, signal)).filter((m) => !TERMINAL.has(m.state));
107
+ return live.find((m) => m.name === name) ?? live.find((m) => m.id === name);
108
+ }
109
+
110
+ async function mustFindMachine(ctx: ApplyCtx, app: string, name: string, http: FlyHttp, signal?: AbortSignal): Promise<FlapsMachine> {
111
+ const m = await findMachine(ctx, app, name, http, signal);
112
+ if (!m) throw new Error(`no machine ${name} on app ${app}`);
113
+ return m;
114
+ }
115
+
116
+ const machinePath = (ctx: ApplyCtx, app: string, id: string) =>
117
+ `${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
118
+
119
+ /** Where to reach flaps, as every activity here takes it. */
120
+ export interface FlapsTarget {
121
+ /** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
122
+ endpoint?: string;
123
+ /** Bearer token for real Fly. Default: `FLY_API_TOKEN`. */
124
+ token?: string;
125
+ }
126
+
127
+ // ── flyMachineRelease ─────────────────────────────────────────────────────────
128
+
129
+ export interface FlyMachineReleaseArgs extends FlapsTarget {
130
+ /** Path to the fly build output (`chant build --lexicon fly -o <path>`). */
131
+ planPath?: string;
132
+ /** The plan itself, in place of `planPath`. */
133
+ plan?: FlyPlan;
134
+ /** The Machine that serves the release, when the plan declares more than one. */
135
+ machine?: string;
136
+ /** The release to serve. `previousDigest` is filled in from the Machine it replaces. */
137
+ release: MachineRelease;
138
+ /** An image to run in place of the declared one (a digest-pinned image a publish step produced). */
139
+ image?: string;
140
+ /** Env to add to the declared Machine's env. */
141
+ env?: Record<string, string>;
142
+ wait?: WaitOpts;
143
+ }
144
+
145
+ export interface FlyMachineReleaseResult {
146
+ app: string;
147
+ machine: { id: string; name: string };
148
+ /** What the apply did to the Machine. */
149
+ action: "created" | "updated" | "noop";
150
+ /** The release the Machine serves now. */
151
+ release: MachineRelease;
152
+ /** The Machine config applied: what a later rollback to this release puts back. */
153
+ config: MachineConfig;
154
+ /** What the Machine served before, when it existed: its release (if it named one) and its config. */
155
+ previous: { release?: MachineRelease; config: MachineConfig } | null;
156
+ }
157
+
158
+ /**
159
+ * Upload and start: apply the declared plan (the App, its Volumes, IPs,
160
+ * Secrets) with the Machine serving `release`. The Machine is updated in place
161
+ * under a lease, so the release serves from here on. A Machine that already
162
+ * serves this release keeps the `previousDigest` it shipped with, so running a
163
+ * release twice never makes it its own predecessor.
164
+ */
165
+ export async function flyMachineRelease(
166
+ args: FlyMachineReleaseArgs,
167
+ signal?: AbortSignal,
168
+ http: FlyHttp = defaultFlyHttp(args.token),
169
+ ): Promise<FlyMachineReleaseResult> {
170
+ const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
171
+ const target = releaseTarget(plan, args.machine);
172
+ const ctx: ApplyCtx = { base: resolveEndpoint(args) };
173
+
174
+ const live = await findMachine(ctx, target.app, target.name, http, signal).catch(() => undefined);
175
+ const liveConfig = live?.config as MachineConfig | undefined;
176
+ const liveRelease = readMachineRelease(liveConfig?.metadata);
177
+ const previousDigest =
178
+ liveRelease && liveRelease.digest !== args.release.digest ? liveRelease.digest : liveRelease?.previousDigest ?? args.release.previousDigest;
179
+ const release: MachineRelease = { ...args.release, ...(previousDigest ? { previousDigest } : {}) };
180
+ if (!previousDigest) delete release.previousDigest;
181
+
182
+ const declared = (target.request.body.config ?? {}) as MachineConfig;
183
+ const config = withReleaseMetadata(
184
+ {
185
+ ...declared,
186
+ ...(args.image ? { image: args.image } : {}),
187
+ ...(args.env ? { env: { ...(declared.env ?? {}), ...args.env } } : {}),
188
+ },
189
+ release,
190
+ );
191
+ const request: FlapsRequest = { ...target.request, body: { ...target.request.body, config } };
192
+ const applied = await flyApply(
193
+ { plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait },
194
+ signal,
195
+ http,
196
+ );
197
+ const action = applied.machines.find((m) => m.app === target.app && m.name === target.name)?.action ?? "noop";
198
+ const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
199
+ return {
200
+ app: target.app,
201
+ machine: { id: now.id, name: target.name },
202
+ action,
203
+ release,
204
+ config,
205
+ previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
206
+ };
207
+ }
208
+
209
+ function requirePlanPath(planPath: string | undefined): string {
210
+ if (!planPath) throw new Error("pass planPath (the fly build output) or plan");
211
+ return planPath;
212
+ }
213
+
214
+ // ── flyMachineExec ────────────────────────────────────────────────────────────
215
+
216
+ export interface FlyMachineExecArgs extends FlapsTarget {
217
+ app: string;
218
+ /** The Machine's name or id. */
219
+ machine: string;
220
+ /** The command: an argv, or a string run with `sh -c`. */
221
+ command: string[] | string;
222
+ /** Seconds the Machines API lets the command run. Default 120. */
223
+ timeoutSecs?: number;
224
+ }
225
+
226
+ export interface FlyMachineExecResult {
227
+ exitCode: number;
228
+ stdout: string;
229
+ stderr: string;
230
+ }
231
+
232
+ /**
233
+ * Run a command inside the Machine (the Machines API's exec). A non-zero exit
234
+ * throws, with the command's output, so the step fails rather than going on
235
+ * over a half-migrated database.
236
+ */
237
+ export async function flyMachineExec(
238
+ args: FlyMachineExecArgs,
239
+ signal?: AbortSignal,
240
+ http: FlyHttp = defaultFlyHttp(args.token),
241
+ ): Promise<FlyMachineExecResult> {
242
+ const ctx: ApplyCtx = { base: resolveEndpoint(args) };
243
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
244
+ const command = typeof args.command === "string" ? ["sh", "-c", args.command] : args.command;
245
+ const res = await http("POST", `${machinePath(ctx, args.app, m.id)}/exec`, { command, timeout: args.timeoutSecs ?? 120 }, undefined, signal);
246
+ if (res.status >= 300) throw new Error(`exec on ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
247
+ let out: { exit_code?: number; stdout?: string; stderr?: string } = {};
248
+ try {
249
+ out = JSON.parse(res.text) as typeof out;
250
+ } catch {
251
+ throw new Error(`exec on ${args.app}/${m.id} answered no exec result: ${res.text}`);
252
+ }
253
+ const result = { exitCode: out.exit_code ?? 0, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
254
+ if (result.exitCode !== 0) {
255
+ const said = [result.stderr, result.stdout].filter(Boolean).join("\n").trim().slice(-2000);
256
+ throw new Error(`${command.join(" ")} exited ${result.exitCode} on ${args.app}/${m.id}${said ? `: ${said}` : ""}`);
257
+ }
258
+ return result;
259
+ }
260
+
261
+ // ── flyMachineRestart / flyMachineStop ────────────────────────────────────────
262
+
263
+ export interface FlyMachineStateArgs extends FlapsTarget {
264
+ app: string;
265
+ /** The Machine's name or id. */
266
+ machine: string;
267
+ wait?: WaitOpts;
268
+ }
269
+
270
+ async function transition(
271
+ args: FlyMachineStateArgs,
272
+ verb: "restart" | "stop",
273
+ signal: AbortSignal | undefined,
274
+ http: FlyHttp,
275
+ ): Promise<{ id: string; state: string }> {
276
+ const ctx: ApplyCtx = { base: resolveEndpoint(args) };
277
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
278
+ const state = verb === "stop" ? "stopped" : "started";
279
+ if (verb === "stop" && m.state !== "started") return { id: m.id, state: m.state };
280
+ const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
281
+ http("POST", `${machinePath(ctx, args.app, m.id)}/${verb}`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
282
+ );
283
+ if (res.status >= 300) throw new Error(`${verb} of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
284
+ await waitForMachine(ctx, args.app, m.id, "", http, signal, { ...(args.wait ?? {}), state });
285
+ return { id: m.id, state };
286
+ }
287
+
288
+ /** Restart the Machine under a lease (the app starts again on migrated data), and wait for it to be started. */
289
+ export async function flyMachineRestart(
290
+ args: FlyMachineStateArgs,
291
+ signal?: AbortSignal,
292
+ http: FlyHttp = defaultFlyHttp(args.token),
293
+ ): Promise<{ id: string; state: string }> {
294
+ return transition(args, "restart", signal, http);
295
+ }
296
+
297
+ /** Stop the Machine under a lease, and wait for it to be stopped. A Machine that is not started is left alone. */
298
+ export async function flyMachineStop(
299
+ args: FlyMachineStateArgs,
300
+ signal?: AbortSignal,
301
+ http: FlyHttp = defaultFlyHttp(args.token),
302
+ ): Promise<{ id: string; state: string }> {
303
+ return transition(args, "stop", signal, http);
304
+ }
305
+
306
+ // ── flyMachineVerify ──────────────────────────────────────────────────────────
307
+
308
+ export interface FlyMachineVerifyArgs extends FlapsTarget {
309
+ app: string;
310
+ machine: string;
311
+ /** The release digest the Machine must name. */
312
+ digest: string;
313
+ /** The app's public URL; with it, its health endpoint must answer. */
314
+ url?: string;
315
+ /** Path of the health endpoint under `url`. Default `/health`. */
316
+ healthPath?: string;
317
+ /** How long to keep asking. Default 120s. */
318
+ timeoutMs?: number;
319
+ /** Delay between asks. Default 1s. */
320
+ intervalMs?: number;
321
+ }
322
+
323
+ /**
324
+ * The Machine is started and its metadata names release `digest`. Given a
325
+ * `url`, its health endpoint answers 2xx too, and when that answer is JSON
326
+ * naming a `revision` or `digest`, it is this release's (the commit or the
327
+ * digest). Retries until `timeoutMs`, then throws the last reason.
328
+ */
329
+ export async function flyMachineVerify(
330
+ args: FlyMachineVerifyArgs,
331
+ signal?: AbortSignal,
332
+ http: FlyHttp = defaultFlyHttp(args.token),
333
+ fetchImpl: typeof fetch = fetch,
334
+ ): Promise<{ id: string; release: MachineRelease }> {
335
+ const ctx: ApplyCtx = { base: resolveEndpoint(args) };
336
+ const deadline = Date.now() + (args.timeoutMs ?? 120_000);
337
+ const interval = args.intervalMs ?? 1_000;
338
+ let why = "";
339
+ for (;;) {
340
+ if (signal?.aborted) throw new Error("flyMachineVerify aborted");
341
+ try {
342
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
343
+ if (m.state !== "started") throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
344
+ const release = readMachineRelease(m.config?.metadata);
345
+ if (release?.digest !== args.digest) {
346
+ throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
347
+ }
348
+ if (args.url) {
349
+ const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
350
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(5_000) });
351
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
352
+ let body: { revision?: unknown; digest?: unknown } | undefined;
353
+ try {
354
+ body = (await res.json()) as typeof body;
355
+ } catch {
356
+ body = undefined;
357
+ }
358
+ const reported = typeof body?.revision === "string" ? body.revision : typeof body?.digest === "string" ? body.digest : undefined;
359
+ if (reported !== undefined && reported !== release.gitSha && reported !== release.digest) {
360
+ throw new Error(`${url} reports ${reported}, expected ${release.gitSha ?? release.digest}`);
361
+ }
362
+ }
363
+ return { id: m.id, release };
364
+ } catch (err) {
365
+ why = err instanceof Error ? err.message : String(err);
366
+ }
367
+ if (Date.now() + interval > deadline) throw new Error(`verify ${args.app}/${args.machine}: ${why}`);
368
+ await new Promise((r) => setTimeout(r, interval));
369
+ }
370
+ }
371
+
372
+ // ── flyMachineRestore ─────────────────────────────────────────────────────────
373
+
374
+ export interface FlyMachineRestoreArgs extends FlapsTarget {
375
+ app: string;
376
+ machine: string;
377
+ /** The Machine config to put back, as a release applied it (its metadata names that release). */
378
+ config: MachineConfig;
379
+ wait?: WaitOpts;
380
+ }
381
+
382
+ /**
383
+ * Put a recorded Machine config back: update the Machine to exactly `config`
384
+ * under a lease and wait for it to be started. Used on a failed release (the
385
+ * config it replaced) and by a rollback (the config an earlier release applied).
386
+ */
387
+ export async function flyMachineRestore(
388
+ args: FlyMachineRestoreArgs,
389
+ signal?: AbortSignal,
390
+ http: FlyHttp = defaultFlyHttp(args.token),
391
+ ): Promise<{ id: string; release?: MachineRelease }> {
392
+ const ctx: ApplyCtx = { base: resolveEndpoint(args) };
393
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
394
+ const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
395
+ http("POST", machinePath(ctx, args.app, m.id), { name: m.name, config: args.config }, { [LEASE_NONCE_HEADER]: nonce }, signal),
396
+ );
397
+ if (res.status >= 300) throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
398
+ let instance = "";
399
+ try {
400
+ instance = (JSON.parse(res.text) as { instance_id?: string }).instance_id ?? "";
401
+ } catch {
402
+ instance = "";
403
+ }
404
+ await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
405
+ const release = readMachineRelease(args.config.metadata);
406
+ return { id: m.id, ...(release ? { release } : {}) };
407
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
2
  import { flapsUp, flapsDown } from "./flaps";
3
- import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
3
+ import { MACHINES_CONTRACT, MACHINE_RELEASE_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
4
4
 
5
5
  // Fidelity check: every flaps endpoint the flyApply applier depends on
6
6
  // (MACHINES_CONTRACT) must be served by the pinned mudflaps image — the twin of
@@ -69,4 +69,13 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
69
69
  expect(roadmap.has(key), `${e.op} → ${e.method} ${e.path} is a mudflaps roadmap endpoint`).toBe(false);
70
70
  }
71
71
  });
72
+
73
+ test("the pinned mudflaps serves every endpoint the release activities depend on (#2736)", async (ctx) => {
74
+ if (!available) ctx.skip();
75
+ const res = await fetch(`${endpoint}/_mudflaps/health`);
76
+ const health = (await res.json()) as { implemented?: string[] };
77
+ const served = new Set((health.implemented ?? []).map(normalizeImplemented));
78
+ const missing = MACHINE_RELEASE_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)).filter((k) => !served.has(k));
79
+ expect(missing, `mudflaps is missing release endpoints: ${missing.join(", ")}`).toEqual([]);
80
+ });
72
81
  });