@intentius/chant-lexicon-fly 0.89.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 +4 -4
- package/dist/manifest.json +1 -1
- package/dist/op/activities/emulator-images.d.ts +13 -0
- package/dist/op/activities/emulator-images.d.ts.map +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 +6 -2
- 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/activities/sprite-services.d.ts +158 -0
- package/dist/op/activities/sprite-services.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +17 -1
- package/dist/op/activities/sprites-contract.d.ts.map +1 -1
- package/dist/op/activities/sprites-emulator.d.ts +12 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
- package/dist/op/activities/sprites-fake.d.ts +4 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +53 -0
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/builders.d.ts +33 -1
- 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/dist/skills/chant-fly-sprites.md +4 -2
- 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 +21 -0
- package/src/op/activities/emulator-images.ts +14 -0
- package/src/op/activities/fly-apply.ts +15 -3
- package/src/op/activities/index.ts +57 -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/activities/sprite-config.test.ts +19 -0
- package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
- package/src/op/activities/sprite-services.test.ts +263 -0
- package/src/op/activities/sprite-services.ts +311 -0
- package/src/op/activities/sprites-contract.test.ts +15 -4
- package/src/op/activities/sprites-contract.ts +24 -6
- package/src/op/activities/sprites-emulator.ts +52 -1
- package/src/op/activities/sprites-fake.ts +57 -8
- package/src/op/activities/sprites.test.ts +102 -0
- package/src/op/activities/sprites.ts +108 -7
- package/src/op/builders.ts +53 -1
- package/src/release-metadata.ts +72 -0
- package/src/release-store.ts +70 -0
- package/src/skills/chant-fly-ops.md +33 -0
- package/src/skills/chant-fly-sprites.md +4 -2
|
@@ -0,0 +1,407 @@
|
|
|
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
|
+
|
|
33
|
+
import {
|
|
34
|
+
appNameFromRequest,
|
|
35
|
+
defaultFlyHttp,
|
|
36
|
+
flyApply,
|
|
37
|
+
isAppRequest,
|
|
38
|
+
isMachineRequest,
|
|
39
|
+
listMachines,
|
|
40
|
+
machineAppSegment,
|
|
41
|
+
resolveApp,
|
|
42
|
+
resolveEndpoint,
|
|
43
|
+
waitForMachine,
|
|
44
|
+
withLease,
|
|
45
|
+
LEASE_NONCE_HEADER,
|
|
46
|
+
type ApplyCtx,
|
|
47
|
+
type FlapsMachine,
|
|
48
|
+
type FlapsRequest,
|
|
49
|
+
type FlyHttp,
|
|
50
|
+
type FlyPlan,
|
|
51
|
+
type WaitOpts,
|
|
52
|
+
} from "./fly-apply";
|
|
53
|
+
import { parsePlan } from "./fly-apply";
|
|
54
|
+
import { readFileSync } from "node:fs";
|
|
55
|
+
import { readMachineRelease, withReleaseMetadata, type MachineConfig, type MachineRelease } from "../../release-metadata";
|
|
56
|
+
|
|
57
|
+
export { RELEASE_METADATA_KEYS, readMachineRelease, withReleaseMetadata } from "../../release-metadata";
|
|
58
|
+
export type { MachineRelease } from "../../release-metadata";
|
|
59
|
+
|
|
60
|
+
/** The App and Machine a release targets in a plan. */
|
|
61
|
+
export interface ReleaseTarget {
|
|
62
|
+
app: string;
|
|
63
|
+
/** The plan entity that declares the Machine. */
|
|
64
|
+
entity: string;
|
|
65
|
+
/** The Machine's name on Fly (its `name`, else the entity name). */
|
|
66
|
+
name: string;
|
|
67
|
+
request: FlapsRequest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Machine a release targets: the plan's one Machine, or the one `machine`
|
|
72
|
+
* names (an entity name or a Machine name). Pure.
|
|
73
|
+
*/
|
|
74
|
+
export function releaseTarget(plan: FlyPlan, machine?: string): ReleaseTarget {
|
|
75
|
+
const appNames = Object.values(plan).filter(isAppRequest).map(appNameFromRequest);
|
|
76
|
+
const soleApp = appNames.length === 1 ? appNames[0] : undefined;
|
|
77
|
+
const machines = Object.entries(plan)
|
|
78
|
+
.filter(([, req]) => isMachineRequest(req))
|
|
79
|
+
.map(([entity, request]) => ({
|
|
80
|
+
entity,
|
|
81
|
+
request,
|
|
82
|
+
name: typeof request.body.name === "string" && request.body.name ? request.body.name : entity,
|
|
83
|
+
}));
|
|
84
|
+
const picked = machine ? machines.filter((m) => m.entity === machine || m.name === machine) : machines;
|
|
85
|
+
if (picked.length !== 1) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
machine
|
|
88
|
+
? `the fly plan declares no Machine named "${machine}" (declared: ${machines.map((m) => m.entity).join(", ") || "none"})`
|
|
89
|
+
: `the fly plan declares ${machines.length} Machines (${machines.map((m) => m.entity).join(", ")}): name the one that serves the release`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const [m] = picked;
|
|
93
|
+
return { app: resolveApp(machineAppSegment(m.request.endpoint), soleApp), entity: m.entity, name: m.name, request: m.request };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const TERMINAL = new Set(["destroyed", "destroying"]);
|
|
97
|
+
|
|
98
|
+
/** The live Machine named `name` (or with id `name`) in `app`; undefined when there is none. */
|
|
99
|
+
export async function findMachine(
|
|
100
|
+
ctx: ApplyCtx,
|
|
101
|
+
app: string,
|
|
102
|
+
name: string,
|
|
103
|
+
http: FlyHttp,
|
|
104
|
+
signal?: AbortSignal,
|
|
105
|
+
): Promise<FlapsMachine | undefined> {
|
|
106
|
+
const live = (await listMachines(ctx, app, http, signal)).filter((m) => !TERMINAL.has(m.state));
|
|
107
|
+
return live.find((m) => m.name === name) ?? live.find((m) => m.id === name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function mustFindMachine(ctx: ApplyCtx, app: string, name: string, http: FlyHttp, signal?: AbortSignal): Promise<FlapsMachine> {
|
|
111
|
+
const m = await findMachine(ctx, app, name, http, signal);
|
|
112
|
+
if (!m) throw new Error(`no machine ${name} on app ${app}`);
|
|
113
|
+
return m;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const machinePath = (ctx: ApplyCtx, app: string, id: string) =>
|
|
117
|
+
`${ctx.base}/v1/apps/${encodeURIComponent(app)}/machines/${encodeURIComponent(id)}`;
|
|
118
|
+
|
|
119
|
+
/** Where to reach flaps, as every activity here takes it. */
|
|
120
|
+
export interface FlapsTarget {
|
|
121
|
+
/** flaps endpoint override. Default: `FLY_FLAPS_BASE_URL`, else real Fly. */
|
|
122
|
+
endpoint?: string;
|
|
123
|
+
/** Bearer token for real Fly. Default: `FLY_API_TOKEN`. */
|
|
124
|
+
token?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── flyMachineRelease ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export interface FlyMachineReleaseArgs extends FlapsTarget {
|
|
130
|
+
/** Path to the fly build output (`chant build --lexicon fly -o <path>`). */
|
|
131
|
+
planPath?: string;
|
|
132
|
+
/** The plan itself, in place of `planPath`. */
|
|
133
|
+
plan?: FlyPlan;
|
|
134
|
+
/** The Machine that serves the release, when the plan declares more than one. */
|
|
135
|
+
machine?: string;
|
|
136
|
+
/** The release to serve. `previousDigest` is filled in from the Machine it replaces. */
|
|
137
|
+
release: MachineRelease;
|
|
138
|
+
/** An image to run in place of the declared one (a digest-pinned image a publish step produced). */
|
|
139
|
+
image?: string;
|
|
140
|
+
/** Env to add to the declared Machine's env. */
|
|
141
|
+
env?: Record<string, string>;
|
|
142
|
+
wait?: WaitOpts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FlyMachineReleaseResult {
|
|
146
|
+
app: string;
|
|
147
|
+
machine: { id: string; name: string };
|
|
148
|
+
/** What the apply did to the Machine. */
|
|
149
|
+
action: "created" | "updated" | "noop";
|
|
150
|
+
/** The release the Machine serves now. */
|
|
151
|
+
release: MachineRelease;
|
|
152
|
+
/** The Machine config applied: what a later rollback to this release puts back. */
|
|
153
|
+
config: MachineConfig;
|
|
154
|
+
/** What the Machine served before, when it existed: its release (if it named one) and its config. */
|
|
155
|
+
previous: { release?: MachineRelease; config: MachineConfig } | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Upload and start: apply the declared plan (the App, its Volumes, IPs,
|
|
160
|
+
* Secrets) with the Machine serving `release`. The Machine is updated in place
|
|
161
|
+
* under a lease, so the release serves from here on. A Machine that already
|
|
162
|
+
* serves this release keeps the `previousDigest` it shipped with, so running a
|
|
163
|
+
* release twice never makes it its own predecessor.
|
|
164
|
+
*/
|
|
165
|
+
export async function flyMachineRelease(
|
|
166
|
+
args: FlyMachineReleaseArgs,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
169
|
+
): Promise<FlyMachineReleaseResult> {
|
|
170
|
+
const plan = args.plan ?? parsePlan(readFileSync(requirePlanPath(args.planPath), "utf8"));
|
|
171
|
+
const target = releaseTarget(plan, args.machine);
|
|
172
|
+
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
173
|
+
|
|
174
|
+
const live = await findMachine(ctx, target.app, target.name, http, signal).catch(() => undefined);
|
|
175
|
+
const liveConfig = live?.config as MachineConfig | undefined;
|
|
176
|
+
const liveRelease = readMachineRelease(liveConfig?.metadata);
|
|
177
|
+
const previousDigest =
|
|
178
|
+
liveRelease && liveRelease.digest !== args.release.digest ? liveRelease.digest : liveRelease?.previousDigest ?? args.release.previousDigest;
|
|
179
|
+
const release: MachineRelease = { ...args.release, ...(previousDigest ? { previousDigest } : {}) };
|
|
180
|
+
if (!previousDigest) delete release.previousDigest;
|
|
181
|
+
|
|
182
|
+
const declared = (target.request.body.config ?? {}) as MachineConfig;
|
|
183
|
+
const config = withReleaseMetadata(
|
|
184
|
+
{
|
|
185
|
+
...declared,
|
|
186
|
+
...(args.image ? { image: args.image } : {}),
|
|
187
|
+
...(args.env ? { env: { ...(declared.env ?? {}), ...args.env } } : {}),
|
|
188
|
+
},
|
|
189
|
+
release,
|
|
190
|
+
);
|
|
191
|
+
const request: FlapsRequest = { ...target.request, body: { ...target.request.body, config } };
|
|
192
|
+
const applied = await flyApply(
|
|
193
|
+
{ plan: { ...plan, [target.entity]: request }, endpoint: args.endpoint, token: args.token, wait: args.wait },
|
|
194
|
+
signal,
|
|
195
|
+
http,
|
|
196
|
+
);
|
|
197
|
+
const action = applied.machines.find((m) => m.app === target.app && m.name === target.name)?.action ?? "noop";
|
|
198
|
+
const now = await mustFindMachine(ctx, target.app, target.name, http, signal);
|
|
199
|
+
return {
|
|
200
|
+
app: target.app,
|
|
201
|
+
machine: { id: now.id, name: target.name },
|
|
202
|
+
action,
|
|
203
|
+
release,
|
|
204
|
+
config,
|
|
205
|
+
previous: live ? { ...(liveRelease ? { release: liveRelease } : {}), config: liveConfig ?? {} } : null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requirePlanPath(planPath: string | undefined): string {
|
|
210
|
+
if (!planPath) throw new Error("pass planPath (the fly build output) or plan");
|
|
211
|
+
return planPath;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── flyMachineExec ────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export interface FlyMachineExecArgs extends FlapsTarget {
|
|
217
|
+
app: string;
|
|
218
|
+
/** The Machine's name or id. */
|
|
219
|
+
machine: string;
|
|
220
|
+
/** The command: an argv, or a string run with `sh -c`. */
|
|
221
|
+
command: string[] | string;
|
|
222
|
+
/** Seconds the Machines API lets the command run. Default 120. */
|
|
223
|
+
timeoutSecs?: number;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface FlyMachineExecResult {
|
|
227
|
+
exitCode: number;
|
|
228
|
+
stdout: string;
|
|
229
|
+
stderr: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run a command inside the Machine (the Machines API's exec). A non-zero exit
|
|
234
|
+
* throws, with the command's output, so the step fails rather than going on
|
|
235
|
+
* over a half-migrated database.
|
|
236
|
+
*/
|
|
237
|
+
export async function flyMachineExec(
|
|
238
|
+
args: FlyMachineExecArgs,
|
|
239
|
+
signal?: AbortSignal,
|
|
240
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
241
|
+
): Promise<FlyMachineExecResult> {
|
|
242
|
+
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
243
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
244
|
+
const command = typeof args.command === "string" ? ["sh", "-c", args.command] : args.command;
|
|
245
|
+
const res = await http("POST", `${machinePath(ctx, args.app, m.id)}/exec`, { command, timeout: args.timeoutSecs ?? 120 }, undefined, signal);
|
|
246
|
+
if (res.status >= 300) throw new Error(`exec on ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
247
|
+
let out: { exit_code?: number; stdout?: string; stderr?: string } = {};
|
|
248
|
+
try {
|
|
249
|
+
out = JSON.parse(res.text) as typeof out;
|
|
250
|
+
} catch {
|
|
251
|
+
throw new Error(`exec on ${args.app}/${m.id} answered no exec result: ${res.text}`);
|
|
252
|
+
}
|
|
253
|
+
const result = { exitCode: out.exit_code ?? 0, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
|
|
254
|
+
if (result.exitCode !== 0) {
|
|
255
|
+
const said = [result.stderr, result.stdout].filter(Boolean).join("\n").trim().slice(-2000);
|
|
256
|
+
throw new Error(`${command.join(" ")} exited ${result.exitCode} on ${args.app}/${m.id}${said ? `: ${said}` : ""}`);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── flyMachineRestart / flyMachineStop ────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export interface FlyMachineStateArgs extends FlapsTarget {
|
|
264
|
+
app: string;
|
|
265
|
+
/** The Machine's name or id. */
|
|
266
|
+
machine: string;
|
|
267
|
+
wait?: WaitOpts;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function transition(
|
|
271
|
+
args: FlyMachineStateArgs,
|
|
272
|
+
verb: "restart" | "stop",
|
|
273
|
+
signal: AbortSignal | undefined,
|
|
274
|
+
http: FlyHttp,
|
|
275
|
+
): Promise<{ id: string; state: string }> {
|
|
276
|
+
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
277
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
278
|
+
const state = verb === "stop" ? "stopped" : "started";
|
|
279
|
+
if (verb === "stop" && m.state !== "started") return { id: m.id, state: m.state };
|
|
280
|
+
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
|
|
281
|
+
http("POST", `${machinePath(ctx, args.app, m.id)}/${verb}`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
282
|
+
);
|
|
283
|
+
if (res.status >= 300) throw new Error(`${verb} of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
284
|
+
await waitForMachine(ctx, args.app, m.id, "", http, signal, { ...(args.wait ?? {}), state });
|
|
285
|
+
return { id: m.id, state };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Restart the Machine under a lease (the app starts again on migrated data), and wait for it to be started. */
|
|
289
|
+
export async function flyMachineRestart(
|
|
290
|
+
args: FlyMachineStateArgs,
|
|
291
|
+
signal?: AbortSignal,
|
|
292
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
293
|
+
): Promise<{ id: string; state: string }> {
|
|
294
|
+
return transition(args, "restart", signal, http);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Stop the Machine under a lease, and wait for it to be stopped. A Machine that is not started is left alone. */
|
|
298
|
+
export async function flyMachineStop(
|
|
299
|
+
args: FlyMachineStateArgs,
|
|
300
|
+
signal?: AbortSignal,
|
|
301
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
302
|
+
): Promise<{ id: string; state: string }> {
|
|
303
|
+
return transition(args, "stop", signal, http);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── flyMachineVerify ──────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
export interface FlyMachineVerifyArgs extends FlapsTarget {
|
|
309
|
+
app: string;
|
|
310
|
+
machine: string;
|
|
311
|
+
/** The release digest the Machine must name. */
|
|
312
|
+
digest: string;
|
|
313
|
+
/** The app's public URL; with it, its health endpoint must answer. */
|
|
314
|
+
url?: string;
|
|
315
|
+
/** Path of the health endpoint under `url`. Default `/health`. */
|
|
316
|
+
healthPath?: string;
|
|
317
|
+
/** How long to keep asking. Default 120s. */
|
|
318
|
+
timeoutMs?: number;
|
|
319
|
+
/** Delay between asks. Default 1s. */
|
|
320
|
+
intervalMs?: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The Machine is started and its metadata names release `digest`. Given a
|
|
325
|
+
* `url`, its health endpoint answers 2xx too, and when that answer is JSON
|
|
326
|
+
* naming a `revision` or `digest`, it is this release's (the commit or the
|
|
327
|
+
* digest). Retries until `timeoutMs`, then throws the last reason.
|
|
328
|
+
*/
|
|
329
|
+
export async function flyMachineVerify(
|
|
330
|
+
args: FlyMachineVerifyArgs,
|
|
331
|
+
signal?: AbortSignal,
|
|
332
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
333
|
+
fetchImpl: typeof fetch = fetch,
|
|
334
|
+
): Promise<{ id: string; release: MachineRelease }> {
|
|
335
|
+
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
336
|
+
const deadline = Date.now() + (args.timeoutMs ?? 120_000);
|
|
337
|
+
const interval = args.intervalMs ?? 1_000;
|
|
338
|
+
let why = "";
|
|
339
|
+
for (;;) {
|
|
340
|
+
if (signal?.aborted) throw new Error("flyMachineVerify aborted");
|
|
341
|
+
try {
|
|
342
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
343
|
+
if (m.state !== "started") throw new Error(`machine ${args.app}/${m.id} is ${m.state}`);
|
|
344
|
+
const release = readMachineRelease(m.config?.metadata);
|
|
345
|
+
if (release?.digest !== args.digest) {
|
|
346
|
+
throw new Error(`machine ${args.app}/${m.id} serves ${release?.digest ?? "no release"}, expected ${args.digest}`);
|
|
347
|
+
}
|
|
348
|
+
if (args.url) {
|
|
349
|
+
const url = `${args.url.replace(/\/$/, "")}${args.healthPath ?? "/health"}`;
|
|
350
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(5_000) });
|
|
351
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
352
|
+
let body: { revision?: unknown; digest?: unknown } | undefined;
|
|
353
|
+
try {
|
|
354
|
+
body = (await res.json()) as typeof body;
|
|
355
|
+
} catch {
|
|
356
|
+
body = undefined;
|
|
357
|
+
}
|
|
358
|
+
const reported = typeof body?.revision === "string" ? body.revision : typeof body?.digest === "string" ? body.digest : undefined;
|
|
359
|
+
if (reported !== undefined && reported !== release.gitSha && reported !== release.digest) {
|
|
360
|
+
throw new Error(`${url} reports ${reported}, expected ${release.gitSha ?? release.digest}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { id: m.id, release };
|
|
364
|
+
} catch (err) {
|
|
365
|
+
why = err instanceof Error ? err.message : String(err);
|
|
366
|
+
}
|
|
367
|
+
if (Date.now() + interval > deadline) throw new Error(`verify ${args.app}/${args.machine}: ${why}`);
|
|
368
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── flyMachineRestore ─────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
export interface FlyMachineRestoreArgs extends FlapsTarget {
|
|
375
|
+
app: string;
|
|
376
|
+
machine: string;
|
|
377
|
+
/** The Machine config to put back, as a release applied it (its metadata names that release). */
|
|
378
|
+
config: MachineConfig;
|
|
379
|
+
wait?: WaitOpts;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Put a recorded Machine config back: update the Machine to exactly `config`
|
|
384
|
+
* under a lease and wait for it to be started. Used on a failed release (the
|
|
385
|
+
* config it replaced) and by a rollback (the config an earlier release applied).
|
|
386
|
+
*/
|
|
387
|
+
export async function flyMachineRestore(
|
|
388
|
+
args: FlyMachineRestoreArgs,
|
|
389
|
+
signal?: AbortSignal,
|
|
390
|
+
http: FlyHttp = defaultFlyHttp(args.token),
|
|
391
|
+
): Promise<{ id: string; release?: MachineRelease }> {
|
|
392
|
+
const ctx: ApplyCtx = { base: resolveEndpoint(args) };
|
|
393
|
+
const m = await mustFindMachine(ctx, args.app, args.machine, http, signal);
|
|
394
|
+
const res = await withLease(ctx, args.app, m.id, http, signal, (nonce) =>
|
|
395
|
+
http("POST", machinePath(ctx, args.app, m.id), { name: m.name, config: args.config }, { [LEASE_NONCE_HEADER]: nonce }, signal),
|
|
396
|
+
);
|
|
397
|
+
if (res.status >= 300) throw new Error(`restore of machine ${args.app}/${m.id} failed (${res.status}): ${res.text}`);
|
|
398
|
+
let instance = "";
|
|
399
|
+
try {
|
|
400
|
+
instance = (JSON.parse(res.text) as { instance_id?: string }).instance_id ?? "";
|
|
401
|
+
} catch {
|
|
402
|
+
instance = "";
|
|
403
|
+
}
|
|
404
|
+
await waitForMachine(ctx, args.app, m.id, instance, http, signal, args.wait ?? {});
|
|
405
|
+
const release = readMachineRelease(args.config.metadata);
|
|
406
|
+
return { id: m.id, ...(release ? { release } : {}) };
|
|
407
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
2
|
import { flapsUp, flapsDown } from "./flaps";
|
|
3
|
-
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
3
|
+
import { MACHINES_CONTRACT, MACHINE_RELEASE_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
4
4
|
|
|
5
5
|
// Fidelity check: every flaps endpoint the flyApply applier depends on
|
|
6
6
|
// (MACHINES_CONTRACT) must be served by the pinned mudflaps image — the twin of
|
|
@@ -69,4 +69,13 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
|
|
|
69
69
|
expect(roadmap.has(key), `${e.op} → ${e.method} ${e.path} is a mudflaps roadmap endpoint`).toBe(false);
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test("the pinned mudflaps serves every endpoint the release activities depend on (#2736)", async (ctx) => {
|
|
74
|
+
if (!available) ctx.skip();
|
|
75
|
+
const res = await fetch(`${endpoint}/_mudflaps/health`);
|
|
76
|
+
const health = (await res.json()) as { implemented?: string[] };
|
|
77
|
+
const served = new Set((health.implemented ?? []).map(normalizeImplemented));
|
|
78
|
+
const missing = MACHINE_RELEASE_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)).filter((k) => !served.has(k));
|
|
79
|
+
expect(missing, `mudflaps is missing release endpoints: ${missing.join(", ")}`).toEqual([]);
|
|
80
|
+
});
|
|
72
81
|
});
|
|
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { MACHINES_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
5
|
+
import { MACHINES_CONTRACT, MACHINE_RELEASE_CONTRACT, normalizeEndpoint, contractKeys } from "./machines-contract";
|
|
6
6
|
|
|
7
7
|
describe("MACHINES_CONTRACT", () => {
|
|
8
8
|
test("covers the flyApply resource operations (apps, machines, leases, volumes, ips, certs, secrets)", () => {
|
|
@@ -46,4 +46,14 @@ describe("MACHINES_CONTRACT", () => {
|
|
|
46
46
|
expect(src, `path segment "${seg}" from the contract is absent from fly-apply.ts`).toContain(seg);
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
test("every release-contract verb appears in the machine-release.ts source (drift anchor, #2736)", () => {
|
|
51
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "machine-release.ts"), "utf-8");
|
|
52
|
+
for (const e of MACHINE_RELEASE_CONTRACT) {
|
|
53
|
+
expect(src, `${e.op} is absent from machine-release.ts`).toContain(e.op);
|
|
54
|
+
}
|
|
55
|
+
// exec is a literal path segment; restart and stop go through one `/${verb}` template.
|
|
56
|
+
expect(src).toContain("/exec");
|
|
57
|
+
for (const verb of ['"restart"', '"stop"']) expect(src).toContain(verb);
|
|
58
|
+
});
|
|
49
59
|
});
|
|
@@ -71,3 +71,20 @@ export function normalizeEndpoint(method: string, path: string): string {
|
|
|
71
71
|
export function contractKeys(): Set<string> {
|
|
72
72
|
return new Set(MACHINES_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)));
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The flaps endpoints the release activities (./machine-release.ts, #2736)
|
|
77
|
+
* add beyond the applier's: getting one Machine back, exec (a migration),
|
|
78
|
+
* restart and stop. The applier's own contract above stays exec-free; these
|
|
79
|
+
* are the site steps', and the docker-gated coverage test holds the pinned
|
|
80
|
+
* mudflaps to them the same way.
|
|
81
|
+
*/
|
|
82
|
+
export const MACHINE_RELEASE_CONTRACT: readonly MachinesEndpoint[] = [
|
|
83
|
+
{ method: "GET", path: "/v1/apps/{app}/machines", op: "findMachine" },
|
|
84
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "flyMachineRestore" },
|
|
85
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/exec", op: "flyMachineExec" },
|
|
86
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/restart", op: "flyMachineRestart" },
|
|
87
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "flyMachineStop" },
|
|
88
|
+
{ method: "POST", path: "/v1/apps/{app}/machines/{id}/lease", op: "withLease" },
|
|
89
|
+
{ method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
|
|
90
|
+
] as const;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory Fly Machines (flaps) API, as a {@link FlyHttp} (#2736).
|
|
3
|
+
*
|
|
4
|
+
* The twin of ./sprites-fake.ts for the Machines side: the endpoints the
|
|
5
|
+
* applier (./fly-apply.ts) and the release activities (./machine-release.ts)
|
|
6
|
+
* call, answered from memory with the response shapes flaps and mudflaps use.
|
|
7
|
+
* Unit tests inject it where the default client would reach the network; the
|
|
8
|
+
* docker-gated tests run the same activities against the pinned mudflaps.
|
|
9
|
+
*
|
|
10
|
+
* Not an activity: nothing in ./index.ts exports it, so `loadActivities`
|
|
11
|
+
* never binds it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FlyHttp } from "./fly-apply";
|
|
15
|
+
|
|
16
|
+
/** One Machine the fake holds. */
|
|
17
|
+
export interface FakeMachine {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
state: string;
|
|
21
|
+
instance_id: string;
|
|
22
|
+
config: Record<string, unknown> & { metadata?: Record<string, string> };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What an `exec` answers: the Machines API's exec envelope. */
|
|
26
|
+
export interface FakeExecResult {
|
|
27
|
+
exit_code: number;
|
|
28
|
+
stdout?: string;
|
|
29
|
+
stderr?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MachinesFake {
|
|
33
|
+
http: FlyHttp;
|
|
34
|
+
apps: Set<string>;
|
|
35
|
+
machines: Map<string, FakeMachine[]>;
|
|
36
|
+
/** Every exec, in order: the app, the machine id and the command. */
|
|
37
|
+
execs: Array<{ app: string; id: string; command: string[] }>;
|
|
38
|
+
/** Every call, as `METHOD path` (no base, no query). */
|
|
39
|
+
calls: string[];
|
|
40
|
+
/** The live machine named `name` in `app`, if any. */
|
|
41
|
+
machine(app: string, name: string): FakeMachine | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MachinesFakeOptions {
|
|
45
|
+
/** Answer an exec. Default: exit 0 with no output. */
|
|
46
|
+
exec?: (app: string, machine: FakeMachine, command: string[]) => FakeExecResult;
|
|
47
|
+
/** The state a created or updated machine settles in. Default `started`. */
|
|
48
|
+
settle?: (machine: FakeMachine) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const json = (status: number, body: unknown) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
|
|
52
|
+
|
|
53
|
+
let seq = 0;
|
|
54
|
+
const nextId = (prefix: string) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
|
|
55
|
+
|
|
56
|
+
/** A fresh in-memory flaps. */
|
|
57
|
+
export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesFake {
|
|
58
|
+
const apps = new Set<string>();
|
|
59
|
+
const machines = new Map<string, FakeMachine[]>();
|
|
60
|
+
const execs: MachinesFake["execs"] = [];
|
|
61
|
+
const calls: string[] = [];
|
|
62
|
+
const settle = options.settle ?? (() => "started");
|
|
63
|
+
const list = (app: string) => machines.get(app) ?? [];
|
|
64
|
+
const find = (app: string, id: string) => list(app).find((m) => m.id === id);
|
|
65
|
+
|
|
66
|
+
const http: FlyHttp = async (method, url, body) => {
|
|
67
|
+
const { pathname, searchParams } = new URL(url);
|
|
68
|
+
calls.push(`${method} ${pathname}`);
|
|
69
|
+
const seg = pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
70
|
+
// seg: ["v1", "apps", app?, kind?, id?, action?]
|
|
71
|
+
if (seg[0] !== "v1" || seg[1] !== "apps") return json(404, { error: "not found" });
|
|
72
|
+
const app = seg[2];
|
|
73
|
+
const b = (body ?? {}) as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
if (!app) {
|
|
76
|
+
if (method === "POST") {
|
|
77
|
+
const name = String(b.app_name);
|
|
78
|
+
if (apps.has(name)) return json(409, { error: "app exists" });
|
|
79
|
+
apps.add(name);
|
|
80
|
+
return json(201, { name, status: "deployed" });
|
|
81
|
+
}
|
|
82
|
+
return json(200, [...apps].map((name) => ({ name })));
|
|
83
|
+
}
|
|
84
|
+
if (seg.length === 3) {
|
|
85
|
+
if (method === "GET") return apps.has(app) ? json(200, { name: app, status: "deployed" }) : json(404, { error: "app not found" });
|
|
86
|
+
if (method === "DELETE") {
|
|
87
|
+
apps.delete(app);
|
|
88
|
+
machines.delete(app);
|
|
89
|
+
return json(202, undefined);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!apps.has(app)) return json(404, { error: "app not found" });
|
|
93
|
+
const kind = seg[3];
|
|
94
|
+
|
|
95
|
+
if (kind === "machines") {
|
|
96
|
+
const id = seg[4];
|
|
97
|
+
const action = seg[5];
|
|
98
|
+
if (!id) {
|
|
99
|
+
if (method === "GET") return json(200, list(app));
|
|
100
|
+
if (method === "POST") {
|
|
101
|
+
const m: FakeMachine = {
|
|
102
|
+
id: nextId("m"),
|
|
103
|
+
name: String(b.name ?? ""),
|
|
104
|
+
state: "created",
|
|
105
|
+
instance_id: nextId("I"),
|
|
106
|
+
config: structuredClone((b.config ?? {}) as FakeMachine["config"]),
|
|
107
|
+
};
|
|
108
|
+
m.state = settle(m);
|
|
109
|
+
machines.set(app, [...list(app), m]);
|
|
110
|
+
return json(200, m);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const m = id ? find(app, id) : undefined;
|
|
114
|
+
if (!m) return action === "wait" ? json(200, { ok: true }) : json(404, { error: "machine not found" });
|
|
115
|
+
if (!action) {
|
|
116
|
+
if (method === "GET") return json(200, m);
|
|
117
|
+
if (method === "POST") {
|
|
118
|
+
m.config = structuredClone((b.config ?? {}) as FakeMachine["config"]);
|
|
119
|
+
m.instance_id = nextId("I");
|
|
120
|
+
m.state = settle(m);
|
|
121
|
+
return json(200, m);
|
|
122
|
+
}
|
|
123
|
+
if (method === "DELETE") {
|
|
124
|
+
machines.set(app, list(app).filter((x) => x !== m));
|
|
125
|
+
return json(200, { ok: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (action === "lease") {
|
|
129
|
+
if (method === "POST") return json(200, { data: { nonce: nextId("n") } });
|
|
130
|
+
return json(200, { ok: true });
|
|
131
|
+
}
|
|
132
|
+
if (action === "wait") {
|
|
133
|
+
const want = searchParams.get("state") ?? "started";
|
|
134
|
+
return json(200, { ok: m.state === want });
|
|
135
|
+
}
|
|
136
|
+
if (action === "restart" || action === "start") {
|
|
137
|
+
m.state = "started";
|
|
138
|
+
return json(200, { ok: true });
|
|
139
|
+
}
|
|
140
|
+
if (action === "stop") {
|
|
141
|
+
m.state = "stopped";
|
|
142
|
+
return json(200, { ok: true });
|
|
143
|
+
}
|
|
144
|
+
if (action === "exec" && method === "POST") {
|
|
145
|
+
const command = (b.command as string[] | undefined) ?? String(b.cmd ?? "").split(" ");
|
|
146
|
+
execs.push({ app, id: m.id, command });
|
|
147
|
+
return json(200, options.exec?.(app, m, command) ?? { exit_code: 0, stdout: "", stderr: "" });
|
|
148
|
+
}
|
|
149
|
+
return json(404, { error: `no ${method} ${action}` });
|
|
150
|
+
}
|
|
151
|
+
if (kind === "ip_assignments") return method === "GET" ? json(200, { ips: [] }) : json(200, {});
|
|
152
|
+
if (kind === "certificates") return method === "GET" ? json(200, { certificates: [] }) : json(200, {});
|
|
153
|
+
if (kind === "volumes" || kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
|
|
154
|
+
return json(404, { error: "not found" });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
http,
|
|
159
|
+
apps,
|
|
160
|
+
machines,
|
|
161
|
+
execs,
|
|
162
|
+
calls,
|
|
163
|
+
machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -107,6 +107,25 @@ describe("spriteApplyNetworkPolicy reconcile", () => {
|
|
|
107
107
|
spriteApplyNetworkPolicy({ id: "np-1", rules: [{ domain: "", action: "allow" }], endpoint: fake.url }),
|
|
108
108
|
).rejects.toThrow(/missing domain/);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
// #2719: the real Sprites API and every official SDK (Go, JS, Python,
|
|
112
|
+
// Elixir) answer the policy update with 204 and no body; a 200 with the
|
|
113
|
+
// rules echoed back fails an SDK-backed client. Pin the fake's raw wire
|
|
114
|
+
// reply directly (spriteApplyNetworkPolicy itself tolerates any <300, so it
|
|
115
|
+
// wouldn't catch a regression here). GET is unchanged: 200 with the rules.
|
|
116
|
+
test("POST /policy/network answers 204 with no body; GET stays 200 with the rules", async () => {
|
|
117
|
+
await createImpl({ name: "np-wire", endpoint: fake.url });
|
|
118
|
+
const url = `${fake.url}/v1/sprites/np-wire/policy/network`;
|
|
119
|
+
const rules = [{ domain: "github.com", action: "allow" }];
|
|
120
|
+
|
|
121
|
+
const post = await fetch(url, { method: "POST", body: JSON.stringify({ rules }) });
|
|
122
|
+
expect(post.status).toBe(204);
|
|
123
|
+
expect(await post.text()).toBe("");
|
|
124
|
+
|
|
125
|
+
const get = await fetch(url);
|
|
126
|
+
expect(get.status).toBe(200);
|
|
127
|
+
expect(await get.json()).toEqual({ rules });
|
|
128
|
+
});
|
|
110
129
|
});
|
|
111
130
|
|
|
112
131
|
describe("spriteApplyServices reconcile", () => {
|