@intentius/chant-lexicon-fly 0.124.31 → 0.124.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `flyRelease`: the `fly-release` capability (../../components/fly-release.ts)
3
+ * as an Op activity (#2782), so an Op's ship phase runs the same site steps a
4
+ * component's deploy does: upload and start, each migration once per
5
+ * environment under a receipt in chant's lifecycle receipt store, verify, and
6
+ * restore on a failure after the Machine changed.
7
+ *
8
+ * An Op has no environment of its own, so the step names the one it ships to
9
+ * (`environment`); the receipts and the recorded Machine configs are that
10
+ * environment's, as they are for the component.
11
+ *
12
+ * Retrying a release that already serves changes nothing: the apply finds the
13
+ * Machine's config unchanged, and every migration's receipt matches.
14
+ */
15
+ export async function flyRelease(args) {
16
+ const { environment, component, ...input } = args;
17
+ if (!environment)
18
+ throw new Error("flyRelease: name the environment the release ships to (environment)");
19
+ const { flyReleaseCapability } = await import("../../components/fly-release.js");
20
+ return flyReleaseCapability.run({ env: environment, component: component ?? "app" }, input);
21
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `flyRollback`: the `fly-rollback` capability (../../components/fly-release.ts)
3
+ * as an Op activity (#2800), so a rollback Op puts an earlier release back on
4
+ * the Machine the way the release Op's `flyRelease` put it there.
5
+ *
6
+ * It restores the Machine config `fly-release` recorded for the release it
7
+ * goes back to (files included), in the environment `environment` names, and
8
+ * checks that the Machine is started with that release. Given `source`, the
9
+ * archive is read only once it hashes to its digest, before any flaps call,
10
+ * and the recorded config must carry exactly that tree, or nothing changes.
11
+ * A release with no recorded config is refused by name.
12
+ *
13
+ * Running it again once the Machine serves the release changes nothing.
14
+ * Migrations are not undone: the data stays where the later release left it.
15
+ */
16
+ export async function flyRollback(args) {
17
+ const { environment, component, ...input } = args;
18
+ if (!environment)
19
+ throw new Error("flyRollback: name the environment whose release it restores (environment)");
20
+ const { flyRollbackCapability } = await import("../../components/fly-release.js");
21
+ return flyRollbackCapability.run({ env: environment, component: component ?? "app" }, input);
22
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * fly Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `fly` lexicon. Contributes the native flaps
4
+ * applier (`flyApply`), which speaks the Fly Machines REST API directly (no
5
+ * flyctl, no state file), and the mudflaps emulator lifecycle it is tested
6
+ * against.
7
+ */
8
+ export { flyApply, toApplyResult, flyDelete, applyApp, applyMachine, destroyMachine, deleteApp, listMachines, listVolumes, listIps, listCerts, listSecrets, checkSecretExists, pruneMachines, waitForMachine, acquireLease, releaseLease, withLease, resolveEndpoint, parsePlan, isAppRequest, isMachineRequest, isVolumeRequest, isIpRequest, isCertRequest, isSecretRequest, isValuelessSecret, machineAppSegment, resourceAppSegment, secretNameSegment, ipType, declaredIpType, resolveApp, appNameFromRequest, isChantOwned, configEqual, isLeaseConflict, defaultFlyHttp, DEFAULT_FLAPS_BASE_URL, LEASE_NONCE_HEADER, } from "./fly-apply.js";
9
+ // The release a Machine serves and the site steps over the Machines API
10
+ // (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
11
+ // restore. `loadActivities(["fly"])` binds these by name; the `fly-release`
12
+ // and `fly-rollback` capabilities compose them.
13
+ export { flyMachineRelease, flyMachineExec, flyMachineRestart, flyMachineStop, flyMachineVerify, flyMachineRestore, } from "./machine-release.js";
14
+ // The fly-release capability as an Op step (#2782): a release Op's ship phase.
15
+ export { flyRelease } from "./fly-release-step.js";
16
+ // The fly-rollback capability as an Op step (#2800): a rollback Op's restore.
17
+ export { flyRollback } from "./fly-rollback-step.js";
18
+ // mudflaps (Fly Machines API emulator) lifecycle — boots/tears down the local
19
+ // flaps target flyApply is exercised against.
20
+ export { flapsUp, flapsDown, flapsRunCommand, flapsRmCommand, flapsExistsCommand, flapsHealthUrl, flapsEndpoint, } from "./flaps.js";
21
+ // Sprites (sprites.dev) — the other Fly product: imperative, checkpointable
22
+ // sandbox activities. `loadActivities(["fly"])` provides these; the fake lives
23
+ // in `sprites-fake.ts` and is imported only by tests (not an activity). Unlike
24
+ // Machines, Sprites have no desired state to reconcile — they are runtime
25
+ // primitives driven inside an Op, with checkpoint-as-compensation as the
26
+ // headline capability.
27
+ export { spriteCreate, spriteExec, spriteCheckpoint, spriteRestore, listCheckpoints, spriteDestroy, spriteDelete, spriteUrl, resolveSpritesEndpoint, resolveSpritesToken, defaultSpritesHttp, spriteCreateBody, parseCreateResponse, accumulateExecFrames, parseCheckpointNdjson, pickCheckpointByComment, splitCommand, spriteExecWsUrl, DEFAULT_SPRITES_BASE_URL, } from "./sprites.js";
28
+ // Sprite Services activities (#2711) — imperative create/get/list/start/stop/
29
+ // delete/logs for one background service at a time, the single-service
30
+ // primitives underneath `spriteApplyServices`'s batch reconcile.
31
+ // `loadActivities(["fly"])` binds these; the step builders live in core.
32
+ export { spriteServiceCreate, spriteServiceGet, spriteServiceList, spriteServiceStart, spriteServiceStop, spriteServiceDelete, spriteServiceLogs, spriteServiceCreateBody, parseServiceLogNdjson, } from "./sprite-services.js";
33
+ // A sprite's services as resources a ConvergeOp observes and converges (#2778):
34
+ // the observer step and the restart a converge rule dispatches.
35
+ export { spriteServicesObserve, spriteServiceRestart, declaredServices, resolveDeclaredServices, findSpriteEnv, parseServicesList, probeHealth, } from "./sprite-service-converge.js";
36
+ // Sprite filesystem activities (#848) — imperative file I/O over the fs API.
37
+ // `loadActivities(["fly"])` binds these; the step builders live in core.
38
+ export { spriteWriteFile, spriteReadFile, spriteListDir, spriteRemove, spriteFsUrl, defaultSpritesRawHttp, } from "./sprite-fs.js";
39
+ // Sprite config reconcile activities (#849) — the desired-state a Sprite carries:
40
+ // its outbound network policy and its background services. `loadActivities(["fly"])`
41
+ // binds these; the step builders live in core. Apply-activities, not a declarable
42
+ // resource — a Sprite has no build→plan→apply pipeline.
43
+ export { spriteApplyNetworkPolicy, spriteApplyServices, validateNetworkRules, networkRulesEqual, validateServices, serviceConfigEqual, parseServiceDefinitions, listedServiceDiffers, spriteEnvCreateArgs, } from "./sprite-config.js";
44
+ // A box's declared services (#2880), read from its box block with `box: true`.
45
+ export { boxServices, expandServiceCommand, inStartOrder, serviceCommandArgv } from "./box-services.js";
46
+ // Sprite keep-alive Tasks activities (#847) — a hold that stops a Sprite pausing
47
+ // mid-session. `loadActivities(["fly"])` binds these; step builders live in core.
48
+ export { spriteTaskCreate, spriteTaskRefresh, spriteTaskRelease, spriteTasksUrl, spriteTaskUrl, } from "./sprite-tasks.js";
49
+ // spritzer (the Sprites API emulator) Docker lifecycle — the twin of mudflaps
50
+ // above. `spritesUp`/`spritesDown` resolve by name so an Op can boot/tear down
51
+ // the emulator as a modeled step; the sprite activities target it via
52
+ // SPRITES_BASE_URL.
53
+ export { spritesUp, spritesDown, spritesRunCommand, spritesRmCommand, spritesExistsCommand, spritesHealthUrl, spritesEndpoint, } from "./sprites-emulator.js";
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The release a Fly Machine serves, and the Machines activities that put one
3
+ * there (#2736, ws-056).
4
+ *
5
+ * chud's `Chud::FlySite` ran a site as one App served by one Machine and kept
6
+ * the release in the Machine's metadata. That moves here: the App and the
7
+ * Machine are this lexicon's own resources, and a release is the declared
8
+ * Machine with {@link RELEASE_METADATA_KEYS} stamped into its
9
+ * `config.metadata`. Because the record is on the Machine itself, any checkout
10
+ * can read which release is serving (`describeResources` reports it, and
11
+ * `chant components status --live` compares it with the release ledger).
12
+ *
13
+ * The site steps are activities over the Machines API, each usable from an
14
+ * Op by name and composed by the `fly-release` and `fly-rollback` capabilities
15
+ * (../../components/fly-release.ts):
16
+ *
17
+ * - {@link flyMachineRelease}: upload and start. Apply the declared plan with
18
+ * the Machine serving the release (its image or env overridden when the
19
+ * release says so), and report the release and config it replaced.
20
+ * - {@link flyMachineExec}: run a command inside the Machine (a migration).
21
+ * - {@link flyMachineRestart}, {@link flyMachineStop}: under a lease, then
22
+ * wait for the Machine to settle.
23
+ * - {@link flyMachineVerify}: the Machine is started with this release in its
24
+ * metadata, and, given a URL, its health endpoint answers.
25
+ * - {@link flyMachineRestore}: put a recorded Machine config back (restore on
26
+ * a failed release, and rollback).
27
+ *
28
+ * Every activity takes the flaps endpoint and token the way `flyApply` does
29
+ * (`FLY_FLAPS_BASE_URL`, `FLY_API_TOKEN`), so the same code reaches mudflaps
30
+ * or a real Fly org, and an injectable `http` for tests (./machines-fake.ts).
31
+ */
32
+ import { appNameFromRequest, defaultFlyHttp, flyApply, isAppRequest, isMachineRequest, listMachines, machineAppSegment, resolveApp, resolveEndpoint, waitForMachine, withLease, LEASE_NONCE_HEADER, } from "./fly-apply.js";
33
+ import { parsePlan } from "./fly-apply.js";
34
+ import { readFileSync } from "node:fs";
35
+ import { readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
36
+ export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "../../release-metadata.js";
37
+ /**
38
+ * The Machine a release targets: the plan's one Machine, or the one `machine`
39
+ * names (an entity name or a Machine name). Pure.
40
+ */
41
+ export function releaseTarget(plan, machine) {
42
+ const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
43
+ const soleApp = appNames.length === 1 ? appNames[0] : undefined;
44
+ const machines = Object.entries(plan)
45
+ .filter(([, req]) => isMachineRequest(req))
46
+ .map(([entity, request]) => ({
47
+ entity,
48
+ request,
49
+ name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
50
+ }));
51
+ const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
52
+ if (picked.length !== 1) {
53
+ throw new Error(machine
54
+ ? `the fly plan declares no Machine named "${machine}" (declared: ${machines.map((m) => m.entity).join(", ") || "none"})`
55
+ : `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`);
56
+ }
57
+ const [m] = picked;
58
+ return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
59
+ }
60
+ const TERMINAL = new Set(["destroyed", "destroying"]);
61
+ /** The live Machine named `name` (or with id `name`) in `app`; undefined when there is none. */
62
+ export async function findMachine(ctx, app, name, http, signal) {
63
+ const live = (await listMachines(ctx, app, http, signal)).filter((m) => !TERMINAL.has(m.state));
64
+ return live.find((m) => m.name === name) ?? live.find((m) => m.id === name);
65
+ }
66
+ async function mustFindMachine(ctx, app, name, http, signal) {
67
+ const m = await findMachine(ctx, app, name, http, signal);
68
+ if (!m)
69
+ throw new Error(`no machine ${name} on app ${app}`);
70
+ return m;
71
+ }
72
+ const machinePath = (ctx, app, id) => `${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
73
+ /**
74
+ * Upload and start: apply the declared plan (the App, its Volumes, IPs,
75
+ * Secrets) with the Machine serving `release`. The Machine is updated in place
76
+ * under a lease, so the release serves from here on. A Machine that already
77
+ * serves this release keeps the `previousDigest` it shipped with, so running a
78
+ * release twice never makes it its own predecessor.
79
+ */
80
+ export async function flyMachineRelease(args, signal, http = defaultFlyHttp(args.token)) {
81
+ const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
82
+ const target = releaseTarget(plan, args.machine);
83
+ const ctx = { base: resolveEndpoint(args) };
84
+ const live = await findMachine(ctx, target.app, target.name, http, signal).catch(() => undefined);
85
+ const liveConfig = live?.config;
86
+ const liveRelease = readMachineRelease(liveConfig?.metadata);
87
+ const previousDigest = liveRelease && liveRelease.digest !== args.release.digest ? liveRelease.digest : liveRelease?.previousDigest ?? args.release.previousDigest;
88
+ const release = { ...args.release, ...(previousDigest ? { previousDigest } : {}) };
89
+ if (!previousDigest)
90
+ delete release.previousDigest;
91
+ const declared = (target.request.body.config ?? {});
92
+ const declaredFiles = declared.files ?? [];
93
+ const releasePaths = new Set((args.files ?? []).map((f) => f.guest_path));
94
+ const cmd = typeof args.cmd === "string" ? ["sh", "-c", args.cmd] : args.cmd;
95
+ const config = withReleaseMetadata({
96
+ ...declared,
97
+ ...(args.image ? { image: args.image } : {}),
98
+ ...(args.env ? { env: { ...(declared.env ?? {}), ...args.env } } : {}),
99
+ ...(args.files ? { files: [...declaredFiles.filter((f) => !releasePaths.has(f.guest_path)), ...args.files] } : {}),
100
+ ...(cmd ? { init: { ...(declared.init ?? {}), cmd } } : {}),
101
+ }, release);
102
+ const request = { ...target.request, body: { ...target.request.body, config } };
103
+ const applied = await flyApply({ plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait }, signal, http);
104
+ const action = applied.machines.find((m) => m.app === target.app && m.name === target.name)?.action ?? "noop";
105
+ const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
106
+ return {
107
+ app: target.app,
108
+ machine: { id: now.id, name: target.name },
109
+ action,
110
+ release,
111
+ config,
112
+ previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
113
+ };
114
+ }
115
+ function requirePlanPath(planPath) {
116
+ if (!planPath)
117
+ throw new Error("pass planPath (the fly build output) or plan");
118
+ return planPath;
119
+ }
120
+ /**
121
+ * Run a command inside the Machine (the Machines API's exec). A non-zero exit
122
+ * throws, with the command's output, so the step fails rather than going on
123
+ * over a half-migrated database.
124
+ */
125
+ export async function flyMachineExec(args, signal, http = defaultFlyHttp(args.token)) {
126
+ const ctx = { base: resolveEndpoint(args) };
127
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
128
+ const command = typeof args.command === "string" ? ["sh", "-c", args.command] : args.command;
129
+ const res = await http("POST", `${machinePath(ctx, args.app, m.id)}/exec`, { command, timeout: args.timeoutSecs ?? 120 }, undefined, signal);
130
+ if (res.status >= 300)
131
+ throw new Error(`exec on ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
132
+ let out = {};
133
+ try {
134
+ out = JSON.parse(res.text);
135
+ }
136
+ catch {
137
+ throw new Error(`exec on ${args.app}/${m.id} answered no exec result: ${res.text}`);
138
+ }
139
+ const result = { exitCode: out.exit_code ?? 0, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
140
+ if (result.exitCode !== 0) {
141
+ const said = [result.stderr, result.stdout].filter(Boolean).join("\n").trim().slice(-2000);
142
+ throw new Error(`${command.join(" ")} exited ${result.exitCode} on ${args.app}/${m.id}${said ? `: ${said}` : ""}`);
143
+ }
144
+ return result;
145
+ }
146
+ async function transition(args, verb, signal, http) {
147
+ const ctx = { base: resolveEndpoint(args) };
148
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
149
+ const state = verb === "stop" ? "stopped" : "started";
150
+ if (verb === "stop" && m.state !== "started")
151
+ return { id: m.id, state: m.state };
152
+ const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", `${machinePath(ctx, args.app, m.id)}/${verb}`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal));
153
+ if (res.status >= 300)
154
+ throw new Error(`${verb} of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
155
+ await waitForMachine(ctx, args.app, m.id, "", http, signal, { ...(args.wait ?? {}), state });
156
+ return { id: m.id, state };
157
+ }
158
+ /** Restart the Machine under a lease (the app starts again on migrated data), and wait for it to be started. */
159
+ export async function flyMachineRestart(args, signal, http = defaultFlyHttp(args.token)) {
160
+ return transition(args, "restart", signal, http);
161
+ }
162
+ /** Stop the Machine under a lease, and wait for it to be stopped. A Machine that is not started is left alone. */
163
+ export async function flyMachineStop(args, signal, http = defaultFlyHttp(args.token)) {
164
+ return transition(args, "stop", signal, http);
165
+ }
166
+ /**
167
+ * The Machine is started and its metadata names release `digest`. Given a
168
+ * `url`, its health endpoint answers 2xx too, and when that answer is JSON
169
+ * naming a `revision` or `digest`, it is this release's (the commit or the
170
+ * digest). Retries until `timeoutMs`, then throws the last reason.
171
+ */
172
+ export async function flyMachineVerify(args, signal, http = defaultFlyHttp(args.token), fetchImpl = fetch) {
173
+ const ctx = { base: resolveEndpoint(args) };
174
+ const deadline = Date.now() + (args.timeoutMs ?? 120000);
175
+ const interval = args.intervalMs ?? 1000;
176
+ let why = "";
177
+ for (;;) {
178
+ if (signal?.aborted)
179
+ throw new Error("flyMachineVerify aborted");
180
+ try {
181
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
182
+ if (m.state !== "started")
183
+ throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
184
+ const release = readMachineRelease(m.config?.metadata);
185
+ if (release?.digest !== args.digest) {
186
+ throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
187
+ }
188
+ if (args.url) {
189
+ const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
190
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(5000) });
191
+ if (!res.ok)
192
+ throw new Error(`${url} answered ${res.status}`);
193
+ let body;
194
+ try {
195
+ body = (await res.json());
196
+ }
197
+ catch {
198
+ body = undefined;
199
+ }
200
+ const reported = typeof body?.revision === "string" ? body.revision : typeof body?.digest === "string" ? body.digest : undefined;
201
+ if (reported !== undefined && reported !== release.gitSha && reported !== release.digest) {
202
+ throw new Error(`${url} reports ${reported}, expected ${release.gitSha ?? release.digest}`);
203
+ }
204
+ }
205
+ return { id: m.id, release };
206
+ }
207
+ catch (err) {
208
+ why = err instanceof Error ? err.message : String(err);
209
+ }
210
+ if (Date.now() + interval > deadline)
211
+ throw new Error(`verify ${args.app}/${args.machine}: ${why}`);
212
+ await new Promise((r) => setTimeout(r, interval));
213
+ }
214
+ }
215
+ /**
216
+ * Put a recorded Machine config back: update the Machine to exactly `config`
217
+ * under a lease and wait for it to be started. Used on a failed release (the
218
+ * config it replaced) and by a rollback (the config an earlier release applied).
219
+ */
220
+ export async function flyMachineRestore(args, signal, http = defaultFlyHttp(args.token)) {
221
+ const ctx = { base: resolveEndpoint(args) };
222
+ const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
223
+ const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) => http("POST", machinePath(ctx, args.app, m.id), { name: m.name, config: args.config }, { [LEASE_NONCE_HEADER]: nonce }, signal));
224
+ if (res.status >= 300)
225
+ throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
226
+ let instance = "";
227
+ try {
228
+ instance = JSON.parse(res.text).instance_id ?? "";
229
+ }
230
+ catch {
231
+ instance = "";
232
+ }
233
+ await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
234
+ const release = readMachineRelease(args.config.metadata);
235
+ return { id: m.id, ...(release ? { release } : {}) };
236
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Fly Machines (flaps) API contract — the endpoint set the `flyApply` applier
3
+ * (./fly-apply.ts) depends on, and the mudflaps counterpart of the hand-authored
4
+ * Sprites contract (./sprites-contract.ts, #808 T3).
5
+ *
6
+ * Unlike Sprites, Machines *does* ship a machine-readable OpenAPI that the fly
7
+ * resource surface drift-checks against (docs.machines.dev, the rolling-upgrade
8
+ * path #813). This contract serves a different fidelity axis: it pins the exact
9
+ * endpoints flyApply calls so the docker-gated coverage test can prove the pinned
10
+ * mudflaps emulator serves them all. mudflaps carries roadmap endpoints that
11
+ * answer 501 (currently machines/{id}/signal, /exec, /ps) — flyApply must never
12
+ * depend on one; if it ever does, the coverage test fails instead of the applier
13
+ * silently passing against a fake that can't model the call.
14
+ *
15
+ * Param names match ./fly-apply.ts's URL builders (`{app}`, `{id}`); the coverage
16
+ * check normalizes param names before comparing, since mudflaps spells volume
17
+ * ids `{vol}` and cert hostnames `{hostname}`.
18
+ */
19
+ /**
20
+ * The flaps endpoints ./fly-apply.ts depends on. Keep in sync with the applier —
21
+ * the unit test asserts every path segment appears in fly-apply.ts.
22
+ */
23
+ export const MACHINES_CONTRACT = [
24
+ // Apps
25
+ { method: "POST", path: "/v1/apps", op: "createApp" },
26
+ { method: "GET", path: "/v1/apps/{app}", op: "getApp" },
27
+ { method: "DELETE", path: "/v1/apps/{app}", op: "deleteApp" },
28
+ // Machines
29
+ { method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
30
+ { method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
31
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
32
+ // A running machine is stopped before the destroy (#3115); `?force=true` is a
33
+ // query, so it stays out of the path template.
34
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "stopForDestroy" },
35
+ { method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
36
+ { method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
37
+ // Leases
38
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "acquireLease" },
39
+ { method: "DELETE", path: "/v1/apps/{app}/machines/{id}/lease", op: "releaseLease" },
40
+ // Volumes
41
+ { method: "GET", path: "/v1/apps/{app}/volumes", op: "listVolumes" },
42
+ { method: "POST", path: "/v1/apps/{app}/volumes", op: "createVolume" },
43
+ { method: "DELETE", path: "/v1/apps/{app}/volumes/{id}", op: "deleteVolume" },
44
+ // IP assignments
45
+ { method: "GET", path: "/v1/apps/{app}/ip_assignments", op: "listIps" },
46
+ { method: "POST", path: "/v1/apps/{app}/ip_assignments", op: "allocateIp" },
47
+ { method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
48
+ // Certificates
49
+ { method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
50
+ // Created at /acme: flaps has no POST on the list path (#3114).
51
+ { method: "POST", path: "/v1/apps/{app}/certificates/acme", op: "addCert" },
52
+ { method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
53
+ // Secrets
54
+ { method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },
55
+ { method: "POST", path: "/v1/apps/{app}/secrets/{name}", op: "setSecret" },
56
+ { method: "DELETE", path: "/v1/apps/{app}/secrets/{name}", op: "deleteSecret" },
57
+ ];
58
+ /** Normalize a `METHOD path` key: collapse every `{param}` to `{}` so param names match. */
59
+ export function normalizeEndpoint(method, path) {
60
+ return `${method.toUpperCase()} ${path.replace(/\{[^}]+\}/g, "{}")}`;
61
+ }
62
+ /** The contract as a set of normalized `METHOD path` keys. */
63
+ export function contractKeys() {
64
+ return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
65
+ }
66
+ /**
67
+ * The flaps endpoints the release activities (./machine-release.ts, #2736)
68
+ * add beyond the applier's: getting one Machine back, exec (a migration),
69
+ * restart and stop. The applier's own contract above stays exec-free; these
70
+ * are the site steps', and the docker-gated coverage test holds the pinned
71
+ * mudflaps to them the same way.
72
+ */
73
+ export const MACHINE_RELEASE_CONTRACT = [
74
+ { method: "GET", path: "/v1/apps/{app}/machines", op: "findMachine" },
75
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "flyMachineRestore" },
76
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/exec", op: "flyMachineExec" },
77
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/restart", op: "flyMachineRestart" },
78
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "flyMachineStop" },
79
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "withLease" },
80
+ { method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
81
+ ];
@@ -0,0 +1,203 @@
1
+ /**
2
+ * An in-memory Fly Machines (flaps) API, as a {@link FlyHttp} (#2736).
3
+ *
4
+ * The twin of ./sprites-fake.ts for the Machines side: the endpoints the
5
+ * applier (./fly-apply.ts) and the release activities (./machine-release.ts)
6
+ * call, answered from memory with the response shapes flaps and mudflaps use.
7
+ * Unit tests inject it where the default client would reach the network; the
8
+ * docker-gated tests run the same activities against the pinned mudflaps.
9
+ * It runs nothing; ./machines-local.ts is the opt-in mode that runs each
10
+ * started Machine's files and command on this host (#2831).
11
+ *
12
+ * Not an activity: nothing in ./index.ts exports it, so `loadActivities`
13
+ * never binds it.
14
+ */
15
+ const json = (status, body) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
16
+ /** Fly's certificate-list page size: 25 when no `limit` is given, 500 at most. */
17
+ export const CERT_PAGE_DEFAULT = 25;
18
+ export const CERT_PAGE_MAX = 500;
19
+ // The fake's cursor is an offset in disguise; callers must treat it as opaque.
20
+ const offsetCursor = (offset) => Buffer.from(`certs:${offset}`).toString("base64url");
21
+ const cursorOffset = (cursor) => {
22
+ const m = /^certs:(\d+)$/.exec(Buffer.from(cursor, "base64url").toString());
23
+ return m ? Number(m[1]) : undefined;
24
+ };
25
+ let seq = 0;
26
+ const nextId = (prefix) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
27
+ /**
28
+ * A fresh in-memory flaps. Like the real API, a bare DELETE on a machine that
29
+ * is not stopped, suspended, failed or created answers 412; `?force=true`
30
+ * destroys it in any state.
31
+ */
32
+ export function createMachinesFake(options = {}) {
33
+ const apps = new Set();
34
+ const machines = new Map();
35
+ const certs = new Map();
36
+ const execs = [];
37
+ const calls = [];
38
+ const settle = options.settle ?? (() => "started");
39
+ const list = (app) => machines.get(app) ?? [];
40
+ const find = (app, id) => list(app).find((m) => m.id === id);
41
+ const http = async (method, url, body) => {
42
+ const { pathname, searchParams } = new URL(url);
43
+ calls.push(`${method} ${pathname}`);
44
+ const seg = pathname.split("/").filter(Boolean).map(decodeURIComponent);
45
+ // seg: ["v1", "apps", app?, kind?, id?, action?]
46
+ if (seg[0] !== "v1" || seg[1] !== "apps")
47
+ return json(404, { error: "not found" });
48
+ const app = seg[2];
49
+ const b = (body ?? {});
50
+ if (!app) {
51
+ if (method === "POST") {
52
+ const name = String(b.app_name);
53
+ if (apps.has(name))
54
+ return json(409, { error: "app exists" });
55
+ apps.add(name);
56
+ return json(201, { name, status: "deployed" });
57
+ }
58
+ return json(200, [...apps].map((name) => ({ name })));
59
+ }
60
+ if (seg.length === 3) {
61
+ if (method === "GET")
62
+ return apps.has(app) ? json(200, { name: app, status: "deployed" }) : json(404, { error: "app not found" });
63
+ if (method === "DELETE") {
64
+ apps.delete(app);
65
+ machines.delete(app);
66
+ certs.delete(app);
67
+ return json(202, undefined);
68
+ }
69
+ }
70
+ if (!apps.has(app))
71
+ return json(404, { error: "app not found" });
72
+ const kind = seg[3];
73
+ if (kind === "machines") {
74
+ const id = seg[4];
75
+ const action = seg[5];
76
+ if (!id) {
77
+ if (method === "GET")
78
+ return json(200, list(app));
79
+ if (method === "POST") {
80
+ const m = {
81
+ id: nextId("m"),
82
+ name: String(b.name ?? ""),
83
+ state: "created",
84
+ instance_id: nextId("I"),
85
+ config: structuredClone((b.config ?? {})),
86
+ };
87
+ m.state = settle(m);
88
+ machines.set(app, [...list(app), m]);
89
+ return json(200, m);
90
+ }
91
+ }
92
+ const m = id ? find(app, id) : undefined;
93
+ if (!m)
94
+ return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
95
+ if (!action) {
96
+ if (method === "GET")
97
+ return json(200, m);
98
+ if (method === "POST") {
99
+ m.config = structuredClone((b.config ?? {}));
100
+ m.instance_id = nextId("I");
101
+ m.state = settle(m);
102
+ return json(200, m);
103
+ }
104
+ if (method === "DELETE") {
105
+ // Real flaps destroys a running machine only with ?force=true and
106
+ // answers a bare DELETE with 412 (#3115). mudflaps does not (yet).
107
+ if (searchParams.get("force") !== "true" && !["stopped", "suspended", "failed", "created"].includes(m.state)) {
108
+ return json(412, {
109
+ error: "failed_precondition: unable to destroy machine, not currently stopped, suspended, failed or created",
110
+ });
111
+ }
112
+ machines.set(app, list(app).filter((x) => x !== m));
113
+ return json(200, { ok: true });
114
+ }
115
+ }
116
+ if (action === "lease") {
117
+ if (method === "POST")
118
+ return json(200, { data: { nonce: nextId("n") } });
119
+ return json(200, { ok: true });
120
+ }
121
+ if (action === "wait") {
122
+ const want = searchParams.get("state") ?? "started";
123
+ return json(200, { ok: m.state === want });
124
+ }
125
+ if (action === "restart" || action === "start") {
126
+ m.state = "started";
127
+ return json(200, { ok: true });
128
+ }
129
+ if (action === "stop") {
130
+ m.state = "stopped";
131
+ return json(200, { ok: true });
132
+ }
133
+ if (action === "exec" && method === "POST") {
134
+ const command = b.command ?? String(b.cmd ?? "").split(" ");
135
+ execs.push({ app, id: m.id, command });
136
+ return json(200, (await options.exec?.(app, m, command)) ?? { exit_code: 0, stdout: "", stderr: "" });
137
+ }
138
+ return json(404, { error: `no ${method} ${action}` });
139
+ }
140
+ if (kind === "ip_assignments")
141
+ return method === "GET" ? json(200, { ips: [] }) : json(200, {});
142
+ if (kind === "certificates")
143
+ return certificates(app, method, seg.slice(4), b, searchParams);
144
+ if (kind === "volumes" || kind === "secrets")
145
+ return method === "GET" ? json(200, []) : json(200, {});
146
+ return json(404, { error: "not found" });
147
+ };
148
+ // Certificates, as the real API routes them (#3114): listed at
149
+ // `.../certificates`, created only at `.../certificates/acme`, read and
150
+ // deleted at `.../certificates/{hostname}`. A POST to the bare list path is
151
+ // the 404 flaps gives, so an applier that posts there fails here too.
152
+ // The list pages like Fly's (#3224): `limit` defaults to 25 and is capped at
153
+ // 500, and `next_cursor` is present while certificates remain. mudflaps
154
+ // returns the whole list in one page (INTENTIUS/mudflaps#71).
155
+ function certificates(app, method, rest, b, q) {
156
+ const hostnames = certs.get(app) ?? [];
157
+ const detail = (hostname) => ({ hostname, acme_requested: true, configured: false, status: "pending" });
158
+ if (rest.length === 0) {
159
+ if (method !== "GET")
160
+ return { status: 404, text: "404 page not found" };
161
+ const limit = Math.min(CERT_PAGE_MAX, Math.max(1, Number(q.get("limit")) || CERT_PAGE_DEFAULT));
162
+ const cursor = q.get("cursor");
163
+ const from = cursor === null ? 0 : cursorOffset(cursor);
164
+ if (from === undefined)
165
+ return json(400, { error: `invalid cursor ${cursor}` });
166
+ const page = hostnames.slice(from, from + limit);
167
+ const more = from + limit < hostnames.length;
168
+ return json(200, {
169
+ certificates: page.map(detail),
170
+ ...(more ? { next_cursor: offsetCursor(from + limit) } : {}),
171
+ total_count: hostnames.length,
172
+ });
173
+ }
174
+ if (rest.length === 1 && rest[0] === "acme" && method === "POST") {
175
+ const hostname = typeof b.hostname === "string" ? b.hostname : "";
176
+ if (!hostname)
177
+ return json(422, { error: "hostname is required" });
178
+ if (hostnames.includes(hostname))
179
+ return json(422, { error: `certificate ${hostname} already exists` });
180
+ certs.set(app, [...hostnames, hostname]);
181
+ return json(201, detail(hostname));
182
+ }
183
+ if (rest.length === 1 && (method === "GET" || method === "DELETE")) {
184
+ const hostname = rest[0];
185
+ if (!hostnames.includes(hostname))
186
+ return json(404, { error: "certificate not found" });
187
+ if (method === "GET")
188
+ return json(200, detail(hostname));
189
+ certs.set(app, hostnames.filter((h) => h !== hostname));
190
+ return json(204, undefined);
191
+ }
192
+ return { status: 404, text: "404 page not found" };
193
+ }
194
+ return {
195
+ http,
196
+ apps,
197
+ machines,
198
+ certs,
199
+ execs,
200
+ calls,
201
+ machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
202
+ };
203
+ }