@intentius/chant-lexicon-fly 0.124.31 → 0.124.32
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/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +143 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +38 -0
- package/dist/codegen/generate-typescript.js +45 -0
- package/dist/codegen/generate.js +82 -0
- package/dist/codegen/naming.js +33 -0
- package/dist/codegen/package.js +35 -0
- package/dist/components/capability-plugin.js +26 -0
- package/dist/components/fly-release.js +335 -0
- package/dist/components/index.js +10 -0
- package/dist/components/run-agent.js +133 -0
- package/dist/composites/catalog.js +176 -0
- package/dist/composites/fly-deploy.js +91 -0
- package/dist/composites/fly-otel-collector.js +66 -0
- package/dist/composites/fly-site.js +80 -0
- package/dist/coverage.js +29 -0
- package/dist/describe-resources.js +443 -0
- package/dist/detect.js +63 -0
- package/dist/export-resources.js +81 -0
- package/dist/generated/index.js +44 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/import/generator.js +208 -0
- package/dist/import/live-export.js +81 -0
- package/dist/import/parser.js +157 -0
- package/dist/index.js +40 -0
- package/dist/integrity.json +3 -3
- package/dist/lint/audit-catalog.js +28 -0
- package/dist/lint/audit-lineage.js +5 -0
- package/dist/lint/post-synth/fly-helpers.js +24 -0
- package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
- package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
- package/dist/lint/post-synth/index.js +6 -0
- package/dist/lint/rules/guest-sizing.js +92 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/no-secret-literals.js +58 -0
- package/dist/lint/rules/valid-region.js +52 -0
- package/dist/lsp/completions.js +12 -0
- package/dist/lsp/hover.js +12 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +113 -0
- package/dist/okf/types/Machine.md +1 -1
- package/dist/op/activities/box-services.js +88 -0
- package/dist/op/activities/emulator-images.js +32 -0
- package/dist/op/activities/flaps.js +34 -0
- package/dist/op/activities/fly-apply.js +911 -0
- package/dist/op/activities/fly-release-step.js +21 -0
- package/dist/op/activities/fly-rollback-step.js +22 -0
- package/dist/op/activities/index.js +53 -0
- package/dist/op/activities/machine-release.js +236 -0
- package/dist/op/activities/machines-contract.js +81 -0
- package/dist/op/activities/machines-fake.js +203 -0
- package/dist/op/activities/machines-local-cli.js +85 -0
- package/dist/op/activities/machines-local.js +395 -0
- package/dist/op/activities/progress.js +10 -0
- package/dist/op/activities/sprite-config.js +409 -0
- package/dist/op/activities/sprite-fs.js +131 -0
- package/dist/op/activities/sprite-service-converge.js +224 -0
- package/dist/op/activities/sprite-services.js +174 -0
- package/dist/op/activities/sprite-tasks.js +57 -0
- package/dist/op/activities/sprites-contract.js +87 -0
- package/dist/op/activities/sprites-emulator.js +72 -0
- package/dist/op/activities/sprites-fake.js +547 -0
- package/dist/op/activities/sprites.js +515 -0
- package/dist/op/activity-contracts.js +82 -0
- package/dist/op/builders.js +157 -0
- package/dist/ownership.js +29 -0
- package/dist/package-cli.js +20 -0
- package/dist/plugin.js +298 -0
- package/dist/pseudo.js +26 -0
- package/dist/reference-catalog.js +37 -0
- package/dist/release-metadata.js +59 -0
- package/dist/release-store.js +46 -0
- package/dist/serializer.js +299 -0
- package/dist/spec/fetch.js +32 -0
- package/dist/spec/parse.js +260 -0
- package/dist/teardown.js +194 -0
- package/dist/types/index.d.ts +33 -2
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +33 -0
- package/package.json +11 -11
- package/src/codegen/package.ts +2 -2
- package/src/generated/index.d.ts +33 -2
|
@@ -0,0 +1,335 @@
|
|
|
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. A Machine the build stamped with
|
|
22
|
+
* telemetry attribution (#3060) also gets the release's `service.version`
|
|
23
|
+
* and `vcs.ref.head.revision` in its `OTEL_RESOURCE_ATTRIBUTES` (#3061,
|
|
24
|
+
* ws-081; see {@link releaseResourceAttributes});
|
|
25
|
+
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
26
|
+
* witnessed by a receipt in chant's lifecycle receipt store
|
|
27
|
+
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
28
|
+
* on the migrated data;
|
|
29
|
+
* 3. verify: the Machine is started with this release, and its health
|
|
30
|
+
* endpoint answers when a URL is given;
|
|
31
|
+
* 4. on a failure after the Machine changed, restore: the config it replaced
|
|
32
|
+
* goes back (or, on a first release, the Machine is stopped), and the step
|
|
33
|
+
* fails.
|
|
34
|
+
*
|
|
35
|
+
* Its output carries `uri` and `digest`, so `chant run --components` records
|
|
36
|
+
* the release in the ledger, and the Machine's metadata carries the same
|
|
37
|
+
* digest, which `chant components status --live` compares with it.
|
|
38
|
+
*
|
|
39
|
+
* Each release's Machine config is kept on the lifecycle branch
|
|
40
|
+
* (../release-store.ts). `fly-rollback` puts back the config of the release
|
|
41
|
+
* the serving one replaced (or the digest it is given), which is also the
|
|
42
|
+
* saga compensation `fly-release` declares. Given the source tree of that
|
|
43
|
+
* release (#2800), it checks the archive against its digest before any flaps
|
|
44
|
+
* call and refuses a recorded config that does not carry exactly that tree.
|
|
45
|
+
*/
|
|
46
|
+
import { createHash } from "node:crypto";
|
|
47
|
+
import { findMachine, flyMachineExec, flyMachineRelease, flyMachineRestart, flyMachineRestore, flyMachineStop, flyMachineVerify, readMachineRelease, releaseTarget, } from "../op/activities/machine-release.js";
|
|
48
|
+
import { defaultFlyHttp, parsePlan, resolveEndpoint } from "../op/activities/fly-apply.js";
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
import { releaseAttributesSuffix } from "@intentius/chant/telemetry-attribution";
|
|
51
|
+
/** Most bytes of files, base64, a release puts in a Machine's config (as chud's fly-site.mjs capped them). */
|
|
52
|
+
export const SOURCE_FILES_LIMIT = 1024 * 1024;
|
|
53
|
+
/** Where a source tree goes on the Machine, without a trailing slash. */
|
|
54
|
+
const intoOf = (source) => (source.into ?? "/srv/app").replace(/\/+$/, "");
|
|
55
|
+
/** A source tree's files as a Machine config carries them, once its archive is the approved one. */
|
|
56
|
+
async function sourceTreeFiles(source, kind) {
|
|
57
|
+
const { readSourceArchive } = await import("@intentius/chant/op/source-archive");
|
|
58
|
+
const into = intoOf(source);
|
|
59
|
+
const files = readSourceArchive(source).map((f) => ({
|
|
60
|
+
guest_path: `${into}/${f.path}`,
|
|
61
|
+
raw_value: f.data.toString("base64"),
|
|
62
|
+
...(f.executable ? { mode: 0o755 } : {}),
|
|
63
|
+
}));
|
|
64
|
+
if (files.length === 0)
|
|
65
|
+
throw new Error(`${kind}: ${source.archive} holds no files`);
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
/** A source tree as Machine files and the start command, once its archive is the approved one. */
|
|
69
|
+
export async function sourceMachineFiles(source) {
|
|
70
|
+
const into = intoOf(source);
|
|
71
|
+
const files = await sourceTreeFiles(source, "fly-release");
|
|
72
|
+
const bytes = files.reduce((n, f) => n + f.raw_value.length, 0);
|
|
73
|
+
if (bytes > SOURCE_FILES_LIMIT) {
|
|
74
|
+
throw new Error(`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\`).`);
|
|
75
|
+
}
|
|
76
|
+
const start = typeof source.start === "string" ? source.start : source.start.map(shellQuote).join(" ");
|
|
77
|
+
return { files, cmd: ["sh", "-c", `cd ${shellQuote(into)} && exec ${start}`] };
|
|
78
|
+
}
|
|
79
|
+
const shellQuote = (v) => (/^[A-Za-z0-9_./:=@%+-]+$/.test(v) ? v : `'${v.replaceAll("'", "'\\''")}'`);
|
|
80
|
+
async function defaultConfigStore(ctx) {
|
|
81
|
+
const { lifecycleMachineConfigStore } = await import("../release-store.js");
|
|
82
|
+
return lifecycleMachineConfigStore(ctx.env);
|
|
83
|
+
}
|
|
84
|
+
async function defaultReceiptStore(ctx) {
|
|
85
|
+
const { lifecycleReceiptStore } = await import("@intentius/chant/op/lifecycle-receipt-store");
|
|
86
|
+
return lifecycleReceiptStore({ environment: ctx.env });
|
|
87
|
+
}
|
|
88
|
+
async function defaultHeadCommit() {
|
|
89
|
+
const { getHeadCommit } = await import("@intentius/chant/lifecycle/git");
|
|
90
|
+
return getHeadCommit();
|
|
91
|
+
}
|
|
92
|
+
const sha256 = (s) => `sha256:${createHash("sha256").update(s).digest("hex")}`;
|
|
93
|
+
/** The receipt a migration fires under on an app. */
|
|
94
|
+
export function migrationReceipt(app, migration) {
|
|
95
|
+
const command = typeof migration.command === "string" ? migration.command : JSON.stringify(migration.command);
|
|
96
|
+
return {
|
|
97
|
+
ref: { name: `fly-migration:${app}/${migration.name}`, effect: "fly-migrate", flavor: "hash", inputs: { app, migration: migration.name } },
|
|
98
|
+
expectation: migration.sha ?? sha256(command),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A stamped Machine's `OTEL_RESOURCE_ATTRIBUTES` with the release's attributes
|
|
103
|
+
* (#3061, ws-081), or undefined when the build did not stamp it (no
|
|
104
|
+
* `chant.decl` key) or the release knows neither attribute.
|
|
105
|
+
*
|
|
106
|
+
* A Machine's env values are literal, so the deploy writes the full value in
|
|
107
|
+
* place of a `CHANT_RELEASE_ATTRIBUTES` reference the platform would expand.
|
|
108
|
+
* The release's keys replace the same keys in the declared value, as the
|
|
109
|
+
* suffix appended last does on Compose and Kubernetes: a digest-pinned image
|
|
110
|
+
* gave the build a `service.version`, and the release may run another image.
|
|
111
|
+
*/
|
|
112
|
+
export function releaseResourceAttributes(declared, release) {
|
|
113
|
+
if (typeof declared !== "string")
|
|
114
|
+
return undefined;
|
|
115
|
+
const pairs = declared.split(",").filter((kv) => kv !== "");
|
|
116
|
+
const key = (kv) => kv.split("=")[0].trim();
|
|
117
|
+
if (!pairs.some((kv) => key(kv) === "chant.decl"))
|
|
118
|
+
return undefined;
|
|
119
|
+
const added = releaseAttributesSuffix(release).split(",").filter((kv) => kv !== "");
|
|
120
|
+
if (added.length === 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
const replaced = new Set(added.map(key));
|
|
123
|
+
return [...pairs.filter((kv) => !replaced.has(key(kv))), ...added].join(",");
|
|
124
|
+
}
|
|
125
|
+
function loadTarget(planPath, machine) {
|
|
126
|
+
const plan = parsePlan(readFileSync(planPath, "utf8"));
|
|
127
|
+
return { plan, target: releaseTarget(plan, machine) };
|
|
128
|
+
}
|
|
129
|
+
const uriOf = (app, machine) => `fly://${app}/${machine}`;
|
|
130
|
+
/** Build the `fly-release` capability. */
|
|
131
|
+
export function createFlyReleaseCapability(deps = {}) {
|
|
132
|
+
const log = deps.log ?? ((line) => console.error(line));
|
|
133
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
134
|
+
async function rollBack(ctx, input, output) {
|
|
135
|
+
if (!output)
|
|
136
|
+
return;
|
|
137
|
+
const http = httpFor();
|
|
138
|
+
const target = { app: output.app, machine: output.machine.name, endpoint: input.endpoint };
|
|
139
|
+
if (!output.previous) {
|
|
140
|
+
await flyMachineStop({ ...target, wait: input.wait }, undefined, http);
|
|
141
|
+
log(`fly-release: nothing served before ${output.digest}; stopped ${output.app}/${output.machine.name}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
145
|
+
const config = await store.read({ app: output.app, machine: output.machine.name, digest: output.previous.digest });
|
|
146
|
+
if (!config) {
|
|
147
|
+
throw new Error(`fly-release: no recorded Machine config for ${output.previous.digest} on ${output.app}/${output.machine.name}; nothing to roll back to`);
|
|
148
|
+
}
|
|
149
|
+
await flyMachineRestore({ ...target, config, wait: input.wait }, undefined, http);
|
|
150
|
+
log(`fly-release: rolled ${output.app}/${output.machine.name} back to ${output.previous.digest}`);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
kind: "fly-release",
|
|
154
|
+
rollbackPolicy: "native",
|
|
155
|
+
async run(ctx, input) {
|
|
156
|
+
if (!input.digest)
|
|
157
|
+
throw new Error("fly-release: a release needs its digest");
|
|
158
|
+
const http = httpFor();
|
|
159
|
+
const { plan, target } = loadTarget(input.plan, input.machine);
|
|
160
|
+
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
161
|
+
const configs = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
162
|
+
// The tree is checked against its digest before the Machine changes.
|
|
163
|
+
const source = input.source ? await sourceMachineFiles(input.source) : undefined;
|
|
164
|
+
// APP_REVISION carries the commit into the Machine's env, as chud's
|
|
165
|
+
// fly-site.mjs did (chant#2834), so the app's own /health can report
|
|
166
|
+
// it. A caller's own APP_REVISION (input.env) wins over the commit.
|
|
167
|
+
// A stamped Machine's resource attributes name the same release as its
|
|
168
|
+
// metadata (#3061, ws-081), unless the caller sets its own.
|
|
169
|
+
const declaredEnv = target.request.body.config?.env;
|
|
170
|
+
const attributes = releaseResourceAttributes(declaredEnv?.OTEL_RESOURCE_ATTRIBUTES, { version: input.digest, revision: gitSha });
|
|
171
|
+
const env = {
|
|
172
|
+
APP_REVISION: gitSha,
|
|
173
|
+
...(attributes !== undefined ? { OTEL_RESOURCE_ATTRIBUTES: attributes } : {}),
|
|
174
|
+
...(input.env ?? {}),
|
|
175
|
+
};
|
|
176
|
+
const released = await flyMachineRelease({
|
|
177
|
+
plan,
|
|
178
|
+
machine: input.machine,
|
|
179
|
+
release: { digest: input.digest, gitSha, ...(input.release ? { release: input.release } : {}) },
|
|
180
|
+
image: input.image,
|
|
181
|
+
env,
|
|
182
|
+
...(source ? { files: source.files, cmd: source.cmd } : {}),
|
|
183
|
+
endpoint: input.endpoint,
|
|
184
|
+
wait: input.wait,
|
|
185
|
+
}, undefined, http);
|
|
186
|
+
const where = { app: released.app, machine: released.machine.name, endpoint: input.endpoint };
|
|
187
|
+
log(`fly-release: ${released.app}/${released.machine.name} ${released.action}, serving ${input.digest}`);
|
|
188
|
+
// The config the Machine served before is kept too, so a Machine
|
|
189
|
+
// released before chant recorded configs can still be rolled back to.
|
|
190
|
+
const replaced = released.previous?.release;
|
|
191
|
+
const previousRelease = replaced && replaced.digest !== input.digest
|
|
192
|
+
? replaced
|
|
193
|
+
: released.release.previousDigest
|
|
194
|
+
? { digest: released.release.previousDigest }
|
|
195
|
+
: null;
|
|
196
|
+
if (replaced && previousRelease?.digest === replaced.digest && !(await configs.read({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }))) {
|
|
197
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: previousRelease.digest }, released.previous.config);
|
|
198
|
+
}
|
|
199
|
+
const migrations = [];
|
|
200
|
+
try {
|
|
201
|
+
if (input.migrations?.length) {
|
|
202
|
+
const receipts = await (deps.receiptStore ?? defaultReceiptStore)(ctx);
|
|
203
|
+
for (const migration of input.migrations) {
|
|
204
|
+
const { ref, expectation } = migrationReceipt(released.app, migration);
|
|
205
|
+
if ((await receipts.read(ref)) === expectation) {
|
|
206
|
+
migrations.push({ name: migration.name, fired: false });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
log(`fly-release: migrating ${migration.name} on ${released.app}/${released.machine.name}`);
|
|
210
|
+
await flyMachineExec({ ...where, command: migration.command }, undefined, http);
|
|
211
|
+
// The receipt is written last, on success only: a failed migration fires again next run.
|
|
212
|
+
await receipts.write(ref, expectation);
|
|
213
|
+
migrations.push({ name: migration.name, fired: true });
|
|
214
|
+
}
|
|
215
|
+
if (migrations.some((m) => m.fired))
|
|
216
|
+
await flyMachineRestart({ ...where, wait: input.wait }, undefined, http);
|
|
217
|
+
}
|
|
218
|
+
await flyMachineVerify({ ...where, digest: input.digest, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
// Restore: the Machine changed, so put back what it served (or stop it
|
|
222
|
+
// when it served nothing), then fail the step.
|
|
223
|
+
try {
|
|
224
|
+
if (released.previous) {
|
|
225
|
+
await flyMachineRestore({ ...where, config: released.previous.config, wait: input.wait }, undefined, http);
|
|
226
|
+
log(`fly-release: restored ${released.app}/${released.machine.name} to ${previousRelease?.digest ?? "its previous config"}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
await flyMachineStop({ ...where, wait: input.wait }, undefined, http);
|
|
230
|
+
log(`fly-release: nothing had been released on ${released.app}/${released.machine.name}; stopped it`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (restoreErr) {
|
|
234
|
+
log(`fly-release: restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`);
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
await configs.write({ app: released.app, machine: released.machine.name, digest: input.digest }, released.config);
|
|
239
|
+
return {
|
|
240
|
+
uri: uriOf(released.app, released.machine.name),
|
|
241
|
+
digest: input.digest,
|
|
242
|
+
...(gitSha ? { gitSha } : {}),
|
|
243
|
+
app: released.app,
|
|
244
|
+
machine: released.machine,
|
|
245
|
+
previous: previousRelease,
|
|
246
|
+
migrations,
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
rollback: rollBack,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Build the `fly-rollback` capability. */
|
|
253
|
+
export function createFlyRollbackCapability(deps = {}) {
|
|
254
|
+
const log = deps.log ?? ((line) => console.error(line));
|
|
255
|
+
const httpFor = () => deps.http ?? defaultFlyHttp();
|
|
256
|
+
async function recordedConfig(ctx, app, machine, digest) {
|
|
257
|
+
const store = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
258
|
+
const config = await store.read({ app, machine, digest });
|
|
259
|
+
if (!config)
|
|
260
|
+
throw new Error(`fly-rollback: no recorded Machine config for ${digest} on ${app}/${machine}`);
|
|
261
|
+
return config;
|
|
262
|
+
}
|
|
263
|
+
async function restoreTo(ctx, input, app, machine, digest, config, serving) {
|
|
264
|
+
config ?? (config = await recordedConfig(ctx, app, machine, digest));
|
|
265
|
+
const http = httpFor();
|
|
266
|
+
// Already serving it (a rerun after the rollback finished): leave the Machine as it is.
|
|
267
|
+
if (serving !== digest)
|
|
268
|
+
await flyMachineRestore({ app, machine, config, endpoint: input.endpoint, wait: input.wait }, undefined, http);
|
|
269
|
+
await flyMachineVerify({ app, machine, digest, endpoint: input.endpoint, ...(input.verify ?? {}) }, undefined, http, deps.fetch);
|
|
270
|
+
return readMachineRelease(config.metadata);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
kind: "fly-rollback",
|
|
274
|
+
rollbackPolicy: "native",
|
|
275
|
+
async run(ctx, input) {
|
|
276
|
+
// The tree is checked against its digest before any flaps call.
|
|
277
|
+
const tree = input.source ? await sourceTreeFiles(input.source, "fly-rollback") : undefined;
|
|
278
|
+
const http = httpFor();
|
|
279
|
+
const { target } = loadTarget(input.plan, input.machine);
|
|
280
|
+
const live = await findMachine({ base: resolveEndpoint(input) }, target.app, target.name, http);
|
|
281
|
+
if (!live)
|
|
282
|
+
throw new Error(`fly-rollback: no machine ${target.name} on app ${target.app}`);
|
|
283
|
+
const serving = readMachineRelease(live.config?.metadata);
|
|
284
|
+
const to = input.to ?? serving?.previousDigest;
|
|
285
|
+
if (!to)
|
|
286
|
+
throw new Error(`fly-rollback: ${target.app}/${target.name} serves ${serving?.digest ?? "no release"}, with no release before it to roll back to`);
|
|
287
|
+
const config = await recordedConfig(ctx, target.app, target.name, to);
|
|
288
|
+
if (tree) {
|
|
289
|
+
const differs = treeDifference(config, tree, intoOf(input.source));
|
|
290
|
+
if (differs) {
|
|
291
|
+
throw new Error(`fly-rollback: the Machine config recorded for ${to} does not carry the tree ${input.source.digest} (${differs}); refusing to restore it`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const restored = await restoreTo(ctx, input, target.app, target.name, to, config, serving?.digest);
|
|
295
|
+
log(serving?.digest === to
|
|
296
|
+
? `fly-rollback: ${target.app}/${target.name} already serves ${to}`
|
|
297
|
+
: `fly-rollback: ${target.app}/${target.name} serves ${to} again (was ${serving?.digest ?? "no release"})`);
|
|
298
|
+
return {
|
|
299
|
+
uri: uriOf(target.app, target.name),
|
|
300
|
+
digest: to,
|
|
301
|
+
...(restored?.gitSha ? { gitSha: restored.gitSha } : {}),
|
|
302
|
+
app: target.app,
|
|
303
|
+
machine: { id: live.id, name: target.name },
|
|
304
|
+
previous: serving ?? null,
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
async rollback(ctx, input, output) {
|
|
308
|
+
if (!output?.previous)
|
|
309
|
+
return;
|
|
310
|
+
await restoreTo(ctx, input, output.app, output.machine.name, output.previous.digest);
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* How a recorded Machine config's files under `into` differ from a source
|
|
316
|
+
* tree, or undefined when they are the same files with the same bytes. Pure.
|
|
317
|
+
*/
|
|
318
|
+
export function treeDifference(config, tree, into) {
|
|
319
|
+
const prefix = `${into}/`;
|
|
320
|
+
const recorded = new Map((config.files ?? []).filter((f) => f.guest_path.startsWith(prefix)).map((f) => [f.guest_path, f.raw_value]));
|
|
321
|
+
for (const f of tree) {
|
|
322
|
+
const bytes = recorded.get(f.guest_path);
|
|
323
|
+
if (bytes === undefined)
|
|
324
|
+
return `${f.guest_path} is not in it`;
|
|
325
|
+
if (bytes !== f.raw_value)
|
|
326
|
+
return `${f.guest_path} has other bytes`;
|
|
327
|
+
recorded.delete(f.guest_path);
|
|
328
|
+
}
|
|
329
|
+
const extra = [...recorded.keys()][0];
|
|
330
|
+
return extra ? `it also carries ${extra}` : undefined;
|
|
331
|
+
}
|
|
332
|
+
/** The default `fly-release` capability. */
|
|
333
|
+
export const flyReleaseCapability = createFlyReleaseCapability();
|
|
334
|
+
/** The default `fly-rollback` capability. */
|
|
335
|
+
export const flyRollbackCapability = createFlyRollbackCapability();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fly lexicon's component/release surface: the `run-agent` capability,
|
|
3
|
+
* its real `SpriteActivities` adapter, and the `flyCapabilityPlugin` core
|
|
4
|
+
* loads when a project declares `lexicons: ["fly"]` (#1942). Component
|
|
5
|
+
* authors reach these from `@intentius/chant-lexicon-fly/components`, the way
|
|
6
|
+
* AWS and helm verbs come from their lexicons' `/components` entries.
|
|
7
|
+
*/
|
|
8
|
+
export { flyCapabilityPlugin, FLY_VERB_FAMILIES } from "./capability-plugin.js";
|
|
9
|
+
export { flyRunAgentCapability, createFlyRunAgentCapability, createFlySpriteActivities, parseSpriteExecFailure, } from "./run-agent.js";
|
|
10
|
+
export { flyReleaseCapability, flyRollbackCapability, createFlyReleaseCapability, createFlyRollbackCapability, migrationReceipt, } from "./fly-release.js";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `run-agent`'s fly-lexicon adapter (#1942, epic #1564 phase 2) — the real
|
|
3
|
+
* `SpriteActivities` implementation
|
|
4
|
+
* (`@intentius/chant/components/verbs/run-agent`) over this lexicon's own
|
|
5
|
+
* sprite lifecycle activities (`../op/activities/sprites.ts` +
|
|
6
|
+
* `../op/activities/sprite-fs.ts`), and the `run-agent` capability built over
|
|
7
|
+
* it. Registered through this lexicon's own `CapabilityPlugin`
|
|
8
|
+
* (`./capability-plugin.ts`) the same way aws contributes `cfn-deploy` and
|
|
9
|
+
* helm contributes `helm-upgrade` (docs/components/cloud-boundary) — core's
|
|
10
|
+
* `createRunAgentCapability` (the sequencing logic: create-or-reuse,
|
|
11
|
+
* checkpoint, stage, exec, collect, destroy/leave-alive, restore-by-comment)
|
|
12
|
+
* stays cloud-agnostic in `@intentius/chant`, structurally independent of
|
|
13
|
+
* this package; this module is the one piece with the hard dependency on the
|
|
14
|
+
* real sprite wire protocol.
|
|
15
|
+
*
|
|
16
|
+
* **The exec-throw finding (pre-merge review of #1946, recorded on #1942).**
|
|
17
|
+
* The real `spriteExec` (`../op/activities/sprites.ts`) throws on any
|
|
18
|
+
* non-zero exit — by design, so a *scripted Op phase*
|
|
19
|
+
* (`examples/sprites-agent-task/ops/guarded-task.op.ts`) fails and triggers
|
|
20
|
+
* its `onFailure` compensation. But `run-agent`'s `RunAgentOutput.turn.status`
|
|
21
|
+
* includes `"failed"` as a first-class, non-throwing result — an ordinary
|
|
22
|
+
* failed agent turn (the model's diff didn't compile, its tests failed,
|
|
23
|
+
* whatever the exit code means) is not an infrastructure failure and should
|
|
24
|
+
* not by itself unwind the saga (checkpoint restore is for "the sprite
|
|
25
|
+
* backend broke," not "the agent's output wasn't good"). Resolved here as
|
|
26
|
+
* **option (a)** from the review comment: `exec()` below wraps the real
|
|
27
|
+
* `spriteExec` in a try/catch and reclassifies a thrown non-zero-exit error
|
|
28
|
+
* back into a resolved `{ stdout, stderr, exitCode }`
|
|
29
|
+
* (`parseSpriteExecFailure`), so `SpriteActivities.exec` always resolves for
|
|
30
|
+
* an ordinary command outcome — exactly the contract core's `run()`
|
|
31
|
+
* (`packages/core/src/components/verbs/run-agent.ts`) assumes and relies on
|
|
32
|
+
* (it deliberately has *no* try/catch of its own around `sprites.exec`). A
|
|
33
|
+
* genuine transport/infra failure — the WebSocket erroring, connection
|
|
34
|
+
* refused, an aborted signal — does not match the reclassification pattern
|
|
35
|
+
* and still rejects, so core's saga-unwind path is preserved for *real*
|
|
36
|
+
* failures.
|
|
37
|
+
*
|
|
38
|
+
* Option (b) — accept that ordinary failures throw, and drop `"failed"` from
|
|
39
|
+
* `turn.status`'s reachable states — was rejected: it would make every
|
|
40
|
+
* failed agent turn indistinguishable from a genuine sprite outage, forcing
|
|
41
|
+
* a checkpoint-restore rollback for what is often just an unsuccessful
|
|
42
|
+
* attempt, which is not what "the environment is the transaction" is meant
|
|
43
|
+
* to protect against, and it would leave `RunAgentOutput.turn.status:
|
|
44
|
+
* "failed"` a dead, unreachable branch of a type #1941 deliberately shipped
|
|
45
|
+
* as reachable.
|
|
46
|
+
*
|
|
47
|
+
* **Fidelity note.** The real `spriteExec`'s thrown message carries only
|
|
48
|
+
* `stderr || stdout` combined (see its own doc comment in `sprites.ts`), not
|
|
49
|
+
* both streams separately — the reclassified result therefore folds that
|
|
50
|
+
* combined text into `stderr` and leaves `stdout` empty on the failure path.
|
|
51
|
+
* This is a known, documented loss versus a real non-throwing exec; recovering
|
|
52
|
+
* full stream fidelity would mean reimplementing the WebSocket exec framing
|
|
53
|
+
* here rather than reusing `spriteExec`, which is out of this issue's scope
|
|
54
|
+
* (core's module doc calls adapting the existing activities "a thin wrapper,
|
|
55
|
+
* not a rewrite").
|
|
56
|
+
*/
|
|
57
|
+
import { createRunAgentCapability, } from "@intentius/chant/components/verbs/run-agent";
|
|
58
|
+
import { spriteCreate, spriteCheckpoint, spriteExec, spriteRestore, spriteDestroy } from "../op/activities/sprites.js";
|
|
59
|
+
import { spriteWriteFile, spriteReadFile } from "../op/activities/sprite-fs.js";
|
|
60
|
+
/**
|
|
61
|
+
* Parse the exit code + combined output text out of the real `spriteExec`'s
|
|
62
|
+
* thrown message shape: `sprite <id> exec "<cmd>" exited <code>: <text>`
|
|
63
|
+
* (see `../op/activities/sprites.ts`'s `spriteExec`). Returns `undefined` for
|
|
64
|
+
* any other error shape — a genuine transport/infra failure the caller
|
|
65
|
+
* should let propagate. Exported for direct unit testing of the
|
|
66
|
+
* reclassification the module doc above describes as option (a).
|
|
67
|
+
*
|
|
68
|
+
* The leading `[\s\S]*` is greedy on purpose: `spriteExec`'s message echoes
|
|
69
|
+
* the raw `cmd` verbatim before the real ` exited <code>: ` marker
|
|
70
|
+
* (`exec "<cmd>" exited ...`), so a crafted command whose text itself
|
|
71
|
+
* contains that exact substring must not be mistaken for the marker. A
|
|
72
|
+
* greedy prefix backtracks from the end of the string, so it always anchors
|
|
73
|
+
* to the *last* occurrence — the genuine marker `spriteExec` appended — never
|
|
74
|
+
* a leftmost false match inside the echoed `cmd`.
|
|
75
|
+
*/
|
|
76
|
+
export function parseSpriteExecFailure(err) {
|
|
77
|
+
if (!(err instanceof Error))
|
|
78
|
+
return undefined;
|
|
79
|
+
const m = err.message.match(/^[\s\S]* exited (\d+): ([\s\S]*)$/);
|
|
80
|
+
if (!m)
|
|
81
|
+
return undefined;
|
|
82
|
+
const exitCode = Number(m[1]);
|
|
83
|
+
if (!Number.isFinite(exitCode))
|
|
84
|
+
return undefined;
|
|
85
|
+
return { exitCode, output: m[2] };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The real `SpriteActivities` implementation: a thin wrapper over this
|
|
89
|
+
* lexicon's own sprite lifecycle + filesystem activities, matching
|
|
90
|
+
* `@intentius/chant/components/verbs/run-agent`'s injectable seam exactly
|
|
91
|
+
* (see that module's doc comment — "adapting them is a thin wrapper, not a
|
|
92
|
+
* rewrite"). `exec()` is the one method that is not a bare pass-through — see
|
|
93
|
+
* this module's doc comment for why.
|
|
94
|
+
*/
|
|
95
|
+
export function createFlySpriteActivities() {
|
|
96
|
+
return {
|
|
97
|
+
async create(args, signal) {
|
|
98
|
+
return spriteCreate({ name: args.name, image: args.image }, signal);
|
|
99
|
+
},
|
|
100
|
+
async checkpoint(args, signal) {
|
|
101
|
+
return spriteCheckpoint({ id: args.id, comment: args.comment }, signal);
|
|
102
|
+
},
|
|
103
|
+
async exec(args, signal) {
|
|
104
|
+
try {
|
|
105
|
+
return await spriteExec({ id: args.id, cmd: args.cmd, timeoutMs: args.timeoutMs }, signal);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const parsed = parseSpriteExecFailure(err);
|
|
109
|
+
if (!parsed)
|
|
110
|
+
throw err; // a genuine transport/infra failure — propagate it.
|
|
111
|
+
return { stdout: "", stderr: parsed.output, exitCode: parsed.exitCode };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async restore(args, signal) {
|
|
115
|
+
await spriteRestore({ id: args.id, checkpoint: args.checkpoint, comment: args.comment }, signal);
|
|
116
|
+
},
|
|
117
|
+
async destroy(args, signal) {
|
|
118
|
+
await spriteDestroy({ id: args.id }, signal);
|
|
119
|
+
},
|
|
120
|
+
async writeFile(args, signal) {
|
|
121
|
+
await spriteWriteFile({ id: args.id, path: args.path, content: args.content, mkdir: args.mkdir }, signal);
|
|
122
|
+
},
|
|
123
|
+
async readFile(args, signal) {
|
|
124
|
+
return spriteReadFile({ id: args.id, path: args.path }, signal);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Build the `run-agent` capability over the real fly sprite backend. */
|
|
129
|
+
export function createFlyRunAgentCapability() {
|
|
130
|
+
return createRunAgentCapability(createFlySpriteActivities());
|
|
131
|
+
}
|
|
132
|
+
/** Default `run-agent` capability, backed by the real `SpriteActivities` adapter above — what `flyCapabilityPlugin` (./capability-plugin.ts) registers. */
|
|
133
|
+
export const flyRunAgentCapability = createFlyRunAgentCapability();
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export const compositeCatalog = [
|
|
2
|
+
{
|
|
3
|
+
"name": "FlyOtelCollector",
|
|
4
|
+
"lexicon": "fly",
|
|
5
|
+
"description": "Create a FlyOtelCollector composite.",
|
|
6
|
+
"bundles": [
|
|
7
|
+
"Machine"
|
|
8
|
+
],
|
|
9
|
+
"params": [
|
|
10
|
+
{
|
|
11
|
+
"name": "name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"required": false,
|
|
14
|
+
"description": "Machine name (default: \"otel-collector\")."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "app",
|
|
18
|
+
"type": "string | Declarable",
|
|
19
|
+
"required": false,
|
|
20
|
+
"description": "The app the machine belongs to, as an app name."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "region",
|
|
24
|
+
"type": "string | Declarable",
|
|
25
|
+
"required": false,
|
|
26
|
+
"description": "Region (default: `Fly.Region`, which the build resolves from `FLY_REGION`)."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "image",
|
|
30
|
+
"type": "string",
|
|
31
|
+
"required": false,
|
|
32
|
+
"description": "Collector image (default: the contrib image at the otel lexicon's pinned collector version)."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "config",
|
|
36
|
+
"type": "Iterable<Declarable>",
|
|
37
|
+
"required": false,
|
|
38
|
+
"description": "The collector config, as otel lexicon entities (components and pipelines)."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "exporters",
|
|
42
|
+
"type": "OTelComponent<\"exporter\", string, any>[]",
|
|
43
|
+
"required": false,
|
|
44
|
+
"description": "Exporters for the default config (default: one `debug` exporter)."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "signals",
|
|
48
|
+
"type": "Signal[]",
|
|
49
|
+
"required": false,
|
|
50
|
+
"description": "Signals the default config has pipelines for (default: traces, metrics and logs)."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "cpuKind",
|
|
54
|
+
"type": "string",
|
|
55
|
+
"required": false,
|
|
56
|
+
"description": "Guest CPU kind (default: \"shared\")."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "cpus",
|
|
60
|
+
"type": "number",
|
|
61
|
+
"required": false,
|
|
62
|
+
"description": "Guest CPUs (default: 1)."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "memoryMb",
|
|
66
|
+
"type": "number",
|
|
67
|
+
"required": false,
|
|
68
|
+
"description": "Guest memory in MB (default: 512)."
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"name": "defaults",
|
|
72
|
+
"type": "{ machine?: Partial<Record<string, unknown>>; }",
|
|
73
|
+
"required": false,
|
|
74
|
+
"description": "Per-member defaults for fine-grained overrides."
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "FlySite",
|
|
80
|
+
"lexicon": "fly",
|
|
81
|
+
"description": "Create a FlySite composite.",
|
|
82
|
+
"bundles": [
|
|
83
|
+
"App",
|
|
84
|
+
"IPAddress",
|
|
85
|
+
"Machine",
|
|
86
|
+
"Secret",
|
|
87
|
+
"Volume"
|
|
88
|
+
],
|
|
89
|
+
"params": [
|
|
90
|
+
{
|
|
91
|
+
"name": "app",
|
|
92
|
+
"type": "string",
|
|
93
|
+
"required": true,
|
|
94
|
+
"description": "The App's name."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "org",
|
|
98
|
+
"type": "string | Declarable",
|
|
99
|
+
"required": false,
|
|
100
|
+
"description": "The owning org (default: `Fly.OrgSlug`, which the build resolves from `FLY_ORG`)."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "region",
|
|
104
|
+
"type": "string | Declarable",
|
|
105
|
+
"required": false,
|
|
106
|
+
"description": "Region of the Machine and the Volume (default: `Fly.Region`, from `FLY_REGION`)."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "machine",
|
|
110
|
+
"type": "string",
|
|
111
|
+
"required": false,
|
|
112
|
+
"description": "The Machine's name (default: \"web\")."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "image",
|
|
116
|
+
"type": "string",
|
|
117
|
+
"required": true,
|
|
118
|
+
"description": "The image the Machine runs until a release replaces it."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "port",
|
|
122
|
+
"type": "number",
|
|
123
|
+
"required": false,
|
|
124
|
+
"description": "The port the app listens on inside the Machine (default: 8080)."
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "env",
|
|
128
|
+
"type": "Record<string, string>",
|
|
129
|
+
"required": false,
|
|
130
|
+
"description": "The Machine's env."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"name": "cpuKind",
|
|
134
|
+
"type": "string",
|
|
135
|
+
"required": false,
|
|
136
|
+
"description": "Guest CPU kind (default: \"shared\")."
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "cpus",
|
|
140
|
+
"type": "number",
|
|
141
|
+
"required": false,
|
|
142
|
+
"description": "Guest CPUs (default: 1)."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "memoryMb",
|
|
146
|
+
"type": "number",
|
|
147
|
+
"required": false,
|
|
148
|
+
"description": "Guest memory in MB (default: 256)."
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "volume",
|
|
152
|
+
"type": "{ name: string; sizeGb: number; path: string }",
|
|
153
|
+
"required": false,
|
|
154
|
+
"description": "A Volume for the app's data, mounted at `path`."
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "ip",
|
|
158
|
+
"type": "\"shared_v4\" | \"v4\" | \"v6\"",
|
|
159
|
+
"required": false,
|
|
160
|
+
"description": "A public IP of this type (for example \"shared_v4\")."
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"name": "secrets",
|
|
164
|
+
"type": "Record<string, string | undefined>",
|
|
165
|
+
"required": false,
|
|
166
|
+
"description": "The app's Secrets, by name."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"name": "defaults",
|
|
170
|
+
"type": "{ app?: Partial<Record<string, unknown>>; machine?: Partial<Record<string, unknown>>; volume?: Partial<Record<string,...",
|
|
171
|
+
"required": false,
|
|
172
|
+
"description": "Per-member defaults for fine-grained overrides."
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
];
|