@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
|
@@ -0,0 +1,341 @@
|
|
|
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;
|
|
18
|
+
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
19
|
+
* witnessed by a receipt in chant's lifecycle receipt store
|
|
20
|
+
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
21
|
+
* on the migrated data;
|
|
22
|
+
* 3. verify: the Machine is started with this release, and its health
|
|
23
|
+
* endpoint answers when a URL is given;
|
|
24
|
+
* 4. on a failure after the Machine changed, restore: the config it replaced
|
|
25
|
+
* goes back (or, on a first release, the Machine is stopped), and the step
|
|
26
|
+
* fails.
|
|
27
|
+
*
|
|
28
|
+
* Its output carries `uri` and `digest`, so `chant run --components` records
|
|
29
|
+
* the release in the ledger, and the Machine's metadata carries the same
|
|
30
|
+
* digest, which `chant components status --live` compares with it.
|
|
31
|
+
*
|
|
32
|
+
* Each release's Machine config is kept on the lifecycle branch
|
|
33
|
+
* (../release-store.ts). `fly-rollback` puts back the config of the release
|
|
34
|
+
* the serving one replaced (or the digest it is given), which is also the
|
|
35
|
+
* saga compensation `fly-release` declares.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
39
|
+
import type { Capability, DeployContext } from "@intentius/chant/components/capability";
|
|
40
|
+
import type { ReceiptStore, EffectReceiptRef } from "@intentius/chant/op/receipt-store";
|
|
41
|
+
import {
|
|
42
|
+
findMachine,
|
|
43
|
+
flyMachineExec,
|
|
44
|
+
flyMachineRelease,
|
|
45
|
+
flyMachineRestart,
|
|
46
|
+
flyMachineRestore,
|
|
47
|
+
flyMachineStop,
|
|
48
|
+
flyMachineVerify,
|
|
49
|
+
readMachineRelease,
|
|
50
|
+
releaseTarget,
|
|
51
|
+
type MachineRelease,
|
|
52
|
+
} from "../op/activities/machine-release";
|
|
53
|
+
import { defaultFlyHttp, parsePlan, resolveEndpoint, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
54
|
+
import type { MachineConfigStore } from "../release-store";
|
|
55
|
+
import { readFileSync } from "node:fs";
|
|
56
|
+
|
|
57
|
+
/** One migration, run inside the Machine once per environment. */
|
|
58
|
+
export interface FlyMigration {
|
|
59
|
+
/** Its name: the receipt is named after it, so a name fires once. */
|
|
60
|
+
name: string;
|
|
61
|
+
/** The command, run in the Machine: an argv, or a string run with `sh -c`. */
|
|
62
|
+
command: string[] | string;
|
|
63
|
+
/** A digest of the migration's content. A migration whose content changes fires again. Default: a digest of the command. */
|
|
64
|
+
sha?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Health check after the release serves. */
|
|
68
|
+
export interface FlyVerify {
|
|
69
|
+
/** The app's public URL. Without it, only the Machine's state and metadata are checked. */
|
|
70
|
+
url?: string;
|
|
71
|
+
/** Default `/health`. */
|
|
72
|
+
healthPath?: string;
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
intervalMs?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FlyReleaseInput {
|
|
78
|
+
/** Path to the fly build output (`chant build --lexicon fly -o <path>`). */
|
|
79
|
+
plan: string;
|
|
80
|
+
/** The Machine that serves, when the plan declares more than one (entity or Machine name). */
|
|
81
|
+
machine?: string;
|
|
82
|
+
/** The release's digest: what the ledger records and the Machine's metadata names. */
|
|
83
|
+
digest: string;
|
|
84
|
+
/** The commit it was built from. Default: `git rev-parse HEAD`, as the ledger records. */
|
|
85
|
+
gitSha?: string;
|
|
86
|
+
/** A human label for the release. */
|
|
87
|
+
release?: string;
|
|
88
|
+
/** An image to run in place of the declared one. */
|
|
89
|
+
image?: string;
|
|
90
|
+
/** Env added to the declared Machine's env. */
|
|
91
|
+
env?: Record<string, string>;
|
|
92
|
+
/** Migrations, in order. */
|
|
93
|
+
migrations?: FlyMigration[];
|
|
94
|
+
verify?: FlyVerify;
|
|
95
|
+
/** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
|
|
96
|
+
endpoint?: string;
|
|
97
|
+
wait?: WaitOpts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface FlyReleaseOutput {
|
|
101
|
+
/** `fly://<app>/<machine>`: where the release was promoted. */
|
|
102
|
+
uri: string;
|
|
103
|
+
digest: string;
|
|
104
|
+
gitSha?: string;
|
|
105
|
+
app: string;
|
|
106
|
+
machine: { id: string; name: string };
|
|
107
|
+
/** The release the Machine served before this one, when it named one. */
|
|
108
|
+
previous: MachineRelease | null;
|
|
109
|
+
/** Each migration, and whether it fired this run. */
|
|
110
|
+
migrations: Array<{ name: string; fired: boolean }>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface FlyRollbackInput {
|
|
114
|
+
/** Path to the fly build output, naming the App and Machine. */
|
|
115
|
+
plan: string;
|
|
116
|
+
machine?: string;
|
|
117
|
+
/** The release digest to go back to. Default: the one the serving release replaced. */
|
|
118
|
+
to?: string;
|
|
119
|
+
verify?: FlyVerify;
|
|
120
|
+
endpoint?: string;
|
|
121
|
+
wait?: WaitOpts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface FlyRollbackOutput {
|
|
125
|
+
uri: string;
|
|
126
|
+
/** The release serving now. */
|
|
127
|
+
digest: string;
|
|
128
|
+
gitSha?: string;
|
|
129
|
+
app: string;
|
|
130
|
+
machine: { id: string; name: string };
|
|
131
|
+
/** The release it replaced. */
|
|
132
|
+
previous: MachineRelease | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What the capabilities reach the world through. Every field has a default; tests replace them. */
|
|
136
|
+
export interface FlyReleaseDeps {
|
|
137
|
+
http?: FlyHttp;
|
|
138
|
+
/** The Machine configs each release applied. Default: the lifecycle branch, per environment. */
|
|
139
|
+
configStore?: (ctx: DeployContext) => MachineConfigStore | Promise<MachineConfigStore>;
|
|
140
|
+
/** Migration receipts. Default: chant's lifecycle receipt store, per environment. */
|
|
141
|
+
receiptStore?: (ctx: DeployContext) => ReceiptStore | Promise<ReceiptStore>;
|
|
142
|
+
/** The commit a release defaults to. Default: `git rev-parse HEAD`. */
|
|
143
|
+
headCommit?: () => Promise<string>;
|
|
144
|
+
fetch?: typeof fetch;
|
|
145
|
+
log?: (line: string) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function defaultConfigStore(ctx: DeployContext): Promise<MachineConfigStore> {
|
|
149
|
+
const { lifecycleMachineConfigStore } = await import("../release-store");
|
|
150
|
+
return lifecycleMachineConfigStore(ctx.env);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function defaultReceiptStore(ctx: DeployContext): Promise<ReceiptStore> {
|
|
154
|
+
const { lifecycleReceiptStore } = await import("@intentius/chant/op/lifecycle-receipt-store");
|
|
155
|
+
return lifecycleReceiptStore({ environment: ctx.env });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function defaultHeadCommit(): Promise<string> {
|
|
159
|
+
const { getHeadCommit } = await import("@intentius/chant/lifecycle/git");
|
|
160
|
+
return getHeadCommit();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sha256 = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`;
|
|
164
|
+
|
|
165
|
+
/** The receipt a migration fires under on an app. */
|
|
166
|
+
export function migrationReceipt(app: string, migration: FlyMigration): { ref: EffectReceiptRef; expectation: string } {
|
|
167
|
+
const command = typeof migration.command === "string" ? migration.command : JSON.stringify(migration.command);
|
|
168
|
+
return {
|
|
169
|
+
ref: { name: `fly-migration:${app}/${migration.name}`, effect: "fly-migrate", flavor: "hash", inputs: { app, migration: migration.name } },
|
|
170
|
+
expectation: migration.sha ?? sha256(command),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function loadTarget(planPath: string, machine?: string) {
|
|
175
|
+
const plan = parsePlan(readFileSync(planPath, "utf8"));
|
|
176
|
+
return { plan, target: releaseTarget(plan, machine) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const uriOf = (app: string, machine: string) => `fly://${app}/${machine}`;
|
|
180
|
+
|
|
181
|
+
/** Build the `fly-release` capability. */
|
|
182
|
+
export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capability<FlyReleaseInput, FlyReleaseOutput> {
|
|
183
|
+
const log = deps.log ?? ((line: string) => console.error(line));
|
|
184
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
185
|
+
|
|
186
|
+
async function rollBack(ctx: DeployContext, input: FlyReleaseInput, output?: FlyReleaseOutput): Promise<void> {
|
|
187
|
+
if (!output) return;
|
|
188
|
+
const http = httpFor();
|
|
189
|
+
const target = { app: output.app, machine: output.machine.name, endpoint: input.endpoint };
|
|
190
|
+
if (!output.previous) {
|
|
191
|
+
await flyMachineStop({ ...target, wait: input.wait }, undefined, http);
|
|
192
|
+
log(`fly-release: nothing served before ${output.digest}; stopped ${output.app}/${output.machine.name}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
196
|
+
const config = await store.read({ app: output.app, machine: output.machine.name, digest: output.previous.digest });
|
|
197
|
+
if (!config) {
|
|
198
|
+
throw new Error(`fly-release: no recorded Machine config for ${output.previous.digest} on ${output.app}/${output.machine.name}; nothing to roll back to`);
|
|
199
|
+
}
|
|
200
|
+
await flyMachineRestore({ ...target, config, wait: input.wait }, undefined, http);
|
|
201
|
+
log(`fly-release: rolled ${output.app}/${output.machine.name} back to ${output.previous.digest}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
kind: "fly-release",
|
|
206
|
+
rollbackPolicy: "native",
|
|
207
|
+
async run(ctx, input) {
|
|
208
|
+
if (!input.digest) throw new Error("fly-release: a release needs its digest");
|
|
209
|
+
const http = httpFor();
|
|
210
|
+
const { plan } = loadTarget(input.plan, input.machine);
|
|
211
|
+
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
212
|
+
const configs = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
213
|
+
|
|
214
|
+
const released = await flyMachineRelease(
|
|
215
|
+
{
|
|
216
|
+
plan,
|
|
217
|
+
machine: input.machine,
|
|
218
|
+
release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
|
|
219
|
+
image: input.image,
|
|
220
|
+
env: input.env,
|
|
221
|
+
endpoint: input.endpoint,
|
|
222
|
+
wait: input.wait,
|
|
223
|
+
},
|
|
224
|
+
undefined,
|
|
225
|
+
http,
|
|
226
|
+
);
|
|
227
|
+
const where = { app: released.app, machine: released.machine.name, endpoint: input.endpoint };
|
|
228
|
+
log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}`);
|
|
229
|
+
|
|
230
|
+
// The config the Machine served before is kept too, so a Machine
|
|
231
|
+
// released before chant recorded configs can still be rolled back to.
|
|
232
|
+
const replaced = released.previous?.release;
|
|
233
|
+
const previousRelease: MachineRelease | null =
|
|
234
|
+
replaced && replaced.digest !== input.digest
|
|
235
|
+
? replaced
|
|
236
|
+
: released.release.previousDigest
|
|
237
|
+
? { digest: released.release.previousDigest }
|
|
238
|
+
: null;
|
|
239
|
+
if (replaced && previousRelease?.digest === replaced.digest && !(await configs.read({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }))) {
|
|
240
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }, released.previous!.config);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const migrations: FlyReleaseOutput["migrations"] = [];
|
|
244
|
+
try {
|
|
245
|
+
if (input.migrations?.length) {
|
|
246
|
+
const receipts = await (deps.receiptStore ?? defaultReceiptStore)(ctx);
|
|
247
|
+
for (const migration of input.migrations) {
|
|
248
|
+
const { ref, expectation } = migrationReceipt(released.app, migration);
|
|
249
|
+
if ((await receipts.read(ref)) === expectation) {
|
|
250
|
+
migrations.push({ name: migration.name, fired: false });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
log(`fly-release: migrating ${migration.name} on ${released.app}/${released.machine.name}`);
|
|
254
|
+
await flyMachineExec({ ...where, command: migration.command }, undefined, http);
|
|
255
|
+
// The receipt is written last, on success only: a failed migration fires again next run.
|
|
256
|
+
await receipts.write(ref, expectation);
|
|
257
|
+
migrations.push({ name: migration.name, fired: true });
|
|
258
|
+
}
|
|
259
|
+
if (migrations.some((m) => m.fired)) await flyMachineRestart({ ...where, wait: input.wait }, undefined, http);
|
|
260
|
+
}
|
|
261
|
+
await flyMachineVerify({ ...where, digest: input.digest, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
// Restore: the Machine changed, so put back what it served (or stop it
|
|
264
|
+
// when it served nothing), then fail the step.
|
|
265
|
+
try {
|
|
266
|
+
if (released.previous) {
|
|
267
|
+
await flyMachineRestore({ ...where, config: released.previous.config, wait: input.wait }, undefined, http);
|
|
268
|
+
log(`fly-release: restored ${released.app}/${released.machine.name} to ${previousRelease?.digest ?? "its previous config"}`);
|
|
269
|
+
} else {
|
|
270
|
+
await flyMachineStop({ ...where, wait: input.wait }, undefined, http);
|
|
271
|
+
log(`fly-release: nothing had been released on ${released.app}/${released.machine.name}; stopped it`);
|
|
272
|
+
}
|
|
273
|
+
} catch (restoreErr) {
|
|
274
|
+
log(`fly-release: restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
|
|
275
|
+
}
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: input.digest }, released.config);
|
|
280
|
+
return {
|
|
281
|
+
uri: uriOf(released.app, released.machine.name),
|
|
282
|
+
digest: input.digest,
|
|
283
|
+
...(gitSha ? { gitSha } : {}),
|
|
284
|
+
app: released.app,
|
|
285
|
+
machine: released.machine,
|
|
286
|
+
previous: previousRelease,
|
|
287
|
+
migrations,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
rollback: rollBack,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Build the `fly-rollback` capability. */
|
|
295
|
+
export function createFlyRollbackCapability(deps: FlyReleaseDeps = {}): Capability<FlyRollbackInput, FlyRollbackOutput> {
|
|
296
|
+
const log = deps.log ?? ((line: string) => console.error(line));
|
|
297
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
298
|
+
|
|
299
|
+
async function restoreTo(ctx: DeployContext, input: FlyRollbackInput, app: string, machine: string, digest: string) {
|
|
300
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
301
|
+
const config = await store.read({ app, machine, digest });
|
|
302
|
+
if (!config) throw new Error(`fly-rollback: no recorded Machine config for ${digest} on ${app}/${machine}`);
|
|
303
|
+
const http = httpFor();
|
|
304
|
+
await flyMachineRestore({ app, machine, config, endpoint: input.endpoint, wait: input.wait }, undefined, http);
|
|
305
|
+
await flyMachineVerify({ app, machine, digest, endpoint: input.endpoint, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
306
|
+
return readMachineRelease((config as { metadata?: Record<string, string> }).metadata);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
kind: "fly-rollback",
|
|
311
|
+
rollbackPolicy: "native",
|
|
312
|
+
async run(ctx, input) {
|
|
313
|
+
const http = httpFor();
|
|
314
|
+
const { target } = loadTarget(input.plan, input.machine);
|
|
315
|
+
const live = await findMachine({ base: resolveEndpoint(input) }, target.app, target.name, http);
|
|
316
|
+
if (!live) throw new Error(`fly-rollback: no machine ${target.name} on app ${target.app}`);
|
|
317
|
+
const serving = readMachineRelease(live.config?.metadata);
|
|
318
|
+
const to = input.to ?? serving?.previousDigest;
|
|
319
|
+
if (!to) throw new Error(`fly-rollback: ${target.app}/${target.name} serves ${serving?.digest ?? "no release"}, with no release before it to roll back to`);
|
|
320
|
+
const restored = await restoreTo(ctx, input, target.app, target.name, to);
|
|
321
|
+
log(`fly-rollback: ${target.app}/${target.name} serves ${to} again (was ${serving?.digest ?? "no release"})`);
|
|
322
|
+
return {
|
|
323
|
+
uri: uriOf(target.app, target.name),
|
|
324
|
+
digest: to,
|
|
325
|
+
...(restored?.gitSha ? { gitSha: restored.gitSha } : {}),
|
|
326
|
+
app: target.app,
|
|
327
|
+
machine: { id: live.id, name: target.name },
|
|
328
|
+
previous: serving ?? null,
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
async rollback(ctx, input, output) {
|
|
332
|
+
if (!output?.previous) return;
|
|
333
|
+
await restoreTo(ctx, input, output.app, output.machine.name, output.previous.digest);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The default `fly-release` capability. */
|
|
339
|
+
export const flyReleaseCapability = createFlyReleaseCapability();
|
|
340
|
+
/** The default `fly-rollback` capability. */
|
|
341
|
+
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";
|
|
@@ -126,13 +126,28 @@ describe("describeResources: live machines → ResourceMetadata verdicts", () =>
|
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
test("with no readable plan, declared entities are unobserved rather than absent (#1089)", async () => {
|
|
129
|
+
// Nothing a plan can be rebuilt from (a Secret keys no read): no plan at all.
|
|
130
|
+
const secrets = entities([["token", "Fly::Machines::Secret"]]);
|
|
129
131
|
const res = await describeResources(
|
|
130
|
-
{ environment: "prod", buildOutput: "", entityNames: ["
|
|
132
|
+
{ environment: "prod", buildOutput: "", entityNames: ["token"], entities: secrets, endpoint: ENDPOINT },
|
|
131
133
|
fakeHttp([]),
|
|
132
134
|
);
|
|
133
135
|
expect(res.resources).toEqual({});
|
|
134
|
-
expect(res.unobserved?.
|
|
135
|
-
|
|
136
|
+
expect(res.unobserved?.token?.reason).toBe("read-failed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("with no build output, the plan is rebuilt from the declared entities (status --live, #2736)", async () => {
|
|
140
|
+
const declared = new Map([
|
|
141
|
+
["shopApp", { entityType: "Fly::Machines::App", props: { name: APP, org_slug: "personal" } as Record<string, unknown> }],
|
|
142
|
+
["web", { entityType: "Fly::Machines::Machine", props: { name: "web" } as Record<string, unknown> }],
|
|
143
|
+
]);
|
|
144
|
+
const res = await describeResources(
|
|
145
|
+
{ environment: "prod", buildOutput: "", entityNames: ["shopApp", "web"], entities: declared, endpoint: ENDPOINT },
|
|
146
|
+
fakeHttp([liveMachine("web", "nginx:1", { id: "m-web" })]),
|
|
147
|
+
);
|
|
148
|
+
expect(res.resources.web?.physicalId).toBe("m-web");
|
|
149
|
+
expect(res.resources.shopApp?.physicalId).toBe(APP);
|
|
150
|
+
expect(res.unobserved ?? {}).toEqual({});
|
|
136
151
|
});
|
|
137
152
|
|
|
138
153
|
test("the owned filter records a withheld declared machine as `filtered`, not absent (#1089)", async () => {
|
|
@@ -31,6 +31,7 @@ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@int
|
|
|
31
31
|
import { normalizeObservation, observation, unobservedAll } from "@intentius/chant/observation";
|
|
32
32
|
import { readOwnership } from "@intentius/chant/ownership";
|
|
33
33
|
import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
34
|
+
import { readMachineRelease } from "./release-metadata";
|
|
34
35
|
import {
|
|
35
36
|
buildChangeSet,
|
|
36
37
|
renderChangeSet,
|
|
@@ -99,6 +100,59 @@ function serializedName(req: FlapsRequest, entityName: string): string {
|
|
|
99
100
|
return typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** The release attributes of a Machine whose metadata names one (#2736). */
|
|
104
|
+
function releaseAttributes(metadata: Record<string, string> | undefined): Record<string, unknown> {
|
|
105
|
+
const release = readMachineRelease(metadata);
|
|
106
|
+
return release ? { digest: release.digest, release } : {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A string prop, or undefined. */
|
|
110
|
+
function stringProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
111
|
+
const v = props[key];
|
|
112
|
+
return typeof v === "string" && v ? v : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A plan rebuilt from declared entities (#2736), for a caller that has the
|
|
117
|
+
* entities and no serialized build. It holds only what a read is keyed by:
|
|
118
|
+
* each App's name, and each Machine, Volume, IP and Certificate with the App it
|
|
119
|
+
* belongs to (an `app` prop naming it, else the sole declared App). Returns ""
|
|
120
|
+
* when there are no entities to rebuild from.
|
|
121
|
+
*/
|
|
122
|
+
export function planFromEntities(
|
|
123
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
|
|
124
|
+
): string {
|
|
125
|
+
const plan: FlyPlan = {};
|
|
126
|
+
const apps: string[] = [];
|
|
127
|
+
for (const [name, { entityType, props }] of entities) {
|
|
128
|
+
if (entityType !== TYPE.app) continue;
|
|
129
|
+
const appName = stringProp(props, "name") ?? name;
|
|
130
|
+
apps.push(appName);
|
|
131
|
+
plan[name] = { endpoint: "/v1/apps", method: "POST", body: { app_name: appName } };
|
|
132
|
+
}
|
|
133
|
+
const soleApp = apps.length === 1 ? apps[0] : "{app}";
|
|
134
|
+
const owning = (props: Record<string, unknown>): string => {
|
|
135
|
+
const app = props.app;
|
|
136
|
+
if (typeof app === "string" && app) return app;
|
|
137
|
+
const ref = app && typeof app === "object" ? (app as { props?: Record<string, unknown> }).props : undefined;
|
|
138
|
+
return (ref && stringProp(ref, "name")) ?? soleApp;
|
|
139
|
+
};
|
|
140
|
+
for (const [name, { entityType, props }] of entities) {
|
|
141
|
+
const app = owning(props);
|
|
142
|
+
const base = `/v1/apps/${app}`;
|
|
143
|
+
if (entityType === TYPE.machine) {
|
|
144
|
+
plan[name] = { endpoint: `${base}/machines`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
|
|
145
|
+
} else if (entityType === TYPE.volume) {
|
|
146
|
+
plan[name] = { endpoint: `${base}/volumes`, method: "POST", body: { name: stringProp(props, "name") ?? name } };
|
|
147
|
+
} else if (entityType === TYPE.ip) {
|
|
148
|
+
plan[name] = { endpoint: `${base}/ip_assignments`, method: "POST", body: { type: props.type } };
|
|
149
|
+
} else if (entityType === TYPE.cert) {
|
|
150
|
+
plan[name] = { endpoint: `${base}/certificates`, method: "POST", body: { hostname: props.hostname } };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Object.keys(plan).length ? JSON.stringify(plan) : "";
|
|
154
|
+
}
|
|
155
|
+
|
|
102
156
|
/**
|
|
103
157
|
* List live Fly resources over flaps and return them keyed by chant entity name,
|
|
104
158
|
* each tagged with an ownership verdict the core change set reads to decide
|
|
@@ -116,10 +170,13 @@ export async function describeResources(
|
|
|
116
170
|
const result: Record<string, ResourceMetadata> = {};
|
|
117
171
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
118
172
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
173
|
+
// `chant components status --live` hands over the declared entities with no
|
|
174
|
+
// serialized build (#2736): the plan is rebuilt from their props, enough to
|
|
175
|
+
// key each read by. Without either there is nothing to key a read by, so
|
|
176
|
+
// chant never asks flaps anything — every declared entity is unobserved, not
|
|
177
|
+
// absent (#1089). Returning an empty map here used to plan a create for each.
|
|
178
|
+
const buildOutput = options.buildOutput || planFromEntities(options.entities);
|
|
179
|
+
if (!buildOutput) {
|
|
123
180
|
return observation(
|
|
124
181
|
result,
|
|
125
182
|
unobservedAll(options.entities.keys(), "read-failed", "no fly plan in the build output to read back", options.entities),
|
|
@@ -128,7 +185,7 @@ export async function describeResources(
|
|
|
128
185
|
|
|
129
186
|
let plan: FlyPlan;
|
|
130
187
|
try {
|
|
131
|
-
plan = parsePlan(
|
|
188
|
+
plan = parsePlan(buildOutput);
|
|
132
189
|
} catch (err) {
|
|
133
190
|
return observation(
|
|
134
191
|
result,
|
|
@@ -318,11 +375,18 @@ export async function describeResources(
|
|
|
318
375
|
// boundary types (volumes/ips/certs) get their verdict inferred at the
|
|
319
376
|
// app boundary and carry no marker of their own — absent, not guessed.
|
|
320
377
|
marker: readOwnership(m.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS),
|
|
378
|
+
// The release the Machine serves (#2736): `digest` is the identity
|
|
379
|
+
// attribute core's status join compares with the release ledger
|
|
380
|
+
// (LIVE_IDENTITY_ATTRIBUTES); `release` carries the rest of what its
|
|
381
|
+
// metadata records. The commit is reported inside `release`, not as
|
|
382
|
+
// the `gitSha` identity: a rollback serves an earlier commit under the
|
|
383
|
+
// digest the ledger records again, so only the digest is the join.
|
|
321
384
|
attributes: pruneUndefined({
|
|
322
385
|
app,
|
|
323
386
|
machineName: m.name,
|
|
324
387
|
instanceId: m.instance_id,
|
|
325
388
|
config: m.config,
|
|
389
|
+
...releaseAttributes(m.config?.metadata),
|
|
326
390
|
}),
|
|
327
391
|
};
|
|
328
392
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ export { Fly, Region, OrgSlug, AppName, PseudoParameter } from "./pseudo";
|
|
|
16
16
|
// Ownership marker convention (machine config.metadata keys)
|
|
17
17
|
export { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
18
18
|
|
|
19
|
+
// The release a Machine serves, in its metadata (#2736): read by
|
|
20
|
+
// describeResources and compared with the release ledger by
|
|
21
|
+
// `chant components status --live`.
|
|
22
|
+
export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "./release-metadata";
|
|
23
|
+
export type { MachineRelease } from "./release-metadata";
|
|
24
|
+
|
|
19
25
|
// Deploy Op composite + typed step builders (#744). `flyDeploy` returns a
|
|
20
26
|
// `boot → build → flyApply → wait → teardown` Op; the step builders wrap the
|
|
21
27
|
// generic `activity()` so the fly activities resolve by name without a core change.
|
|
@@ -63,6 +69,12 @@ export {
|
|
|
63
69
|
spriteTaskRelease,
|
|
64
70
|
spritesUp,
|
|
65
71
|
spritesDown,
|
|
72
|
+
flyMachineRelease,
|
|
73
|
+
flyMachineExec,
|
|
74
|
+
flyMachineRestart,
|
|
75
|
+
flyMachineStop,
|
|
76
|
+
flyMachineVerify,
|
|
77
|
+
flyMachineRestore,
|
|
66
78
|
} from "./op/builders";
|
|
67
79
|
|
|
68
80
|
// Generated resources — export everything from generated index.
|
|
@@ -797,7 +797,14 @@ export async function pruneSecrets(
|
|
|
797
797
|
|
|
798
798
|
export interface FlyApplyArgs {
|
|
799
799
|
/** Path to the #738 serializer's JSON output (entity name → flaps request). */
|
|
800
|
-
planPath
|
|
800
|
+
planPath?: string;
|
|
801
|
+
/**
|
|
802
|
+
* The plan itself, in place of `planPath` (#2736): a caller that changes the
|
|
803
|
+
* declared plan before applying it (the release a Machine serves, stamped
|
|
804
|
+
* into its metadata) passes it here rather than writing a file that may
|
|
805
|
+
* hold Secret values.
|
|
806
|
+
*/
|
|
807
|
+
plan?: FlyPlan;
|
|
801
808
|
/** flaps endpoint override (D3). Default: `FLY_FLAPS_BASE_URL` env, else real Fly. */
|
|
802
809
|
endpoint?: string;
|
|
803
810
|
/** Bearer token for real Fly. Default: `FLY_API_TOKEN`. mudflaps ignores it. */
|
|
@@ -835,6 +842,11 @@ export interface FlyApplyArgs {
|
|
|
835
842
|
* because the serializer produced it and the two being out of sync is a bug in
|
|
836
843
|
* the lexicon, not a resource the user declined.
|
|
837
844
|
*/
|
|
845
|
+
function readPlanFile(planPath: string | undefined): FlyPlan {
|
|
846
|
+
if (!planPath) throw new Error("flyApply needs a plan: pass planPath (the fly build output) or plan");
|
|
847
|
+
return parsePlan(readFileSync(planPath, "utf8"));
|
|
848
|
+
}
|
|
849
|
+
|
|
838
850
|
export function toApplyResult(result: {
|
|
839
851
|
apps: Array<{ app: string; created: boolean }>;
|
|
840
852
|
machines: Array<{ app: string; name: string; action: "created" | "updated" | "noop" }>;
|
|
@@ -892,7 +904,7 @@ export async function flyApply(
|
|
|
892
904
|
prunedCerts: Array<{ app: string; hostname: string }>;
|
|
893
905
|
prunedSecrets: Array<{ app: string; name: string }>;
|
|
894
906
|
}> {
|
|
895
|
-
const plan =
|
|
907
|
+
const plan = args.plan ?? readPlanFile(args.planPath);
|
|
896
908
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
897
909
|
const opts = args.wait ?? {};
|
|
898
910
|
|
|
@@ -1046,7 +1058,7 @@ export async function flyDelete(
|
|
|
1046
1058
|
signal?: AbortSignal,
|
|
1047
1059
|
http: FlyHttp = defaultFlyHttp(args.token),
|
|
1048
1060
|
): Promise<{ machines: Array<{ app: string; name: string }>; apps: Array<{ app: string; deleted: boolean }> }> {
|
|
1049
|
-
const plan =
|
|
1061
|
+
const plan = args.plan ?? readPlanFile(args.planPath);
|
|
1050
1062
|
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
1051
1063
|
const opts = args.wait ?? {};
|
|
1052
1064
|
|
|
@@ -35,6 +35,29 @@ export {
|
|
|
35
35
|
} from "./fly-apply";
|
|
36
36
|
export type { FlyApplyArgs, FlyPlan, FlapsRequest, FlapsMachine, FlyHttp, WaitOpts, ApplyCtx } from "./fly-apply";
|
|
37
37
|
|
|
38
|
+
// The release a Machine serves and the site steps over the Machines API
|
|
39
|
+
// (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
|
|
40
|
+
// restore. `loadActivities(["fly"])` binds these by name; the `fly-release`
|
|
41
|
+
// and `fly-rollback` capabilities compose them.
|
|
42
|
+
export {
|
|
43
|
+
flyMachineRelease,
|
|
44
|
+
flyMachineExec,
|
|
45
|
+
flyMachineRestart,
|
|
46
|
+
flyMachineStop,
|
|
47
|
+
flyMachineVerify,
|
|
48
|
+
flyMachineRestore,
|
|
49
|
+
} from "./machine-release";
|
|
50
|
+
export type {
|
|
51
|
+
MachineRelease,
|
|
52
|
+
FlyMachineReleaseArgs,
|
|
53
|
+
FlyMachineReleaseResult,
|
|
54
|
+
FlyMachineExecArgs,
|
|
55
|
+
FlyMachineExecResult,
|
|
56
|
+
FlyMachineStateArgs,
|
|
57
|
+
FlyMachineVerifyArgs,
|
|
58
|
+
FlyMachineRestoreArgs,
|
|
59
|
+
} from "./machine-release";
|
|
60
|
+
|
|
38
61
|
// mudflaps (Fly Machines API emulator) lifecycle — boots/tears down the local
|
|
39
62
|
// flaps target flyApply is exercised against.
|
|
40
63
|
export {
|