@intentius/chant-lexicon-fly 0.90.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/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +195 -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/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/describe-resources.d.ts +11 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/index.d.ts +5 -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-apply.d.ts +8 -22
- package/dist/op/activities/fly-apply.d.ts.map +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 +8 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +192 -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 +55 -0
- package/dist/op/activities/machines-fake.d.ts.map +1 -0
- 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 +45 -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 +100 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/capability-plugin.ts +11 -3
- package/src/components/fly-release.test.ts +378 -0
- package/src/components/fly-release.ts +458 -0
- package/src/components/index.ts +16 -0
- 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/describe-resources.test.ts +18 -3
- package/src/describe-resources.ts +69 -5
- package/src/index.ts +20 -0
- package/src/op/activities/fly-apply.ts +15 -3
- 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 +48 -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 +429 -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 +167 -0
- 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 +57 -0
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +100 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fly-release` and `fly-rollback`: a component deploying to Fly with this
|
|
3
|
+
* lexicon alone (#2736, ws-056), in place of chud's `Chud::FlySite`.
|
|
4
|
+
*
|
|
5
|
+
* A component declares its App and Machine as this lexicon's resources, builds
|
|
6
|
+
* them (`chant build --lexicon fly -o <plan>`), and deploys with one step:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* { kind: "fly-release", plan: "dist/fly.json", digest: "@app.publish.digest",
|
|
10
|
+
* image: "@app.publish.uri", migrations: [{ name: "001_init.sql", command: "node migrate.js 001_init.sql" }] }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* `fly-release` runs the site steps in order, as the Machines activities
|
|
14
|
+
* (../op/activities/machine-release.ts):
|
|
15
|
+
*
|
|
16
|
+
* 1. upload and start: apply the plan with the Machine serving the release,
|
|
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;
|
|
22
|
+
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
23
|
+
* witnessed by a receipt in chant's lifecycle receipt store
|
|
24
|
+
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
25
|
+
* on the migrated data;
|
|
26
|
+
* 3. verify: the Machine is started with this release, and its health
|
|
27
|
+
* endpoint answers when a URL is given;
|
|
28
|
+
* 4. on a failure after the Machine changed, restore: the config it replaced
|
|
29
|
+
* goes back (or, on a first release, the Machine is stopped), and the step
|
|
30
|
+
* fails.
|
|
31
|
+
*
|
|
32
|
+
* Its output carries `uri` and `digest`, so `chant run --components` records
|
|
33
|
+
* the release in the ledger, and the Machine's metadata carries the same
|
|
34
|
+
* digest, which `chant components status --live` compares with it.
|
|
35
|
+
*
|
|
36
|
+
* Each release's Machine config is kept on the lifecycle branch
|
|
37
|
+
* (../release-store.ts). `fly-rollback` puts back the config of the release
|
|
38
|
+
* the serving one replaced (or the digest it is given), which is also the
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
45
|
+
import type { Capability, DeployContext } from "@intentius/chant/components/capability";
|
|
46
|
+
import type { ReceiptStore, EffectReceiptRef } from "@intentius/chant/op/receipt-store";
|
|
47
|
+
import {
|
|
48
|
+
findMachine,
|
|
49
|
+
flyMachineExec,
|
|
50
|
+
flyMachineRelease,
|
|
51
|
+
flyMachineRestart,
|
|
52
|
+
flyMachineRestore,
|
|
53
|
+
flyMachineStop,
|
|
54
|
+
flyMachineVerify,
|
|
55
|
+
readMachineRelease,
|
|
56
|
+
releaseTarget,
|
|
57
|
+
type MachineFile,
|
|
58
|
+
type MachineRelease,
|
|
59
|
+
} from "../op/activities/machine-release";
|
|
60
|
+
import { defaultFlyHttp, parsePlan, resolveEndpoint, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
61
|
+
import type { MachineConfigStore } from "../release-store";
|
|
62
|
+
import { readFileSync } from "node:fs";
|
|
63
|
+
|
|
64
|
+
/** One migration, run inside the Machine once per environment. */
|
|
65
|
+
export interface FlyMigration {
|
|
66
|
+
/** Its name: the receipt is named after it, so a name fires once. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** The command, run in the Machine: an argv, or a string run with `sh -c`. */
|
|
69
|
+
command: string[] | string;
|
|
70
|
+
/** A digest of the migration's content. A migration whose content changes fires again. Default: a digest of the command. */
|
|
71
|
+
sha?: string;
|
|
72
|
+
}
|
|
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
|
+
|
|
123
|
+
/** Health check after the release serves. */
|
|
124
|
+
export interface FlyVerify {
|
|
125
|
+
/** The app's public URL. Without it, only the Machine's state and metadata are checked. */
|
|
126
|
+
url?: string;
|
|
127
|
+
/** Default `/health`. */
|
|
128
|
+
healthPath?: string;
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
intervalMs?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface FlyReleaseInput {
|
|
134
|
+
/** Path to the fly build output (`chant build --lexicon fly -o <path>`). */
|
|
135
|
+
plan: string;
|
|
136
|
+
/** The Machine that serves, when the plan declares more than one (entity or Machine name). */
|
|
137
|
+
machine?: string;
|
|
138
|
+
/** The release's digest: what the ledger records and the Machine's metadata names. */
|
|
139
|
+
digest: string;
|
|
140
|
+
/** The commit it was built from. Default: `git rev-parse HEAD`, as the ledger records. */
|
|
141
|
+
gitSha?: string;
|
|
142
|
+
/** A human label for the release. */
|
|
143
|
+
release?: string;
|
|
144
|
+
/** An image to run in place of the declared one. */
|
|
145
|
+
image?: string;
|
|
146
|
+
/** Env added to the declared Machine's env, over `APP_REVISION` (the commit, #2834) unless this names its own. */
|
|
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;
|
|
155
|
+
/** Migrations, in order. */
|
|
156
|
+
migrations?: FlyMigration[];
|
|
157
|
+
verify?: FlyVerify;
|
|
158
|
+
/** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
|
|
159
|
+
endpoint?: string;
|
|
160
|
+
wait?: WaitOpts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface FlyReleaseOutput {
|
|
164
|
+
/** `fly://<app>/<machine>`: where the release was promoted. */
|
|
165
|
+
uri: string;
|
|
166
|
+
digest: string;
|
|
167
|
+
gitSha?: string;
|
|
168
|
+
app: string;
|
|
169
|
+
machine: { id: string; name: string };
|
|
170
|
+
/** The release the Machine served before this one, when it named one. */
|
|
171
|
+
previous: MachineRelease | null;
|
|
172
|
+
/** Each migration, and whether it fired this run. */
|
|
173
|
+
migrations: Array<{ name: string; fired: boolean }>;
|
|
174
|
+
}
|
|
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
|
+
|
|
179
|
+
export interface FlyRollbackInput {
|
|
180
|
+
/** Path to the fly build output, naming the App and Machine. */
|
|
181
|
+
plan: string;
|
|
182
|
+
machine?: string;
|
|
183
|
+
/** The release digest to go back to. Default: the one the serving release replaced. */
|
|
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;
|
|
192
|
+
verify?: FlyVerify;
|
|
193
|
+
endpoint?: string;
|
|
194
|
+
wait?: WaitOpts;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface FlyRollbackOutput {
|
|
198
|
+
uri: string;
|
|
199
|
+
/** The release serving now. */
|
|
200
|
+
digest: string;
|
|
201
|
+
gitSha?: string;
|
|
202
|
+
app: string;
|
|
203
|
+
machine: { id: string; name: string };
|
|
204
|
+
/** The release it replaced. */
|
|
205
|
+
previous: MachineRelease | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What the capabilities reach the world through. Every field has a default; tests replace them. */
|
|
209
|
+
export interface FlyReleaseDeps {
|
|
210
|
+
http?: FlyHttp;
|
|
211
|
+
/** The Machine configs each release applied. Default: the lifecycle branch, per environment. */
|
|
212
|
+
configStore?: (ctx: DeployContext) => MachineConfigStore | Promise<MachineConfigStore>;
|
|
213
|
+
/** Migration receipts. Default: chant's lifecycle receipt store, per environment. */
|
|
214
|
+
receiptStore?: (ctx: DeployContext) => ReceiptStore | Promise<ReceiptStore>;
|
|
215
|
+
/** The commit a release defaults to. Default: `git rev-parse HEAD`. */
|
|
216
|
+
headCommit?: () => Promise<string>;
|
|
217
|
+
fetch?: typeof fetch;
|
|
218
|
+
log?: (line: string) => void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function defaultConfigStore(ctx: DeployContext): Promise<MachineConfigStore> {
|
|
222
|
+
const { lifecycleMachineConfigStore } = await import("../release-store");
|
|
223
|
+
return lifecycleMachineConfigStore(ctx.env);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function defaultReceiptStore(ctx: DeployContext): Promise<ReceiptStore> {
|
|
227
|
+
const { lifecycleReceiptStore } = await import("@intentius/chant/op/lifecycle-receipt-store");
|
|
228
|
+
return lifecycleReceiptStore({ environment: ctx.env });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function defaultHeadCommit(): Promise<string> {
|
|
232
|
+
const { getHeadCommit } = await import("@intentius/chant/lifecycle/git");
|
|
233
|
+
return getHeadCommit();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sha256 = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`;
|
|
237
|
+
|
|
238
|
+
/** The receipt a migration fires under on an app. */
|
|
239
|
+
export function migrationReceipt(app: string, migration: FlyMigration): { ref: EffectReceiptRef; expectation: string } {
|
|
240
|
+
const command = typeof migration.command === "string" ? migration.command : JSON.stringify(migration.command);
|
|
241
|
+
return {
|
|
242
|
+
ref: { name: `fly-migration:${app}/${migration.name}`, effect: "fly-migrate", flavor: "hash", inputs: { app, migration: migration.name } },
|
|
243
|
+
expectation: migration.sha ?? sha256(command),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function loadTarget(planPath: string, machine?: string) {
|
|
248
|
+
const plan = parsePlan(readFileSync(planPath, "utf8"));
|
|
249
|
+
return { plan, target: releaseTarget(plan, machine) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const uriOf = (app: string, machine: string) => `fly://${app}/${machine}`;
|
|
253
|
+
|
|
254
|
+
/** Build the `fly-release` capability. */
|
|
255
|
+
export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capability<FlyReleaseInput, FlyReleaseOutput> {
|
|
256
|
+
const log = deps.log ?? ((line: string) => console.error(line));
|
|
257
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
258
|
+
|
|
259
|
+
async function rollBack(ctx: DeployContext, input: FlyReleaseInput, output?: FlyReleaseOutput): Promise<void> {
|
|
260
|
+
if (!output) return;
|
|
261
|
+
const http = httpFor();
|
|
262
|
+
const target = { app: output.app, machine: output.machine.name, endpoint: input.endpoint };
|
|
263
|
+
if (!output.previous) {
|
|
264
|
+
await flyMachineStop({ ...target, wait: input.wait }, undefined, http);
|
|
265
|
+
log(`fly-release: nothing served before ${output.digest}; stopped ${output.app}/${output.machine.name}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
269
|
+
const config = await store.read({ app: output.app, machine: output.machine.name, digest: output.previous.digest });
|
|
270
|
+
if (!config) {
|
|
271
|
+
throw new Error(`fly-release: no recorded Machine config for ${output.previous.digest} on ${output.app}/${output.machine.name}; nothing to roll back to`);
|
|
272
|
+
}
|
|
273
|
+
await flyMachineRestore({ ...target, config, wait: input.wait }, undefined, http);
|
|
274
|
+
log(`fly-release: rolled ${output.app}/${output.machine.name} back to ${output.previous.digest}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
kind: "fly-release",
|
|
279
|
+
rollbackPolicy: "native",
|
|
280
|
+
async run(ctx, input) {
|
|
281
|
+
if (!input.digest) throw new Error("fly-release: a release needs its digest");
|
|
282
|
+
const http = httpFor();
|
|
283
|
+
const { plan } = loadTarget(input.plan, input.machine);
|
|
284
|
+
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
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 ?? {}) };
|
|
293
|
+
|
|
294
|
+
const released = await flyMachineRelease(
|
|
295
|
+
{
|
|
296
|
+
plan,
|
|
297
|
+
machine: input.machine,
|
|
298
|
+
release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
|
|
299
|
+
image: input.image,
|
|
300
|
+
env,
|
|
301
|
+
...(source ? { files: source.files, cmd: source.cmd } : {}),
|
|
302
|
+
endpoint: input.endpoint,
|
|
303
|
+
wait: input.wait,
|
|
304
|
+
},
|
|
305
|
+
undefined,
|
|
306
|
+
http,
|
|
307
|
+
);
|
|
308
|
+
const where = { app: released.app, machine: released.machine.name, endpoint: input.endpoint };
|
|
309
|
+
log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}`);
|
|
310
|
+
|
|
311
|
+
// The config the Machine served before is kept too, so a Machine
|
|
312
|
+
// released before chant recorded configs can still be rolled back to.
|
|
313
|
+
const replaced = released.previous?.release;
|
|
314
|
+
const previousRelease: MachineRelease | null =
|
|
315
|
+
replaced && replaced.digest !== input.digest
|
|
316
|
+
? replaced
|
|
317
|
+
: released.release.previousDigest
|
|
318
|
+
? { digest: released.release.previousDigest }
|
|
319
|
+
: null;
|
|
320
|
+
if (replaced && previousRelease?.digest === replaced.digest && !(await configs.read({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }))) {
|
|
321
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }, released.previous!.config);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const migrations: FlyReleaseOutput["migrations"] = [];
|
|
325
|
+
try {
|
|
326
|
+
if (input.migrations?.length) {
|
|
327
|
+
const receipts = await (deps.receiptStore ?? defaultReceiptStore)(ctx);
|
|
328
|
+
for (const migration of input.migrations) {
|
|
329
|
+
const { ref, expectation } = migrationReceipt(released.app, migration);
|
|
330
|
+
if ((await receipts.read(ref)) === expectation) {
|
|
331
|
+
migrations.push({ name: migration.name, fired: false });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
log(`fly-release: migrating ${migration.name} on ${released.app}/${released.machine.name}`);
|
|
335
|
+
await flyMachineExec({ ...where, command: migration.command }, undefined, http);
|
|
336
|
+
// The receipt is written last, on success only: a failed migration fires again next run.
|
|
337
|
+
await receipts.write(ref, expectation);
|
|
338
|
+
migrations.push({ name: migration.name, fired: true });
|
|
339
|
+
}
|
|
340
|
+
if (migrations.some((m) => m.fired)) await flyMachineRestart({ ...where, wait: input.wait }, undefined, http);
|
|
341
|
+
}
|
|
342
|
+
await flyMachineVerify({ ...where, digest: input.digest, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// Restore: the Machine changed, so put back what it served (or stop it
|
|
345
|
+
// when it served nothing), then fail the step.
|
|
346
|
+
try {
|
|
347
|
+
if (released.previous) {
|
|
348
|
+
await flyMachineRestore({ ...where, config: released.previous.config, wait: input.wait }, undefined, http);
|
|
349
|
+
log(`fly-release: restored ${released.app}/${released.machine.name} to ${previousRelease?.digest ?? "its previous config"}`);
|
|
350
|
+
} else {
|
|
351
|
+
await flyMachineStop({ ...where, wait: input.wait }, undefined, http);
|
|
352
|
+
log(`fly-release: nothing had been released on ${released.app}/${released.machine.name}; stopped it`);
|
|
353
|
+
}
|
|
354
|
+
} catch (restoreErr) {
|
|
355
|
+
log(`fly-release: restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
|
|
356
|
+
}
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: input.digest }, released.config);
|
|
361
|
+
return {
|
|
362
|
+
uri: uriOf(released.app, released.machine.name),
|
|
363
|
+
digest: input.digest,
|
|
364
|
+
...(gitSha ? { gitSha } : {}),
|
|
365
|
+
app: released.app,
|
|
366
|
+
machine: released.machine,
|
|
367
|
+
previous: previousRelease,
|
|
368
|
+
migrations,
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
rollback: rollBack,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Build the `fly-rollback` capability. */
|
|
376
|
+
export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capability<FlyRollbackInput, FlyRollbackOutput> {
|
|
377
|
+
const log = deps.log ?? ((line: string) => console.error(line));
|
|
378
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
379
|
+
|
|
380
|
+
async function recordedConfig(ctx: DeployContext, app: string, machine: string, digest: string): Promise<Record<string, unknown>> {
|
|
381
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
382
|
+
const config = await store.read({ app, machine, digest });
|
|
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);
|
|
389
|
+
const http = httpFor();
|
|
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);
|
|
392
|
+
await flyMachineVerify({ app, machine, digest, endpoint: input.endpoint, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
393
|
+
return readMachineRelease((config as { metadata?: Record<string, string> }).metadata);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
kind: "fly-rollback",
|
|
398
|
+
rollbackPolicy: "native",
|
|
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;
|
|
402
|
+
const http = httpFor();
|
|
403
|
+
const { target } = loadTarget(input.plan, input.machine);
|
|
404
|
+
const live = await findMachine({ base: resolveEndpoint(input) }, target.app, target.name, http);
|
|
405
|
+
if (!live) throw new Error(`fly-rollback: no machine ${target.name} on app ${target.app}`);
|
|
406
|
+
const serving = readMachineRelease(live.config?.metadata);
|
|
407
|
+
const to = input.to ?? serving?.previousDigest;
|
|
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`);
|
|
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
|
+
);
|
|
422
|
+
return {
|
|
423
|
+
uri: uriOf(target.app, target.name),
|
|
424
|
+
digest: to,
|
|
425
|
+
...(restored?.gitSha ? { gitSha: restored.gitSha } : {}),
|
|
426
|
+
app: target.app,
|
|
427
|
+
machine: { id: live.id, name: target.name },
|
|
428
|
+
previous: serving ?? null,
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
async rollback(ctx, input, output) {
|
|
432
|
+
if (!output?.previous) return;
|
|
433
|
+
await restoreTo(ctx, input, output.app, output.machine.name, output.previous.digest);
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
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
|
+
|
|
455
|
+
/** The default `fly-release` capability. */
|
|
456
|
+
export const flyReleaseCapability = createFlyReleaseCapability();
|
|
457
|
+
/** The default `fly-rollback` capability. */
|
|
458
|
+
export const flyRollbackCapability = createFlyRollbackCapability();
|
package/src/components/index.ts
CHANGED
|
@@ -12,3 +12,19 @@ export {
|
|
|
12
12
|
createFlySpriteActivities,
|
|
13
13
|
parseSpriteExecFailure,
|
|
14
14
|
} from "./run-agent";
|
|
15
|
+
export {
|
|
16
|
+
flyReleaseCapability,
|
|
17
|
+
flyRollbackCapability,
|
|
18
|
+
createFlyReleaseCapability,
|
|
19
|
+
createFlyRollbackCapability,
|
|
20
|
+
migrationReceipt,
|
|
21
|
+
} from "./fly-release";
|
|
22
|
+
export type {
|
|
23
|
+
FlyReleaseInput,
|
|
24
|
+
FlyReleaseOutput,
|
|
25
|
+
FlyRollbackInput,
|
|
26
|
+
FlyRollbackOutput,
|
|
27
|
+
FlyMigration,
|
|
28
|
+
FlyVerify,
|
|
29
|
+
FlyReleaseDeps,
|
|
30
|
+
} from "./fly-release";
|
|
@@ -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
|
+
});
|