@intentius/chant-lexicon-fly 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/components/fly-release.d.ts +51 -4
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +86 -0
- package/dist/composites/fly-site.d.ts.map +1 -0
- package/dist/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/fly-release-step.d.ts +24 -0
- package/dist/op/activities/fly-release-step.d.ts.map +1 -0
- package/dist/op/activities/fly-rollback-step.d.ts +25 -0
- package/dist/op/activities/fly-rollback-step.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +7 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machine-release.d.ts +16 -0
- package/dist/op/activities/machine-release.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +4 -2
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-local-cli.d.ts +20 -0
- package/dist/op/activities/machines-local-cli.d.ts.map +1 -0
- package/dist/op/activities/machines-local.d.ts +107 -0
- package/dist/op/activities/machines-local.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +12 -3
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts +107 -0
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +18 -1
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts +29 -9
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +62 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/builders.d.ts +32 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/skills/chant-fly-ops.md +67 -0
- package/dist/skills/chant-fly-sprites.md +2 -1
- package/package.json +11 -4
- package/src/components/fly-release.test.ts +135 -1
- package/src/components/fly-release.ts +125 -8
- package/src/composites/catalog.test.ts +2 -1
- package/src/composites/catalog.ts +98 -0
- package/src/composites/fly-site.test.ts +66 -0
- package/src/composites/fly-site.ts +140 -0
- package/src/index.ts +8 -0
- package/src/op/activities/fly-release-step.ts +32 -0
- package/src/op/activities/fly-rollback-step.ts +33 -0
- package/src/op/activities/index.ts +25 -0
- package/src/op/activities/machine-release.ts +22 -0
- package/src/op/activities/machines-fake.ts +5 -3
- package/src/op/activities/machines-local-cli.ts +73 -0
- package/src/op/activities/machines-local.test.ts +225 -0
- package/src/op/activities/machines-local.ts +437 -0
- package/src/op/activities/sprite-exec-options.docker.integration.test.ts +70 -0
- package/src/op/activities/sprite-fs.test.ts +55 -0
- package/src/op/activities/sprite-fs.ts +14 -5
- package/src/op/activities/sprite-service-converge.test.ts +268 -0
- package/src/op/activities/sprite-service-converge.ts +268 -0
- package/src/op/activities/sprites-fake.ts +51 -8
- package/src/op/activities/sprites.integration.test.ts +30 -0
- package/src/op/activities/sprites.test.ts +20 -0
- package/src/op/activities/sprites.ts +50 -10
- package/src/op/activity-contracts.test.ts +71 -0
- package/src/op/activity-contracts.ts +67 -0
- package/src/op/builders.ts +32 -0
- package/src/skills/chant-fly-ops.md +67 -0
- package/src/skills/chant-fly-sprites.md +2 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local Fly Machines API that runs its Machines (#2831).
|
|
3
|
+
*
|
|
4
|
+
* ./machines-fake.ts answers the flaps endpoints from memory and runs nothing,
|
|
5
|
+
* and so does mudflaps. That is what unit tests want, and it stays the
|
|
6
|
+
* default. This module is the opt-in running mode: the same in-memory API,
|
|
7
|
+
* with each started Machine run as a process on this host, so a release that
|
|
8
|
+
* puts a source tree on a Machine (`config.files` plus `init.cmd`) can be
|
|
9
|
+
* checked over HTTP.
|
|
10
|
+
*
|
|
11
|
+
* For each Machine the fake holds in state `started`:
|
|
12
|
+
*
|
|
13
|
+
* - `config.files` are written at their `guest_path` (under `root` when one
|
|
14
|
+
* is given), and the files an earlier instance wrote are removed first, as
|
|
15
|
+
* a Machine's root filesystem starts again from its image;
|
|
16
|
+
* - `config.mounts` are directories that outlive instances (a Volume);
|
|
17
|
+
* - `init.exec`, or `init.entrypoint` then `init.cmd`, runs with
|
|
18
|
+
* `config.env`, and an `exec` (a migration) runs its command with the same
|
|
19
|
+
* env;
|
|
20
|
+
* - a service's `internal_port` is published on a host port: when the
|
|
21
|
+
* Machine's env sets `PORT` to an `internal_port`, the process gets the
|
|
22
|
+
* host port instead, since every Machine here shares the host's network.
|
|
23
|
+
*
|
|
24
|
+
* No image is pulled: the command runs on this host's binaries (a Node app
|
|
25
|
+
* runs on this host's Node).
|
|
26
|
+
*
|
|
27
|
+
* A create or an update (a new `instance_id`) starts the process again, a
|
|
28
|
+
* `restart` restarts it, a `stop` or a delete stops it, and a process that
|
|
29
|
+
* exits on its own leaves its Machine `stopped`. Each call answers only after
|
|
30
|
+
* that is done, and a started process is given until its published port
|
|
31
|
+
* accepts a connection, so the next call (a migration's exec, a health check)
|
|
32
|
+
* finds the files and the server.
|
|
33
|
+
*
|
|
34
|
+
* Processes run in their own process group. `close()` stops them all
|
|
35
|
+
* (SIGTERM, then SIGKILL); if the host process exits or is ended by SIGINT,
|
|
36
|
+
* SIGTERM or SIGHUP first, they are killed with it. Only a SIGKILL of the host
|
|
37
|
+
* process can leave them running.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
41
|
+
import { chmodSync, closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
43
|
+
import { createConnection, createServer as createNetServer, type AddressInfo } from "node:net";
|
|
44
|
+
import { dirname, join } from "node:path";
|
|
45
|
+
import type { FlyHttp } from "./fly-apply";
|
|
46
|
+
import { createMachinesFake, type FakeExecResult, type FakeMachine, type MachinesFake, type MachinesFakeOptions } from "./machines-fake";
|
|
47
|
+
|
|
48
|
+
/** Where a Machine's service is published: one port for every Machine, a port per Machine name (or `app/name`), or a function. */
|
|
49
|
+
export type LocalPublish = number | Record<string, number> | ((app: string, machine: FakeMachine) => number | undefined);
|
|
50
|
+
|
|
51
|
+
export interface LocalMachinesOptions {
|
|
52
|
+
/**
|
|
53
|
+
* A host directory the Machines' guest paths are placed under: `/srv/app`
|
|
54
|
+
* becomes `<root>/srv/app`, in the files written, the mounts, and the
|
|
55
|
+
* command, exec and env strings that name those paths. Default: none, so a
|
|
56
|
+
* guest path is the same path on this host (a container or a box made for it).
|
|
57
|
+
*/
|
|
58
|
+
root?: string;
|
|
59
|
+
/** The host port a Machine's service is published on. Default: a free port per Machine, kept for its name. */
|
|
60
|
+
publish?: LocalPublish;
|
|
61
|
+
/** A file the processes' stdout and stderr are appended to. Default: this process's stderr. */
|
|
62
|
+
log?: string;
|
|
63
|
+
/** How long a started process is given to accept a connection on its published port. Default 15s. */
|
|
64
|
+
readyTimeoutMs?: number;
|
|
65
|
+
/** How long a stopped process is given after SIGTERM before SIGKILL. Default 5s. */
|
|
66
|
+
stopTimeoutMs?: number;
|
|
67
|
+
/** What happens, one line at a time (starts, stops, execs). Default: nothing is said. */
|
|
68
|
+
onEvent?: (line: string) => void;
|
|
69
|
+
/**
|
|
70
|
+
* Kill the processes when this process gets SIGINT, SIGTERM or SIGHUP, then raise the signal again if nothing
|
|
71
|
+
* else handles it. Default true; a caller that handles those signals itself (and calls `close()`) passes false.
|
|
72
|
+
*/
|
|
73
|
+
handleSignals?: boolean;
|
|
74
|
+
/** Passed to the fake: the state a created or updated Machine settles in. */
|
|
75
|
+
settle?: MachinesFakeOptions["settle"];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A Machine's process, as {@link LocalMachines.processes} reports it. */
|
|
79
|
+
export interface LocalProcess {
|
|
80
|
+
app: string;
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
instance: string;
|
|
84
|
+
pid: number | undefined;
|
|
85
|
+
/** The host port its service is published on, when its env names one. */
|
|
86
|
+
port: number | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface LocalMachines extends MachinesFake {
|
|
90
|
+
/** `http://127.0.0.1:<port>` for the running Machine `name` in `app`, when its service is published. */
|
|
91
|
+
endpoint(app: string, name: string): string | undefined;
|
|
92
|
+
/** The processes running now. */
|
|
93
|
+
processes(): LocalProcess[];
|
|
94
|
+
/** Stop every process and remove its files. Mounts are kept. */
|
|
95
|
+
close(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Running {
|
|
99
|
+
app: string;
|
|
100
|
+
id: string;
|
|
101
|
+
name: string;
|
|
102
|
+
instance: string;
|
|
103
|
+
files: string[];
|
|
104
|
+
child: ChildProcess | undefined;
|
|
105
|
+
port: number | undefined;
|
|
106
|
+
stopping: boolean;
|
|
107
|
+
exited: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
111
|
+
|
|
112
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
113
|
+
|
|
114
|
+
/** Is `pid`'s process group still there? */
|
|
115
|
+
function groupAlive(pid: number): boolean {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-pid, 0);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function killGroup(child: ChildProcess | undefined, signal: NodeJS.Signals): void {
|
|
125
|
+
if (!child?.pid) return;
|
|
126
|
+
try {
|
|
127
|
+
process.kill(-child.pid, signal);
|
|
128
|
+
} catch {
|
|
129
|
+
// Already gone.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function freePort(): Promise<number> {
|
|
134
|
+
const srv = createNetServer();
|
|
135
|
+
await new Promise<void>((ok, fail) => srv.once("error", fail).listen(0, "127.0.0.1", ok));
|
|
136
|
+
const { port } = srv.address() as AddressInfo;
|
|
137
|
+
await new Promise<void>((ok) => srv.close(() => ok()));
|
|
138
|
+
return port;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function connects(port: number): Promise<boolean> {
|
|
142
|
+
return new Promise((done) => {
|
|
143
|
+
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
144
|
+
socket.once("connect", () => {
|
|
145
|
+
socket.destroy();
|
|
146
|
+
done(true);
|
|
147
|
+
});
|
|
148
|
+
socket.once("error", () => done(false));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** An in-memory Machines API that runs each started Machine on this host. See the module header. */
|
|
153
|
+
export function createLocalMachines(options: LocalMachinesOptions = {}): LocalMachines {
|
|
154
|
+
const root = options.root;
|
|
155
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 15_000;
|
|
156
|
+
const stopTimeoutMs = options.stopTimeoutMs ?? 5_000;
|
|
157
|
+
const say = options.onEvent ?? (() => undefined);
|
|
158
|
+
const running = new Map<string, Running>();
|
|
159
|
+
/** Host ports picked for Machines with no declared one, by `app/name`, so a new instance serves where the last did. */
|
|
160
|
+
const picked = new Map<string, number>();
|
|
161
|
+
|
|
162
|
+
const host = (guest: string) => (root ? join(root, guest) : guest);
|
|
163
|
+
|
|
164
|
+
/** Rewrites guest paths named in a string to host paths (identity with no root). */
|
|
165
|
+
function mapperOf(config: FakeMachine["config"]): (s: string) => string {
|
|
166
|
+
if (!root) return (s) => s;
|
|
167
|
+
const prefixes = new Set<string>();
|
|
168
|
+
for (const m of (config.mounts as Array<{ path?: string }> | undefined) ?? []) if (m.path) prefixes.add(m.path.replace(/\/+$/, ""));
|
|
169
|
+
for (const f of (config.files as Array<{ guest_path?: string }> | undefined) ?? []) if (f.guest_path) prefixes.add(dirname(f.guest_path));
|
|
170
|
+
prefixes.delete("");
|
|
171
|
+
prefixes.delete("/");
|
|
172
|
+
if (prefixes.size === 0) return (s) => s;
|
|
173
|
+
const alts = [...prefixes].sort((a, b) => b.length - a.length).map(escape).join("|");
|
|
174
|
+
const re = new RegExp(`(^|[\\s=:'"(;&|])(${alts})(?=$|[/\\s'":;)&|])`, "g");
|
|
175
|
+
return (s) => s.replace(re, (_m, pre: string, path: string) => `${pre}${host(path)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function publishedPort(app: string, m: FakeMachine): Promise<number | undefined> {
|
|
179
|
+
const env = (m.config.env as Record<string, string> | undefined) ?? {};
|
|
180
|
+
const internal = ((m.config.services as Array<{ internal_port?: number }> | undefined) ?? []).map((s) => String(s.internal_port));
|
|
181
|
+
if (env.PORT === undefined || !internal.includes(String(env.PORT))) return undefined;
|
|
182
|
+
const p = options.publish;
|
|
183
|
+
const declared =
|
|
184
|
+
typeof p === "number" ? p : typeof p === "function" ? p(app, m) : p ? (p[`${app}/${m.name}`] ?? p[m.name]) : undefined;
|
|
185
|
+
if (declared !== undefined) return declared;
|
|
186
|
+
const key = `${app}/${m.name}`;
|
|
187
|
+
if (!picked.has(key)) picked.set(key, await freePort());
|
|
188
|
+
return picked.get(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function envOf(config: FakeMachine["config"], port: number | undefined): Record<string, string> {
|
|
192
|
+
const map = mapperOf(config);
|
|
193
|
+
const env: Record<string, string> = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/" };
|
|
194
|
+
for (const [k, v] of Object.entries((config.env as Record<string, string> | undefined) ?? {})) env[k] = map(String(v));
|
|
195
|
+
if (port !== undefined) env.PORT = String(port);
|
|
196
|
+
return env;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function argvOf(config: FakeMachine["config"]): string[] | undefined {
|
|
200
|
+
const init = (config.init ?? {}) as { exec?: string[]; entrypoint?: string[]; cmd?: string[] | string };
|
|
201
|
+
const map = mapperOf(config);
|
|
202
|
+
if (init.exec?.length) return init.exec.map(map);
|
|
203
|
+
const cmd = typeof init.cmd === "string" ? ["sh", "-c", init.cmd] : (init.cmd ?? []);
|
|
204
|
+
const argv = [...(init.entrypoint ?? []), ...cmd];
|
|
205
|
+
return argv.length ? argv.map(map) : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const stdio = (): { out: number; close: () => void } => {
|
|
209
|
+
if (!options.log) return { out: 2, close: () => undefined };
|
|
210
|
+
mkdirSync(dirname(options.log), { recursive: true });
|
|
211
|
+
const fd = openSync(options.log, "a");
|
|
212
|
+
return { out: fd, close: () => closeSync(fd) };
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function stop(key: string): Promise<void> {
|
|
216
|
+
const r = running.get(key);
|
|
217
|
+
if (!r) return;
|
|
218
|
+
running.delete(key);
|
|
219
|
+
r.stopping = true;
|
|
220
|
+
const child = r.child;
|
|
221
|
+
if (child?.pid && groupAlive(child.pid)) {
|
|
222
|
+
const exited = child.exitCode === null && child.signalCode === null ? new Promise((ok) => child.once("exit", ok)) : Promise.resolve();
|
|
223
|
+
killGroup(child, "SIGTERM");
|
|
224
|
+
const deadline = Date.now() + stopTimeoutMs;
|
|
225
|
+
// Wait for the leader to exit and the rest of its group to go.
|
|
226
|
+
await Promise.race([exited, new Promise((ok) => setTimeout(ok, stopTimeoutMs))]);
|
|
227
|
+
while (groupAlive(child.pid) && Date.now() < deadline) await new Promise((ok) => setTimeout(ok, 25));
|
|
228
|
+
if (groupAlive(child.pid)) {
|
|
229
|
+
killGroup(child, "SIGKILL");
|
|
230
|
+
while (groupAlive(child.pid)) await new Promise((ok) => setTimeout(ok, 25));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const path of r.files) rmSync(path, { force: true });
|
|
234
|
+
say(`stopped ${key} (instance ${r.instance})`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function start(app: string, m: FakeMachine): Promise<void> {
|
|
238
|
+
const key = `${app}/${m.id}`;
|
|
239
|
+
await stop(key);
|
|
240
|
+
const { config } = m;
|
|
241
|
+
for (const mount of (config.mounts as Array<{ path?: string }> | undefined) ?? []) if (mount.path) mkdirSync(host(mount.path), { recursive: true });
|
|
242
|
+
const files: string[] = [];
|
|
243
|
+
for (const f of (config.files as Array<{ guest_path: string; raw_value?: string; mode?: number }> | undefined) ?? []) {
|
|
244
|
+
const path = host(f.guest_path);
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
246
|
+
writeFileSync(path, Buffer.from(f.raw_value ?? "", "base64"));
|
|
247
|
+
if (f.mode) chmodSync(path, f.mode);
|
|
248
|
+
files.push(path);
|
|
249
|
+
}
|
|
250
|
+
const argv = argvOf(config);
|
|
251
|
+
const port = argv ? await publishedPort(app, m) : undefined;
|
|
252
|
+
const r: Running = { app, id: m.id, name: m.name, instance: m.instance_id, files, child: undefined, port, stopping: false, exited: false };
|
|
253
|
+
running.set(key, r);
|
|
254
|
+
if (!argv) {
|
|
255
|
+
say(`started ${key} instance ${m.instance_id}: ${files.length} files, no command`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const log = stdio();
|
|
259
|
+
const child = spawn(argv[0], argv.slice(1), { cwd: root ?? "/", env: envOf(config, port), stdio: ["ignore", log.out, log.out], detached: true });
|
|
260
|
+
log.close();
|
|
261
|
+
child.unref();
|
|
262
|
+
r.child = child;
|
|
263
|
+
child.once("error", (error) => say(`${key} instance ${m.instance_id} failed to start: ${error.message}`));
|
|
264
|
+
child.once("exit", (code, signal) => {
|
|
265
|
+
r.exited = true;
|
|
266
|
+
if (r.stopping) return;
|
|
267
|
+
say(`${key} instance ${m.instance_id} exited ${signal ?? code}`);
|
|
268
|
+
// A process that ends on its own leaves its Machine stopped, and its group is killed with it.
|
|
269
|
+
killGroup(child, "SIGKILL");
|
|
270
|
+
if (m.instance_id === r.instance && m.state === "started") m.state = "stopped";
|
|
271
|
+
});
|
|
272
|
+
say(`started ${key} instance ${m.instance_id}: ${files.length} files, ${argv.join(" ")}${port ? `, on 127.0.0.1:${port}` : ""}`);
|
|
273
|
+
if (port !== undefined) {
|
|
274
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
275
|
+
while (!r.exited && Date.now() < deadline && !(await connects(port))) await new Promise((ok) => setTimeout(ok, 50));
|
|
276
|
+
if (!r.exited && Date.now() >= deadline) say(`${key} instance ${m.instance_id} is not accepting connections on ${port} after ${readyTimeoutMs}ms`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function exec(app: string, m: FakeMachine, command: string[], timeoutSecs: number): Promise<FakeExecResult> {
|
|
281
|
+
const map = mapperOf(m.config);
|
|
282
|
+
const argv = command.map(map);
|
|
283
|
+
const port = running.get(`${app}/${m.id}`)?.port;
|
|
284
|
+
return new Promise((done) => {
|
|
285
|
+
const child = spawn(argv[0], argv.slice(1), { cwd: root ?? "/", env: envOf(m.config, port), stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
286
|
+
let stdout = "";
|
|
287
|
+
let stderr = "";
|
|
288
|
+
child.stdout!.on("data", (d) => (stdout += d));
|
|
289
|
+
child.stderr!.on("data", (d) => (stderr += d));
|
|
290
|
+
const timer = setTimeout(() => killGroup(child, "SIGKILL"), timeoutSecs * 1000);
|
|
291
|
+
child.once("error", (error) => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
done({ exit_code: 127, stdout, stderr: `${stderr}${error.message}` });
|
|
294
|
+
});
|
|
295
|
+
child.once("close", (code, signal) => {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
const exit = code ?? (signal ? 128 : 1);
|
|
298
|
+
say(`exec on ${app}/${m.id}: ${command.join(" ")} exited ${signal ?? code}${exit === 0 ? "" : `: ${`${stderr}${stdout}`.trim().slice(-500)}`}`);
|
|
299
|
+
done({ exit_code: exit, stdout, stderr });
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The exec timeout the last exec call asked for, read before the fake answers it. */
|
|
305
|
+
let execTimeout = 120;
|
|
306
|
+
const fake = createMachinesFake({
|
|
307
|
+
settle: options.settle,
|
|
308
|
+
exec: (app, m, command) => exec(app, m, command, execTimeout),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/** Make what runs match the fake's Machines after a call; `restarted` names a Machine (`app/id`) whose process starts again. */
|
|
312
|
+
async function reconcile(restarted: string | undefined): Promise<void> {
|
|
313
|
+
const live = new Set<string>();
|
|
314
|
+
for (const [app, machines] of fake.machines) {
|
|
315
|
+
for (const m of machines) {
|
|
316
|
+
const key = `${app}/${m.id}`;
|
|
317
|
+
live.add(key);
|
|
318
|
+
const r = running.get(key);
|
|
319
|
+
if (m.state !== "started") await stop(key);
|
|
320
|
+
else if (!r || r.exited || r.instance !== m.instance_id || key === restarted) await start(app, m);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const key of [...running.keys()]) if (!live.has(key)) await stop(key);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// One call at a time, so a start never races the next call.
|
|
327
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
328
|
+
const http: FlyHttp = (method, url, body, headers, signal) => {
|
|
329
|
+
const next = queue.then(async () => {
|
|
330
|
+
const seg = new URL(url).pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
331
|
+
if (method === "POST" && seg[3] === "machines" && seg[5] === "exec") {
|
|
332
|
+
const t = Number((body as { timeout?: number } | undefined)?.timeout);
|
|
333
|
+
execTimeout = Number.isFinite(t) && t > 0 ? t : 120;
|
|
334
|
+
}
|
|
335
|
+
const out = await fake.http(method, url, body, headers, signal);
|
|
336
|
+
if (method !== "GET" && !(seg[5] === "exec" || seg[5] === "lease" || seg[5] === "wait")) {
|
|
337
|
+
const restarted = method === "POST" && seg[3] === "machines" && seg[5] === "restart" && out.status < 300 ? `${seg[2]}/${seg[4]}` : undefined;
|
|
338
|
+
await reconcile(restarted);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
});
|
|
342
|
+
queue = next.catch(() => undefined);
|
|
343
|
+
return next;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Nothing is left running when this process ends: on exit, and on a signal (which is then raised again, so it still ends the process).
|
|
347
|
+
const killAll = () => {
|
|
348
|
+
for (const r of running.values()) {
|
|
349
|
+
r.stopping = true;
|
|
350
|
+
killGroup(r.child, "SIGKILL");
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const onSignal = (sig: NodeJS.Signals) => {
|
|
354
|
+
killAll();
|
|
355
|
+
detach();
|
|
356
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
357
|
+
};
|
|
358
|
+
const detach = () => {
|
|
359
|
+
process.off("exit", killAll);
|
|
360
|
+
for (const s of SIGNALS) process.off(s, onSignal);
|
|
361
|
+
};
|
|
362
|
+
process.on("exit", killAll);
|
|
363
|
+
if (options.handleSignals !== false) for (const s of SIGNALS) process.on(s, onSignal);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...fake,
|
|
367
|
+
http,
|
|
368
|
+
endpoint(app, name) {
|
|
369
|
+
const m = fake.machine(app, name);
|
|
370
|
+
const r = m ? running.get(`${app}/${m.id}`) : undefined;
|
|
371
|
+
const port = r && !r.exited ? r.port : undefined;
|
|
372
|
+
return port === undefined ? undefined : `http://127.0.0.1:${port}`;
|
|
373
|
+
},
|
|
374
|
+
processes: () =>
|
|
375
|
+
[...running.values()].filter((r) => !r.exited).map((r) => ({ app: r.app, id: r.id, name: r.name, instance: r.instance, pid: r.child?.pid, port: r.port })),
|
|
376
|
+
async close() {
|
|
377
|
+
await queue;
|
|
378
|
+
for (const key of [...running.keys()]) await stop(key);
|
|
379
|
+
detach();
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface ServeLocalMachinesOptions extends LocalMachinesOptions {
|
|
385
|
+
/** The port the Machines API listens on. Default 4280, mudflaps' port; 0 picks a free one. */
|
|
386
|
+
port?: number;
|
|
387
|
+
/** Default 127.0.0.1. */
|
|
388
|
+
host?: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export interface ServedLocalMachines {
|
|
392
|
+
/** The flaps base URL, for `FLY_FLAPS_BASE_URL` or an `endpoint` argument. */
|
|
393
|
+
url: string;
|
|
394
|
+
machines: LocalMachines;
|
|
395
|
+
/** Stop every Machine's process, then the server. */
|
|
396
|
+
close(): Promise<void>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Serve {@link createLocalMachines} over HTTP, where the release activities
|
|
401
|
+
* reach it through `FLY_FLAPS_BASE_URL`. `GET /_mudflaps/health` answers 200,
|
|
402
|
+
* as mudflaps' health check does.
|
|
403
|
+
*/
|
|
404
|
+
export async function serveLocalMachines(options: ServeLocalMachinesOptions = {}): Promise<ServedLocalMachines> {
|
|
405
|
+
const machines = createLocalMachines(options);
|
|
406
|
+
const hostname = options.host ?? "127.0.0.1";
|
|
407
|
+
const server: Server = createHttpServer((req, res) => {
|
|
408
|
+
const chunks: Buffer[] = [];
|
|
409
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
410
|
+
req.on("end", () => {
|
|
411
|
+
void (async () => {
|
|
412
|
+
let out: { status: number; text: string };
|
|
413
|
+
try {
|
|
414
|
+
if (req.method === "GET" && req.url === "/_mudflaps/health") out = { status: 200, text: JSON.stringify({ status: "ok" }) };
|
|
415
|
+
else {
|
|
416
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
417
|
+
out = await machines.http(req.method ?? "GET", `http://${hostname}${req.url}`, text ? JSON.parse(text) : undefined, {});
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
out = { status: 500, text: JSON.stringify({ error: (error as Error).message }) };
|
|
421
|
+
}
|
|
422
|
+
res.writeHead(out.status, out.text ? { "content-type": "application/json" } : {});
|
|
423
|
+
res.end(out.text);
|
|
424
|
+
})();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
await new Promise<void>((ok, fail) => server.once("error", fail).listen(options.port ?? 4280, hostname, ok));
|
|
428
|
+
const { port } = server.address() as AddressInfo;
|
|
429
|
+
return {
|
|
430
|
+
url: `http://${hostname}:${port}`,
|
|
431
|
+
machines,
|
|
432
|
+
async close() {
|
|
433
|
+
await machines.close();
|
|
434
|
+
await new Promise<void>((ok) => server.close(() => ok()));
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { spriteCreate, spriteDelete, spriteExec } from "./sprites";
|
|
3
|
+
import { spritesContainerUp, spritesContainerDown } from "./sprites-emulator";
|
|
4
|
+
|
|
5
|
+
// Acceptance (#2765) against the REAL spritzer image in container exec mode
|
|
6
|
+
// (SPRITZER_EXEC=container, SPRITZER_RUNTIME=docker — INTENTIUS/spritzer#22,
|
|
7
|
+
// spritzer 0.6.0's CHANGELOG: "Exec runs the real command ... with stdin, env
|
|
8
|
+
// and dir"). Interpreter-mode spritzer (the default `sprites.docker.integration.test.ts`
|
|
9
|
+
// runs against) has no notion of `env`/`dir` at all — only container mode runs
|
|
10
|
+
// a real process, so it is the only place `env`/`dir` reaching the command can
|
|
11
|
+
// be proven against spritzer rather than the in-process fake.
|
|
12
|
+
// `sprite-services.docker.integration.test.ts` runs the same container-mode
|
|
13
|
+
// suite for the Services activities; this file is the `spriteExec` twin.
|
|
14
|
+
// Docker required; deterministically skipped in CI (GitHub runners have
|
|
15
|
+
// Docker, so relying on absence would pull the image on every run) — run
|
|
16
|
+
// locally, or opt in with SPRITES_DOCKER=1, same convention as the other
|
|
17
|
+
// docker tests here.
|
|
18
|
+
|
|
19
|
+
const CONTAINER = "chant-spritzer-exec-opts-it";
|
|
20
|
+
const PORT = 4295;
|
|
21
|
+
const SPRITE = "chant-2765-exec";
|
|
22
|
+
|
|
23
|
+
let available = false;
|
|
24
|
+
let prevBaseUrl: string | undefined;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
if (process.env.CI && !process.env.SPRITES_DOCKER) {
|
|
28
|
+
available = false;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const up = await spritesContainerUp({ name: CONTAINER, port: PORT, timeoutMs: 60_000 });
|
|
33
|
+
prevBaseUrl = process.env.SPRITES_BASE_URL;
|
|
34
|
+
process.env.SPRITES_BASE_URL = up.endpoint;
|
|
35
|
+
available = true;
|
|
36
|
+
} catch {
|
|
37
|
+
available = false;
|
|
38
|
+
}
|
|
39
|
+
}, 90_000);
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
|
|
43
|
+
else process.env.SPRITES_BASE_URL = prevBaseUrl;
|
|
44
|
+
if (available) {
|
|
45
|
+
await spriteDelete({ id: SPRITE }).catch(() => {});
|
|
46
|
+
await spritesContainerDown({ name: CONTAINER });
|
|
47
|
+
}
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
describe("spriteExec env/dir/timeoutMs against real spritzer 0.6.1, container mode (#2765)", () => {
|
|
51
|
+
test("env reaches the command", async (ctx) => {
|
|
52
|
+
if (!available) ctx.skip();
|
|
53
|
+
await spriteCreate({ name: SPRITE });
|
|
54
|
+
const res = await spriteExec({ id: SPRITE, cmd: "env", env: { CHANT_2765: "hi" } });
|
|
55
|
+
expect(res.stdout).toMatch(/^CHANT_2765=hi$/m);
|
|
56
|
+
}, 30_000);
|
|
57
|
+
|
|
58
|
+
test("dir reaches the command", async (ctx) => {
|
|
59
|
+
if (!available) ctx.skip();
|
|
60
|
+
const res = await spriteExec({ id: SPRITE, cmd: "pwd", dir: "/tmp" });
|
|
61
|
+
expect(res.stdout.trim()).toBe("/tmp");
|
|
62
|
+
}, 30_000);
|
|
63
|
+
|
|
64
|
+
test("timeoutMs aborts a real hung command", async (ctx) => {
|
|
65
|
+
if (!available) ctx.skip();
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
await expect(spriteExec({ id: SPRITE, cmd: "sleep 30", timeoutMs: 500 })).rejects.toThrow(/timed out/);
|
|
68
|
+
expect(Date.now() - started).toBeLessThan(15_000);
|
|
69
|
+
}, 30_000);
|
|
70
|
+
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
spriteListDir as listImpl,
|
|
20
20
|
spriteRemove as removeImpl,
|
|
21
21
|
spriteFsUrl,
|
|
22
|
+
defaultSpritesRawHttp,
|
|
22
23
|
} from "./sprite-fs";
|
|
23
24
|
|
|
24
25
|
// Filesystem activities (#848) end-to-end against the in-process fake (S7) — no
|
|
@@ -63,6 +64,60 @@ describe("spriteFsUrl (pure)", () => {
|
|
|
63
64
|
});
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
describe("defaultSpritesRawHttp bearer header (#2765)", () => {
|
|
68
|
+
test("sends Authorization: Bearer <token> when a token is set", async () => {
|
|
69
|
+
const seen: Array<Record<string, string> | undefined> = [];
|
|
70
|
+
const fakeFetch = (async (_url: string, init: { headers?: Record<string, string> }) => {
|
|
71
|
+
seen.push(init.headers);
|
|
72
|
+
return { status: 200, text: async () => "" } as unknown as Response;
|
|
73
|
+
}) as unknown as typeof fetch;
|
|
74
|
+
const http = defaultSpritesRawHttp("secret-token", fakeFetch);
|
|
75
|
+
await http("GET", "http://x/v1/sprites/task-1/fs/read");
|
|
76
|
+
expect(seen[0]?.authorization).toBe("Bearer secret-token");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("SPRITE_TOKEN alias authenticates when SPRITES_API_TOKEN is unset, through the shared resolver", async () => {
|
|
80
|
+
const seen: Array<Record<string, string> | undefined> = [];
|
|
81
|
+
const fakeFetch = (async (_url: string, init: { headers?: Record<string, string> }) => {
|
|
82
|
+
seen.push(init.headers);
|
|
83
|
+
return { status: 200, text: async () => "" } as unknown as Response;
|
|
84
|
+
}) as unknown as typeof fetch;
|
|
85
|
+
const prevApiToken = process.env.SPRITES_API_TOKEN;
|
|
86
|
+
const prevAlias = process.env.SPRITE_TOKEN;
|
|
87
|
+
delete process.env.SPRITES_API_TOKEN;
|
|
88
|
+
process.env.SPRITE_TOKEN = "alias-token";
|
|
89
|
+
try {
|
|
90
|
+
const http = defaultSpritesRawHttp(undefined, fakeFetch);
|
|
91
|
+
await http("GET", "http://x/v1/sprites/task-1/fs/read");
|
|
92
|
+
expect(seen[0]?.authorization).toBe("Bearer alias-token");
|
|
93
|
+
} finally {
|
|
94
|
+
if (prevApiToken !== undefined) process.env.SPRITES_API_TOKEN = prevApiToken;
|
|
95
|
+
if (prevAlias !== undefined) process.env.SPRITE_TOKEN = prevAlias;
|
|
96
|
+
else delete process.env.SPRITE_TOKEN;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("no Authorization header when no token is set", async () => {
|
|
101
|
+
const seen: Array<Record<string, string> | undefined> = [];
|
|
102
|
+
const fakeFetch = (async (_url: string, init: { headers?: Record<string, string> }) => {
|
|
103
|
+
seen.push(init?.headers);
|
|
104
|
+
return { status: 200, text: async () => "" } as unknown as Response;
|
|
105
|
+
}) as unknown as typeof fetch;
|
|
106
|
+
const prevApiToken = process.env.SPRITES_API_TOKEN;
|
|
107
|
+
const prevAlias = process.env.SPRITE_TOKEN;
|
|
108
|
+
delete process.env.SPRITES_API_TOKEN;
|
|
109
|
+
delete process.env.SPRITE_TOKEN;
|
|
110
|
+
try {
|
|
111
|
+
const http = defaultSpritesRawHttp(undefined, fakeFetch);
|
|
112
|
+
await http("GET", "http://x/v1/sprites/task-1/fs/read");
|
|
113
|
+
expect(seen[0]?.authorization).toBeUndefined();
|
|
114
|
+
} finally {
|
|
115
|
+
if (prevApiToken !== undefined) process.env.SPRITES_API_TOKEN = prevApiToken;
|
|
116
|
+
if (prevAlias !== undefined) process.env.SPRITE_TOKEN = prevAlias;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
66
121
|
describe("fs activities resolve by name", () => {
|
|
67
122
|
test("loadActivities([\"fly\"]) exposes the four fs activities", () => {
|
|
68
123
|
for (const fn of ["spriteWriteFile", "spriteReadFile", "spriteListDir", "spriteRemove"]) {
|
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* read/write move raw file bytes in the body (not JSON), so these use a small
|
|
9
9
|
* raw HTTP client (`SpritesRawHttp`) rather than the JSON `SpritesHttp` the
|
|
10
10
|
* lifecycle activities use; path/mode/mkdir/recursive ride as query params.
|
|
11
|
-
* Endpoint + bearer resolution
|
|
12
|
-
* `
|
|
11
|
+
* Endpoint + bearer resolution go through `sprites.ts`'s shared resolvers
|
|
12
|
+
* (`resolveSpritesEndpoint`/`resolveSpritesToken`) — an explicit `endpoint` /
|
|
13
|
+
* `token` wins, then `SPRITES_BASE_URL`/`SPRITES_API_URL`, then
|
|
14
|
+
* `SPRITES_API_TOKEN`/its `SPRITE_TOKEN` alias (#2711, #2765: this module
|
|
15
|
+
* used to read `SPRITES_API_TOKEN` directly, missing the alias the other
|
|
16
|
+
* Sprites activities honor).
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
import { resolveSpritesEndpoint } from "./sprites";
|
|
19
|
+
import { resolveSpritesEndpoint, resolveSpritesToken } from "./sprites";
|
|
16
20
|
|
|
17
21
|
function safeJson(text: string): unknown {
|
|
18
22
|
try {
|
|
@@ -62,13 +66,18 @@ export type SpritesRawHttp = (
|
|
|
62
66
|
* Default `fetch`-based raw client. Sends the body as-is with an
|
|
63
67
|
* `application/octet-stream` content-type and `Authorization: Bearer <token>`
|
|
64
68
|
* when a token is set (real Sprites); the fake ignores the token. The token
|
|
65
|
-
*
|
|
69
|
+
* resolves through `resolveSpritesToken` at call time — `SPRITES_API_TOKEN`,
|
|
70
|
+
* then its `SPRITE_TOKEN` alias (#2711, #2765) — the same resolver
|
|
71
|
+
* `sprites.ts`'s `defaultSpritesHttp` uses, so every fs activity
|
|
72
|
+
* (`spriteWriteFile`, `spriteReadFile`, `spriteListDir`, `spriteRemove`)
|
|
73
|
+
* authenticates the same way the lifecycle activities do. `fetchImpl` is
|
|
74
|
+
* injectable.
|
|
66
75
|
*/
|
|
67
76
|
export function defaultSpritesRawHttp(token?: string, fetchImpl: typeof fetch = fetch): SpritesRawHttp {
|
|
68
77
|
return async (method, url, body, headers, signal) => {
|
|
69
78
|
const h: Record<string, string> = { ...headers };
|
|
70
79
|
if (body !== undefined) h["content-type"] = "application/octet-stream";
|
|
71
|
-
const tok = token
|
|
80
|
+
const tok = resolveSpritesToken(token);
|
|
72
81
|
if (tok) h["authorization"] = `Bearer ${tok}`;
|
|
73
82
|
const res = await fetchImpl(url, {
|
|
74
83
|
method,
|