@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.
- package/dist/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +148 -0
- package/dist/components/fly-release.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fly-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +176 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -0
- package/dist/op/activities/machines-contract.d.ts +8 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +53 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- package/dist/op/builders.d.ts +13 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts +48 -0
- package/dist/release-metadata.d.ts.map +1 -0
- package/dist/release-store.d.ts +36 -0
- package/dist/release-store.d.ts.map +1 -0
- package/dist/skills/chant-fly-ops.md +33 -0
- package/package.json +2 -2
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +244 -0
- package/src/components/fly-release.ts +341 -0
- package/src/components/index.ts +16 -0
- package/src/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +12 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/index.ts +23 -0
- package/src/op/activities/machine-release.integration.test.ts +113 -0
- package/src/op/activities/machine-release.test.ts +138 -0
- package/src/op/activities/machine-release.ts +407 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
- package/src/op/activities/machines-contract.test.ts +11 -1
- package/src/op/activities/machines-contract.ts +17 -0
- package/src/op/activities/machines-fake.ts +165 -0
- package/src/op/builders.ts +25 -0
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +33 -0
|
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
5
|
+
import { MACHINES_CONTRACT, MACHINE_RELEASE_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
6
6
|
|
|
7
7
|
describe("MACHINES_CONTRACT", () => {
|
|
8
8
|
test("covers the flyApply resource operations (apps, machines, leases, volumes, ips, certs, secrets)", () => {
|
|
@@ -46,4 +46,14 @@ describe("MACHINES_CONTRACT", () => {
|
|
|
46
46
|
expect(src, `path segment "${seg}" from the contract is absent from fly-apply.ts`).toContain(seg);
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
test("every release-contract verb appears in the machine-release.ts source (drift anchor, #2736)", () => {
|
|
51
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "machine-release.ts"), "utf-8");
|
|
52
|
+
for (const e of MACHINE_RELEASE_CONTRACT) {
|
|
53
|
+
expect(src, `${e.op} is absent from machine-release.ts`).toContain(e.op);
|
|
54
|
+
}
|
|
55
|
+
// exec is a literal path segment; restart and stop go through one `/${verb}` template.
|
|
56
|
+
expect(src).toContain("/exec");
|
|
57
|
+
for (const verb of ['"restart"', '"stop"']) expect(src).toContain(verb);
|
|
58
|
+
});
|
|
49
59
|
});
|
|
@@ -71,3 +71,20 @@ export function normalizeEndpoint(method: string, path: string): string {
|
|
|
71
71
|
export function contractKeys(): Set<string> {
|
|
72
72
|
return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The flaps endpoints the release activities (./machine-release.ts, #2736)
|
|
77
|
+
* add beyond the applier's: getting one Machine back, exec (a migration),
|
|
78
|
+
* restart and stop. The applier's own contract above stays exec-free; these
|
|
79
|
+
* are the site steps', and the docker-gated coverage test holds the pinned
|
|
80
|
+
* mudflaps to them the same way.
|
|
81
|
+
*/
|
|
82
|
+
export const MACHINE_RELEASE_CONTRACT: readonly MachinesEndpoint[] = [
|
|
83
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "findMachine" },
|
|
84
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "flyMachineRestore" },
|
|
85
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/exec", op: "flyMachineExec" },
|
|
86
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/restart", op: "flyMachineRestart" },
|
|
87
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "flyMachineStop" },
|
|
88
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "withLease" },
|
|
89
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
90
|
+
] as const;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory Fly Machines (flaps) API, as a {@link FlyHttp} (#2736).
|
|
3
|
+
*
|
|
4
|
+
* The twin of ./sprites-fake.ts for the Machines side: the endpoints the
|
|
5
|
+
* applier (./fly-apply.ts) and the release activities (./machine-release.ts)
|
|
6
|
+
* call, answered from memory with the response shapes flaps and mudflaps use.
|
|
7
|
+
* Unit tests inject it where the default client would reach the network; the
|
|
8
|
+
* docker-gated tests run the same activities against the pinned mudflaps.
|
|
9
|
+
*
|
|
10
|
+
* Not an activity: nothing in ./index.ts exports it, so `loadActivities`
|
|
11
|
+
* never binds it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FlyHttp } from "./fly-apply";
|
|
15
|
+
|
|
16
|
+
/** One Machine the fake holds. */
|
|
17
|
+
export interface FakeMachine {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
state: string;
|
|
21
|
+
instance_id: string;
|
|
22
|
+
config: Record<string, unknown> & { metadata?: Record<string, string> };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What an `exec` answers: the Machines API's exec envelope. */
|
|
26
|
+
export interface FakeExecResult {
|
|
27
|
+
exit_code: number;
|
|
28
|
+
stdout?: string;
|
|
29
|
+
stderr?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MachinesFake {
|
|
33
|
+
http: FlyHttp;
|
|
34
|
+
apps: Set<string>;
|
|
35
|
+
machines: Map<string, FakeMachine[]>;
|
|
36
|
+
/** Every exec, in order: the app, the machine id and the command. */
|
|
37
|
+
execs: Array<{ app: string; id: string; command: string[] }>;
|
|
38
|
+
/** Every call, as `METHOD path` (no base, no query). */
|
|
39
|
+
calls: string[];
|
|
40
|
+
/** The live machine named `name` in `app`, if any. */
|
|
41
|
+
machine(app: string, name: string): FakeMachine | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MachinesFakeOptions {
|
|
45
|
+
/** Answer an exec. Default: exit 0 with no output. */
|
|
46
|
+
exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult;
|
|
47
|
+
/** The state a created or updated machine settles in. Default `started`. */
|
|
48
|
+
settle?: (machine: FakeMachine) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const json = (status: number, body: unknown) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
|
|
52
|
+
|
|
53
|
+
let seq = 0;
|
|
54
|
+
const nextId = (prefix: string) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
|
|
55
|
+
|
|
56
|
+
/** A fresh in-memory flaps. */
|
|
57
|
+
export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesFake {
|
|
58
|
+
const apps = new Set<string>();
|
|
59
|
+
const machines = new Map<string, FakeMachine[]>();
|
|
60
|
+
const execs: MachinesFake["execs"] = [];
|
|
61
|
+
const calls: string[] = [];
|
|
62
|
+
const settle = options.settle ?? (() => "started");
|
|
63
|
+
const list = (app: string) => machines.get(app) ?? [];
|
|
64
|
+
const find = (app: string, id: string) => list(app).find((m) => m.id === id);
|
|
65
|
+
|
|
66
|
+
const http: FlyHttp = async (method, url, body) => {
|
|
67
|
+
const { pathname, searchParams } = new URL(url);
|
|
68
|
+
calls.push(`${method} ${pathname}`);
|
|
69
|
+
const seg = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
70
|
+
// seg: ["v1", "apps", app?, kind?, id?, action?]
|
|
71
|
+
if (seg[0] !== "v1" || seg[1] !== "apps") return json(404, { error: "not found" });
|
|
72
|
+
const app = seg[2];
|
|
73
|
+
const b = (body ?? {}) as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
if (!app) {
|
|
76
|
+
if (method === "POST") {
|
|
77
|
+
const name = String(b.app_name);
|
|
78
|
+
if (apps.has(name)) return json(409, { error: "app exists" });
|
|
79
|
+
apps.add(name);
|
|
80
|
+
return json(201, { name, status: "deployed" });
|
|
81
|
+
}
|
|
82
|
+
return json(200, [...apps].map((name) => ({ name })));
|
|
83
|
+
}
|
|
84
|
+
if (seg.length === 3) {
|
|
85
|
+
if (method === "GET") return apps.has(app) ? json(200, { name: app, status: "deployed" }) : json(404, { error: "app not found" });
|
|
86
|
+
if (method === "DELETE") {
|
|
87
|
+
apps.delete(app);
|
|
88
|
+
machines.delete(app);
|
|
89
|
+
return json(202, undefined);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!apps.has(app)) return json(404, { error: "app not found" });
|
|
93
|
+
const kind = seg[3];
|
|
94
|
+
|
|
95
|
+
if (kind === "machines") {
|
|
96
|
+
const id = seg[4];
|
|
97
|
+
const action = seg[5];
|
|
98
|
+
if (!id) {
|
|
99
|
+
if (method === "GET") return json(200, list(app));
|
|
100
|
+
if (method === "POST") {
|
|
101
|
+
const m: FakeMachine = {
|
|
102
|
+
id: nextId("m"),
|
|
103
|
+
name: String(b.name ?? ""),
|
|
104
|
+
state: "created",
|
|
105
|
+
instance_id: nextId("I"),
|
|
106
|
+
config: structuredClone((b.config ?? {}) as FakeMachine["config"]),
|
|
107
|
+
};
|
|
108
|
+
m.state = settle(m);
|
|
109
|
+
machines.set(app, [...list(app), m]);
|
|
110
|
+
return json(200, m);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const m = id ? find(app, id) : undefined;
|
|
114
|
+
if (!m) return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
|
|
115
|
+
if (!action) {
|
|
116
|
+
if (method === "GET") return json(200, m);
|
|
117
|
+
if (method === "POST") {
|
|
118
|
+
m.config = structuredClone((b.config ?? {}) as FakeMachine["config"]);
|
|
119
|
+
m.instance_id = nextId("I");
|
|
120
|
+
m.state = settle(m);
|
|
121
|
+
return json(200, m);
|
|
122
|
+
}
|
|
123
|
+
if (method === "DELETE") {
|
|
124
|
+
machines.set(app, list(app).filter((x) => x !== m));
|
|
125
|
+
return json(200, { ok: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (action === "lease") {
|
|
129
|
+
if (method === "POST") return json(200, { data: { nonce: nextId("n") } });
|
|
130
|
+
return json(200, { ok: true });
|
|
131
|
+
}
|
|
132
|
+
if (action === "wait") {
|
|
133
|
+
const want = searchParams.get("state") ?? "started";
|
|
134
|
+
return json(200, { ok: m.state === want });
|
|
135
|
+
}
|
|
136
|
+
if (action === "restart" || action === "start") {
|
|
137
|
+
m.state = "started";
|
|
138
|
+
return json(200, { ok: true });
|
|
139
|
+
}
|
|
140
|
+
if (action === "stop") {
|
|
141
|
+
m.state = "stopped";
|
|
142
|
+
return json(200, { ok: true });
|
|
143
|
+
}
|
|
144
|
+
if (action === "exec" && method === "POST") {
|
|
145
|
+
const command = (b.command as string[] | undefined) ?? String(b.cmd ?? "").split(" ");
|
|
146
|
+
execs.push({ app, id: m.id, command });
|
|
147
|
+
return json(200, options.exec?.(app, m, command) ?? { exit_code: 0, stdout: "", stderr: "" });
|
|
148
|
+
}
|
|
149
|
+
return json(404, { error: `no ${method} ${action}` });
|
|
150
|
+
}
|
|
151
|
+
if (kind === "ip_assignments") return method === "GET" ? json(200, { ips: [] }) : json(200, {});
|
|
152
|
+
if (kind === "certificates") return method === "GET" ? json(200, { certificates: [] }) : json(200, {});
|
|
153
|
+
if (kind === "volumes" || kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
|
|
154
|
+
return json(404, { error: "not found" });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
http,
|
|
159
|
+
apps,
|
|
160
|
+
machines,
|
|
161
|
+
execs,
|
|
162
|
+
calls,
|
|
163
|
+
machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/op/builders.ts
CHANGED
|
@@ -42,6 +42,13 @@ import type {
|
|
|
42
42
|
} from "./activities/sprite-services";
|
|
43
43
|
import type { SpriteTaskCreateArgs, SpriteTaskRefreshArgs, SpriteTaskReleaseArgs } from "./activities/sprite-tasks";
|
|
44
44
|
import type { SpritesUpArgs, SpritesDownArgs } from "./activities/sprites-emulator";
|
|
45
|
+
import type {
|
|
46
|
+
FlyMachineReleaseArgs,
|
|
47
|
+
FlyMachineExecArgs,
|
|
48
|
+
FlyMachineStateArgs,
|
|
49
|
+
FlyMachineVerifyArgs,
|
|
50
|
+
FlyMachineRestoreArgs,
|
|
51
|
+
} from "./activities/machine-release";
|
|
45
52
|
|
|
46
53
|
type StepOpts = { profile?: ActivityStep["profile"] };
|
|
47
54
|
|
|
@@ -127,3 +134,21 @@ export const spritesUp = (args: WithStepRefs<SpritesUpArgs> & StepOpts = {}): Na
|
|
|
127
134
|
/** Stop and remove the local spritzer container — the fully typed twin of core's `spritesDown`. Defaults to the `fastIdempotent` profile. */
|
|
128
135
|
export const spritesDown = (args: WithStepRefs<SpritesDownArgs> & StepOpts = {}): NamedActivityStep =>
|
|
129
136
|
spriteStep<SpritesDownArgs>("spritesDown", "fastIdempotent")(args);
|
|
137
|
+
|
|
138
|
+
// ── Machines release activities (#2736, ws-056) ──────────────────────────────
|
|
139
|
+
// The site steps as Op steps: upload and start, a migration inside the
|
|
140
|
+
// Machine, restart, stop, verify and restore. Wrap a migration's
|
|
141
|
+
// `flyMachineExec` in `effect()` so it fires once per environment.
|
|
142
|
+
|
|
143
|
+
/** Apply the plan with the Machine serving a release (its digest in the Machine's metadata). Defaults to the `longInfra` profile. */
|
|
144
|
+
export const flyMachineRelease = spriteStep<FlyMachineReleaseArgs>("flyMachineRelease", "longInfra");
|
|
145
|
+
/** Run a command inside a Machine (a migration). Defaults to the `atMostOnce` profile: chant cannot know the command is safe to repeat. */
|
|
146
|
+
export const flyMachineExec = spriteStep<FlyMachineExecArgs>("flyMachineExec", "atMostOnce");
|
|
147
|
+
/** Restart a Machine under a lease and wait for it to be started. Defaults to the `longInfra` profile. */
|
|
148
|
+
export const flyMachineRestart = spriteStep<FlyMachineStateArgs>("flyMachineRestart", "longInfra");
|
|
149
|
+
/** Stop a Machine under a lease. Defaults to the `longInfra` profile. */
|
|
150
|
+
export const flyMachineStop = spriteStep<FlyMachineStateArgs>("flyMachineStop", "longInfra");
|
|
151
|
+
/** Check a Machine is started with a release, and its health endpoint answers. Defaults to the `longInfra` profile. */
|
|
152
|
+
export const flyMachineVerify = spriteStep<FlyMachineVerifyArgs>("flyMachineVerify", "longInfra");
|
|
153
|
+
/** Put a recorded Machine config back (restore, rollback). Defaults to the `longInfra` profile. */
|
|
154
|
+
export const flyMachineRestore = spriteStep<FlyMachineRestoreArgs>("flyMachineRestore", "longInfra");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release a Fly Machine serves, as its metadata records it (#2736, ws-056).
|
|
3
|
+
*
|
|
4
|
+
* Pure: the keys, a reader, and a writer over a Machine config. The Machines
|
|
5
|
+
* activities (./op/activities/machine-release.ts) stamp these on a release,
|
|
6
|
+
* `describeResources` (./describe-resources.ts) reads them back, and
|
|
7
|
+
* `chant components status --live` compares the digest with the release
|
|
8
|
+
* ledger.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Machine metadata keys that say which release it serves. Fly metadata is
|
|
13
|
+
* a string map, so each field is its own key.
|
|
14
|
+
*
|
|
15
|
+
* - `digest`: the release's digest, the release ledger's join key.
|
|
16
|
+
* - `gitSha`: the commit the release was built from.
|
|
17
|
+
* - `release`: an optional human label (a tag, a release id).
|
|
18
|
+
* - `previousDigest`: the release this one replaced when it first shipped, so
|
|
19
|
+
* a rollback knows where to go back to with no history of its own.
|
|
20
|
+
*/
|
|
21
|
+
export const RELEASE_METADATA_KEYS = {
|
|
22
|
+
digest: "chant-release-digest",
|
|
23
|
+
gitSha: "chant-release-git-sha",
|
|
24
|
+
release: "chant-release",
|
|
25
|
+
previousDigest: "chant-release-previous-digest",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** A release, as a Machine's metadata records it. */
|
|
29
|
+
export interface MachineRelease {
|
|
30
|
+
/** The release's digest (`sha256:...`): what the release ledger records. */
|
|
31
|
+
digest: string;
|
|
32
|
+
/** The commit it was built from. */
|
|
33
|
+
gitSha?: string;
|
|
34
|
+
/** A human label for it. */
|
|
35
|
+
release?: string;
|
|
36
|
+
/** The digest of the release it replaced, when there was one. */
|
|
37
|
+
previousDigest?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MachineConfig = Record<string, unknown> & { metadata?: Record<string, string>; env?: Record<string, string> };
|
|
41
|
+
|
|
42
|
+
/** The release a Machine's metadata names, or undefined when it names none. Pure. */
|
|
43
|
+
export function readMachineRelease(metadata: Record<string, string> | null | undefined): MachineRelease | undefined {
|
|
44
|
+
const digest = metadata?.[RELEASE_METADATA_KEYS.digest];
|
|
45
|
+
if (!digest) return undefined;
|
|
46
|
+
const gitSha = metadata?.[RELEASE_METADATA_KEYS.gitSha];
|
|
47
|
+
const release = metadata?.[RELEASE_METADATA_KEYS.release];
|
|
48
|
+
const previousDigest = metadata?.[RELEASE_METADATA_KEYS.previousDigest];
|
|
49
|
+
return {
|
|
50
|
+
digest,
|
|
51
|
+
...(gitSha ? { gitSha } : {}),
|
|
52
|
+
...(release ? { release } : {}),
|
|
53
|
+
...(previousDigest ? { previousDigest } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A Machine config serving `release`: a copy of `config` with the release's
|
|
59
|
+
* metadata keys set (and any it does not carry removed). Pure.
|
|
60
|
+
*/
|
|
61
|
+
export function withReleaseMetadata(config: MachineConfig | undefined, release: MachineRelease): MachineConfig {
|
|
62
|
+
const out = structuredClone(config ?? {}) as MachineConfig;
|
|
63
|
+
const metadata = { ...(out.metadata ?? {}) };
|
|
64
|
+
for (const key of Object.values(RELEASE_METADATA_KEYS)) delete metadata[key];
|
|
65
|
+
metadata[RELEASE_METADATA_KEYS.digest] = release.digest;
|
|
66
|
+
if (release.gitSha) metadata[RELEASE_METADATA_KEYS.gitSha] = release.gitSha;
|
|
67
|
+
if (release.release) metadata[RELEASE_METADATA_KEYS.release] = release.release;
|
|
68
|
+
if (release.previousDigest) metadata[RELEASE_METADATA_KEYS.previousDigest] = release.previousDigest;
|
|
69
|
+
out.metadata = metadata;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a Fly release's Machine config is kept, so a rollback can put it back
|
|
3
|
+
* (#2736, ws-056).
|
|
4
|
+
*
|
|
5
|
+
* The Machine's metadata says which release it serves and which one it
|
|
6
|
+
* replaced; the config that earlier release applied is kept on the
|
|
7
|
+
* `chant/lifecycle` branch beside the release ledger, one file per release at
|
|
8
|
+
* `<env>/fly/<app>/<machine>/<digest>.json`. It is written through core's
|
|
9
|
+
* ledger plumbing, so a workspace member's copy lands under its own prefix,
|
|
10
|
+
* and every checkout that fetches the branch can roll back.
|
|
11
|
+
*
|
|
12
|
+
* Not exported from the package entry point: it spawns git, which stays off
|
|
13
|
+
* the build path. The `fly-release` and `fly-rollback` capabilities import it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readBlobFromPath, writeBlobToPath } from "@intentius/chant/lifecycle/git";
|
|
17
|
+
|
|
18
|
+
/** Which Machine and release a config belongs to. */
|
|
19
|
+
export interface MachineConfigRef {
|
|
20
|
+
app: string;
|
|
21
|
+
machine: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A store of the Machine config each release applied. */
|
|
26
|
+
export interface MachineConfigStore {
|
|
27
|
+
read(ref: MachineConfigRef): Promise<Record<string, unknown> | undefined>;
|
|
28
|
+
write(ref: MachineConfigRef, config: Record<string, unknown>): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const safe = (s: string) => s.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
32
|
+
|
|
33
|
+
/** The file a release's Machine config is kept in, under the environment's ledger directory. */
|
|
34
|
+
export function machineConfigFile(ref: MachineConfigRef): string {
|
|
35
|
+
return `fly/${safe(ref.app)}/${safe(ref.machine)}/${safe(ref.digest.replace(/^sha256:/, ""))}.json`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The lifecycle-branch store for `environment`. */
|
|
39
|
+
export function lifecycleMachineConfigStore(environment: string, opts?: { cwd?: string }): MachineConfigStore {
|
|
40
|
+
return {
|
|
41
|
+
async read(ref) {
|
|
42
|
+
const content = await readBlobFromPath(environment, machineConfigFile(ref), opts);
|
|
43
|
+
return content === null ? undefined : (JSON.parse(content) as Record<string, unknown>);
|
|
44
|
+
},
|
|
45
|
+
async write(ref, config) {
|
|
46
|
+
await writeBlobToPath(
|
|
47
|
+
environment,
|
|
48
|
+
machineConfigFile(ref),
|
|
49
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
50
|
+
`Fly release config ${ref.app}/${ref.machine} ${ref.digest}`,
|
|
51
|
+
opts,
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An in-memory store, for tests and for a caller with nowhere to keep configs. */
|
|
58
|
+
export function memoryMachineConfigStore(): MachineConfigStore & { entries: Map<string, Record<string, unknown>> } {
|
|
59
|
+
const entries = new Map<string, Record<string, unknown>>();
|
|
60
|
+
return {
|
|
61
|
+
entries,
|
|
62
|
+
async read(ref) {
|
|
63
|
+
const found = entries.get(machineConfigFile(ref));
|
|
64
|
+
return found ? structuredClone(found) : undefined;
|
|
65
|
+
},
|
|
66
|
+
async write(ref, config) {
|
|
67
|
+
entries.set(machineConfigFile(ref), structuredClone(config));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -53,3 +53,36 @@ Each prune logs the resource and endpoint it removed, so a prune run is auditabl
|
|
|
53
53
|
## Re-applying is safe
|
|
54
54
|
|
|
55
55
|
A re-apply of an unchanged stack is a no-op per resource: machines whose config is structurally equal to live are skipped, volumes and certificates that already exist are skipped, and an IP of an already-present family is skipped. Only apply-only secrets are always re-set, because flaps exposes no value to diff against.
|
|
56
|
+
|
|
57
|
+
## Releases on a Machine
|
|
58
|
+
|
|
59
|
+
A component can deploy to Fly with this lexicon alone. It declares an `App` and the `Machine` that serves, builds them with `chant build --lexicon fly -o dist/fly.json`, and deploys with the `fly-release` step. Here a `Publish` phase (`publish-image`, elided) pushes the image, and the release serves it by digest:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { phase, type Component } from "@intentius/chant/components";
|
|
63
|
+
|
|
64
|
+
export const web: Component = {
|
|
65
|
+
name: "web",
|
|
66
|
+
deploy: [
|
|
67
|
+
phase("Publish", [/* publish-image */]),
|
|
68
|
+
phase("Release", [
|
|
69
|
+
{
|
|
70
|
+
kind: "fly-release",
|
|
71
|
+
plan: "dist/fly.json",
|
|
72
|
+
digest: "@Publish.digest",
|
|
73
|
+
image: "@Publish.uri",
|
|
74
|
+
migrations: [{ name: "001_init.sql", command: "node migrate.js 001_init.sql" }],
|
|
75
|
+
verify: { url: "https://web.example.com", healthPath: "/health" },
|
|
76
|
+
},
|
|
77
|
+
]),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The step updates the Machine in place with the release in its `config.metadata`: `chant-release-digest`, `chant-release-git-sha` (the commit, `git rev-parse HEAD` unless given), and `chant-release-previous-digest` (the release it replaced). Each migration then runs inside the Machine through the Machines API's exec, once per environment: its receipt is kept on `chant/lifecycle` at `<env>/receipts/`, and a migration fires again only when its `sha` (or command) changes. After a migration fires, the Machine restarts, and the step checks that it is started with this release and, given a `url`, that its health endpoint answers with this commit or digest. If anything fails after the Machine changed, the step puts back the config the Machine served before (on a first release, it stops the Machine) and fails.
|
|
83
|
+
|
|
84
|
+
The step's output carries `uri` and `digest`, so `chant run --components web --env prod` records the release in the ledger. `chant components status prod --live` reads the Machine's metadata back and compares its digest with the ledger's: the row is `reconciled` when they agree and `drifted` when the Machine serves something the ledger does not name. The component joins the Machine by name: name the component after the Machine entity, or list it in `liveNames`.
|
|
85
|
+
|
|
86
|
+
Each release's Machine config is kept on `chant/lifecycle` at `<env>/fly/<app>/<machine>/<digest>.json`. `fly-rollback` puts back the config of the release the serving one replaced (or the digest given as `to`), checks it, and outputs that digest so the ledger records it again. `fly-release`'s own saga compensation does the same.
|
|
87
|
+
|
|
88
|
+
The steps are also Op activities, for an Op that composes them itself: `flyMachineRelease`, `flyMachineExec`, `flyMachineRestart`, `flyMachineStop`, `flyMachineVerify` and `flyMachineRestore`. Wrap a migration's `flyMachineExec` in `effect()` so it fires once.
|