@intentius/chant-lexicon-fly 0.101.0 → 0.104.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/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/package.d.ts +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +17 -1
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/composites/fly-deploy.d.ts +2 -0
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/composites/fly-site.d.ts +2 -2
- package/dist/generated/index.d.ts +41 -41
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/manifest.json +1 -1
- package/dist/okf/types/Secret.md +1 -0
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/fly-apply.d.ts +87 -8
- package/dist/op/activities/fly-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts.map +1 -1
- package/dist/op/activities/machines-fake.d.ts +10 -1
- package/dist/op/activities/machines-fake.d.ts.map +1 -1
- package/dist/op/activities/machines-local.d.ts.map +1 -1
- package/dist/op/activities/progress.d.ts +9 -0
- package/dist/op/activities/progress.d.ts.map +1 -0
- package/dist/op/activities/sprite-config.d.ts.map +1 -1
- package/dist/op/activities/sprite-fs.d.ts.map +1 -1
- package/dist/op/activities/sprite-service-converge.d.ts.map +1 -1
- package/dist/op/activities/sprite-services.d.ts.map +1 -1
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
- package/dist/op/activities/sprites.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -10
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/release-metadata.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-fly-ops.md +8 -0
- package/dist/skills/chant-fly-patterns.md +1 -1
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/package.json +3 -3
- package/src/codegen/docs.ts +32 -1
- package/src/components/fly-release.test.ts +36 -0
- package/src/components/fly-release.ts +37 -3
- package/src/composites/fly-deploy.ts +2 -0
- package/src/describe-resources.test.ts +18 -1
- package/src/describe-resources.ts +1 -1
- package/src/generated/index.d.ts +2 -1
- package/src/generated/index.ts +1 -1
- package/src/op/activities/fly-apply.integration.test.ts +12 -5
- package/src/op/activities/fly-apply.test.ts +483 -2
- package/src/op/activities/fly-apply.ts +229 -45
- package/src/op/activities/index.ts +27 -1
- package/src/op/activities/machines-contract.docker.integration.test.ts +22 -1
- package/src/op/activities/machines-contract.ts +5 -1
- package/src/op/activities/machines-fake.ts +69 -2
- package/src/op/activities/machines-local-cli.ts +3 -0
- package/src/op/activities/machines-local.test.ts +48 -12
- package/src/op/activities/machines-local.ts +28 -5
- package/src/op/activities/progress.ts +10 -0
- package/src/op/activities/sprite-config.ts +10 -9
- package/src/op/activities/sprite-fs.ts +3 -2
- package/src/op/activities/sprite-progress.test.ts +52 -0
- package/src/op/activities/sprite-service-converge.ts +2 -1
- package/src/op/activities/sprite-services.ts +5 -4
- package/src/op/activities/sprites.ts +6 -5
- package/src/serializer-telemetry.test.ts +80 -0
- package/src/serializer.test.ts +17 -2
- package/src/serializer.ts +58 -8
- package/src/skills/chant-fly-ops.md +8 -0
- package/src/skills/chant-fly-patterns.md +1 -1
- package/src/spec/parse.ts +25 -2
- package/src/teardown.test.ts +22 -3
- package/src/teardown.ts +1 -1
- package/src/telemetry-build.test.ts +75 -0
|
@@ -35,6 +35,8 @@ export interface MachinesFake {
|
|
|
35
35
|
http: FlyHttp;
|
|
36
36
|
apps: Set<string>;
|
|
37
37
|
machines: Map<string, FakeMachine[]>;
|
|
38
|
+
/** Each app's certificate hostnames, in creation order. */
|
|
39
|
+
certs: Map<string, string[]>;
|
|
38
40
|
/** Every exec, in order: the app, the machine id and the command. */
|
|
39
41
|
execs: Array<{ app: string; id: string; command: string[] }>;
|
|
40
42
|
/** Every call, as `METHOD path` (no base, no query). */
|
|
@@ -52,13 +54,28 @@ export interface MachinesFakeOptions {
|
|
|
52
54
|
|
|
53
55
|
const json = (status: number, body: unknown) => ({ status, text: body === undefined ? "" : JSON.stringify(body) });
|
|
54
56
|
|
|
57
|
+
/** Fly's certificate-list page size: 25 when no `limit` is given, 500 at most. */
|
|
58
|
+
export const CERT_PAGE_DEFAULT = 25;
|
|
59
|
+
export const CERT_PAGE_MAX = 500;
|
|
60
|
+
// The fake's cursor is an offset in disguise; callers must treat it as opaque.
|
|
61
|
+
const offsetCursor = (offset: number): string => Buffer.from(`certs:${offset}`).toString("base64url");
|
|
62
|
+
const cursorOffset = (cursor: string): number | undefined => {
|
|
63
|
+
const m = /^certs:(\d+)$/.exec(Buffer.from(cursor, "base64url").toString());
|
|
64
|
+
return m ? Number(m[1]) : undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
55
67
|
let seq = 0;
|
|
56
68
|
const nextId = (prefix: string) => `${prefix}${(++seq).toString(16).padStart(10, "0")}`;
|
|
57
69
|
|
|
58
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* A fresh in-memory flaps. Like the real API, a bare DELETE on a machine that
|
|
72
|
+
* is not stopped, suspended, failed or created answers 412; `?force=true`
|
|
73
|
+
* destroys it in any state.
|
|
74
|
+
*/
|
|
59
75
|
export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesFake {
|
|
60
76
|
const apps = new Set<string>();
|
|
61
77
|
const machines = new Map<string, FakeMachine[]>();
|
|
78
|
+
const certs = new Map<string, string[]>();
|
|
62
79
|
const execs: MachinesFake["execs"] = [];
|
|
63
80
|
const calls: string[] = [];
|
|
64
81
|
const settle = options.settle ?? (() => "started");
|
|
@@ -88,6 +105,7 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
|
|
|
88
105
|
if (method === "DELETE") {
|
|
89
106
|
apps.delete(app);
|
|
90
107
|
machines.delete(app);
|
|
108
|
+
certs.delete(app);
|
|
91
109
|
return json(202, undefined);
|
|
92
110
|
}
|
|
93
111
|
}
|
|
@@ -123,6 +141,13 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
|
|
|
123
141
|
return json(200, m);
|
|
124
142
|
}
|
|
125
143
|
if (method === "DELETE") {
|
|
144
|
+
// Real flaps destroys a running machine only with ?force=true and
|
|
145
|
+
// answers a bare DELETE with 412 (#3115). mudflaps does not (yet).
|
|
146
|
+
if (searchParams.get("force") !== "true" && !["stopped", "suspended", "failed", "created"].includes(m.state)) {
|
|
147
|
+
return json(412, {
|
|
148
|
+
error: "failed_precondition: unable to destroy machine, not currently stopped, suspended, failed or created",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
126
151
|
machines.set(app, list(app).filter((x) => x !== m));
|
|
127
152
|
return json(200, { ok: true });
|
|
128
153
|
}
|
|
@@ -151,15 +176,57 @@ export function createMachinesFake(options: MachinesFakeOptions = {}): MachinesF
|
|
|
151
176
|
return json(404, { error: `no ${method} ${action}` });
|
|
152
177
|
}
|
|
153
178
|
if (kind === "ip_assignments") return method === "GET" ? json(200, { ips: [] }) : json(200, {});
|
|
154
|
-
if (kind === "certificates") return
|
|
179
|
+
if (kind === "certificates") return certificates(app, method, seg.slice(4), b, searchParams);
|
|
155
180
|
if (kind === "volumes" || kind === "secrets") return method === "GET" ? json(200, []) : json(200, {});
|
|
156
181
|
return json(404, { error: "not found" });
|
|
157
182
|
};
|
|
158
183
|
|
|
184
|
+
// Certificates, as the real API routes them (#3114): listed at
|
|
185
|
+
// `.../certificates`, created only at `.../certificates/acme`, read and
|
|
186
|
+
// deleted at `.../certificates/{hostname}`. A POST to the bare list path is
|
|
187
|
+
// the 404 flaps gives, so an applier that posts there fails here too.
|
|
188
|
+
// The list pages like Fly's (#3224): `limit` defaults to 25 and is capped at
|
|
189
|
+
// 500, and `next_cursor` is present while certificates remain. mudflaps
|
|
190
|
+
// returns the whole list in one page (INTENTIUS/mudflaps#71).
|
|
191
|
+
function certificates(app: string, method: string, rest: string[], b: Record<string, unknown>, q: URLSearchParams) {
|
|
192
|
+
const hostnames = certs.get(app) ?? [];
|
|
193
|
+
const detail = (hostname: string) => ({ hostname, acme_requested: true, configured: false, status: "pending" });
|
|
194
|
+
if (rest.length === 0) {
|
|
195
|
+
if (method !== "GET") return { status: 404, text: "404 page not found" };
|
|
196
|
+
const limit = Math.min(CERT_PAGE_MAX, Math.max(1, Number(q.get("limit")) || CERT_PAGE_DEFAULT));
|
|
197
|
+
const cursor = q.get("cursor");
|
|
198
|
+
const from = cursor === null ? 0 : cursorOffset(cursor);
|
|
199
|
+
if (from === undefined) return json(400, { error: `invalid cursor ${cursor}` });
|
|
200
|
+
const page = hostnames.slice(from, from + limit);
|
|
201
|
+
const more = from + limit < hostnames.length;
|
|
202
|
+
return json(200, {
|
|
203
|
+
certificates: page.map(detail),
|
|
204
|
+
...(more ? { next_cursor: offsetCursor(from + limit) } : {}),
|
|
205
|
+
total_count: hostnames.length,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (rest.length === 1 && rest[0] === "acme" && method === "POST") {
|
|
209
|
+
const hostname = typeof b.hostname === "string" ? b.hostname : "";
|
|
210
|
+
if (!hostname) return json(422, { error: "hostname is required" });
|
|
211
|
+
if (hostnames.includes(hostname)) return json(422, { error: `certificate ${hostname} already exists` });
|
|
212
|
+
certs.set(app, [...hostnames, hostname]);
|
|
213
|
+
return json(201, detail(hostname));
|
|
214
|
+
}
|
|
215
|
+
if (rest.length === 1 && (method === "GET" || method === "DELETE")) {
|
|
216
|
+
const hostname = rest[0];
|
|
217
|
+
if (!hostnames.includes(hostname)) return json(404, { error: "certificate not found" });
|
|
218
|
+
if (method === "GET") return json(200, detail(hostname));
|
|
219
|
+
certs.set(app, hostnames.filter((h) => h !== hostname));
|
|
220
|
+
return json(204, undefined);
|
|
221
|
+
}
|
|
222
|
+
return { status: 404, text: "404 page not found" };
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
return {
|
|
160
226
|
http,
|
|
161
227
|
apps,
|
|
162
228
|
machines,
|
|
229
|
+
certs,
|
|
163
230
|
execs,
|
|
164
231
|
calls,
|
|
165
232
|
machine: (app, name) => list(app).find((m) => m.name === name && !["destroyed", "destroying"].includes(m.state)),
|
|
@@ -51,6 +51,9 @@ async function main(): Promise<void> {
|
|
|
51
51
|
console.error("usage: machines-local-cli.ts [--listen <port>] [--root <dir>] [--publish <port> | --publish <name>=<port>]... [--log <file>]");
|
|
52
52
|
process.exit(2);
|
|
53
53
|
}
|
|
54
|
+
// A standalone server, not an Op activity: its stdout is its output. The flaps URL and the
|
|
55
|
+
// event lines (which name each Machine's published port) stay there for the person or
|
|
56
|
+
// script that started it; errors and usage go to stderr.
|
|
54
57
|
const served = await serveLocalMachines({ ...args, handleSignals: false, onEvent: (line) => console.log(`machines-local: ${line}`) });
|
|
55
58
|
console.log(`machines-local: flaps on ${served.url}${args.root ? `, guest paths under ${args.root}` : ""}`);
|
|
56
59
|
let closing = false;
|
|
@@ -109,11 +109,12 @@ describe("the running mode of the local Machines API (#2831)", () => {
|
|
|
109
109
|
expect(existsSync(join(root, "srv/app/page.txt"))).toBe(false);
|
|
110
110
|
expect(readFileSync(join(root, "data/state"), "utf8")).toBe("migrated\n");
|
|
111
111
|
|
|
112
|
-
// Start, then delete: running again, then gone.
|
|
112
|
+
// Start, then a forced delete: running again, then gone. A bare delete of a
|
|
113
|
+
// started Machine is refused, as Fly refuses it (#3115).
|
|
113
114
|
await call("POST", `/v1/apps/shop/machines/${id}/start`);
|
|
114
115
|
const [fourth] = machines.processes();
|
|
115
116
|
expect(await get(url!)).toBe("B migrated");
|
|
116
|
-
await call("DELETE", `/v1/apps/shop/machines/${id}`);
|
|
117
|
+
await call("DELETE", `/v1/apps/shop/machines/${id}?force=true`);
|
|
117
118
|
expect(machines.processes()).toEqual([]);
|
|
118
119
|
expect(alive(fourth.pid!)).toBe(false);
|
|
119
120
|
});
|
|
@@ -165,6 +166,30 @@ describe("the running mode of the local Machines API (#2831)", () => {
|
|
|
165
166
|
expect(JSON.parse((await machines.http("GET", `http://f/v1/apps/shop/machines/${id}/wait?state=started`)).text)).toEqual({ ok: false });
|
|
166
167
|
});
|
|
167
168
|
|
|
169
|
+
test("an exec that times out while its command is forking leaves nothing behind", { timeout: 30_000 }, async () => {
|
|
170
|
+
const machines = createLocalMachines({ root: tmp(), log: join(tmp(), "log") });
|
|
171
|
+
open.push(machines);
|
|
172
|
+
await machines.http("POST", "http://f/v1/apps", { app_name: "shop" });
|
|
173
|
+
const r = await machines.http("POST", "http://f/v1/apps/shop/machines", { name: "web", config: {} });
|
|
174
|
+
const id = JSON.parse(r.text).id as string;
|
|
175
|
+
// Forks a long sleep and kills it, over and over: a child that one group kill misses survives (#3202),
|
|
176
|
+
// and it holds the exec's output pipes, so the exec would not answer until it ended.
|
|
177
|
+
const marker = `299.${process.pid}${Math.floor(Math.random() * 1e6)}`;
|
|
178
|
+
try {
|
|
179
|
+
const out = await machines.http("POST", `http://f/v1/apps/shop/machines/${id}/exec`, {
|
|
180
|
+
command: ["sh", "-c", `while :; do sleep ${marker} & kill $!; done`],
|
|
181
|
+
timeout: 1,
|
|
182
|
+
});
|
|
183
|
+
expect(JSON.parse(out.text).exit_code).not.toBe(0);
|
|
184
|
+
const deadline = Date.now() + 10_000;
|
|
185
|
+
const left = () => spawnSync("pgrep", ["-f", `sleep ${marker}`], { encoding: "utf8" }).stdout.trim();
|
|
186
|
+
while (left() && Date.now() < deadline) await new Promise((ok) => setTimeout(ok, 50));
|
|
187
|
+
expect(left()).toBe("");
|
|
188
|
+
} finally {
|
|
189
|
+
spawnSync("pkill", ["-KILL", "-f", `sleep ${marker}`]);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
168
193
|
test("with no root, guest paths are host paths", async () => {
|
|
169
194
|
const dir = tmp();
|
|
170
195
|
const machines = createLocalMachines({ log: join(dir, "log") });
|
|
@@ -177,6 +202,10 @@ describe("the running mode of the local Machines API (#2831)", () => {
|
|
|
177
202
|
expect(readFileSync(join(dir, "a/b.txt"), "utf8")).toBe("hi");
|
|
178
203
|
});
|
|
179
204
|
|
|
205
|
+
/** Forks children for a while, then becomes one: the host is ended while it is still forking. */
|
|
206
|
+
const FORKS = "i=0; while [ $i -lt 100 ]; do sleep 300 & i=$((i+1)); done; exec sleep 301";
|
|
207
|
+
/** The live (not zombie) members of a process group, as pgrep lists them. */
|
|
208
|
+
const group = (pgid: number) => spawnSync("pgrep", ["-g", String(pgid)], { encoding: "utf8" }).stdout.trim();
|
|
180
209
|
for (const how of ["SIGTERM", "SIGINT", "exit"] as const) {
|
|
181
210
|
test(`a Machine's process does not outlive its host process (${how})`, { timeout: 60_000 }, async () => {
|
|
182
211
|
const root = tmp();
|
|
@@ -186,7 +215,7 @@ describe("the running mode of the local Machines API (#2831)", () => {
|
|
|
186
215
|
`import { createLocalMachines } from ${JSON.stringify(join(here, "machines-local.ts"))};
|
|
187
216
|
const m = createLocalMachines({ root: ${JSON.stringify(root)}, log: ${JSON.stringify(join(root, "log"))} });
|
|
188
217
|
await m.http("POST", "http://f/v1/apps", { app_name: "shop" });
|
|
189
|
-
await m.http("POST", "http://f/v1/apps/shop/machines", { name: "web", config: { init: { cmd: ["sh", "-c",
|
|
218
|
+
await m.http("POST", "http://f/v1/apps/shop/machines", { name: "web", config: { init: { cmd: ["sh", "-c", ${JSON.stringify(FORKS)}] } } });
|
|
190
219
|
console.log(JSON.stringify(m.processes()[0].pid));
|
|
191
220
|
${how === "exit" ? "setTimeout(() => process.exit(0), 100);" : "setInterval(() => undefined, 1000);"}
|
|
192
221
|
`,
|
|
@@ -197,15 +226,22 @@ ${how === "exit" ? "setTimeout(() => process.exit(0), 100);" : "setInterval(() =
|
|
|
197
226
|
env: { ...process.env, TSX_DISABLE_CACHE: "1" },
|
|
198
227
|
});
|
|
199
228
|
const pid = await new Promise<number>((ok) => host.stdout!.once("data", (d) => ok(Number(String(d).trim()))));
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
229
|
+
try {
|
|
230
|
+
expect(alive(pid)).toBe(true);
|
|
231
|
+
const exited = new Promise((ok) => host.once("exit", ok));
|
|
232
|
+
// Sent while the command is still forking, which is when a single group kill can miss a child (#3202).
|
|
233
|
+
if (how !== "exit") host.kill(how);
|
|
234
|
+
await exited;
|
|
235
|
+
// Killed processes still take a moment to go (and the leader to be reaped), more so on a loaded host, so poll.
|
|
236
|
+
// Anything left after the deadline was never killed: a missed child sleeps for 300s.
|
|
237
|
+
const deadline = Date.now() + 10_000;
|
|
238
|
+
while ((alive(pid) || group(pid)) && Date.now() < deadline) await new Promise((ok) => setTimeout(ok, 50));
|
|
239
|
+
expect(alive(pid)).toBe(false);
|
|
240
|
+
// Nor does anything the command started in its group.
|
|
241
|
+
expect(group(pid)).toBe("");
|
|
242
|
+
} finally {
|
|
243
|
+
spawnSync("pkill", ["-KILL", "-g", String(pid)]);
|
|
244
|
+
}
|
|
209
245
|
});
|
|
210
246
|
}
|
|
211
247
|
});
|
|
@@ -130,6 +130,27 @@ function killGroup(child: ChildProcess | undefined, signal: NodeJS.Signals): voi
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* SIGKILL a process group until nothing in it is left to kill. One kill(-pgid)
|
|
135
|
+
* can miss a child that a member is forking at that moment: the child joins the
|
|
136
|
+
* group after the signal went out, and then outlives the rest (seen on macOS,
|
|
137
|
+
* #3202). Sending again once the forker is dead catches it. Synchronous, so it
|
|
138
|
+
* works in an "exit" handler; it stops as soon as the group is empty (or only
|
|
139
|
+
* zombies), and gives up after about 100ms.
|
|
140
|
+
*/
|
|
141
|
+
function killGroupNow(child: ChildProcess | undefined): void {
|
|
142
|
+
if (!child?.pid) return;
|
|
143
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
144
|
+
for (let i = 0; i < 20; i++) {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(-child.pid, "SIGKILL");
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
Atomics.wait(pause, 0, 0, 5);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
133
154
|
async function freePort(): Promise<number> {
|
|
134
155
|
const srv = createNetServer();
|
|
135
156
|
await new Promise<void>((ok, fail) => srv.once("error", fail).listen(0, "127.0.0.1", ok));
|
|
@@ -225,9 +246,10 @@ export function createLocalMachines(options: LocalMachinesOptions = {}): LocalMa
|
|
|
225
246
|
// Wait for the leader to exit and the rest of its group to go.
|
|
226
247
|
await Promise.race([exited, new Promise((ok) => setTimeout(ok, stopTimeoutMs))]);
|
|
227
248
|
while (groupAlive(child.pid) && Date.now() < deadline) await new Promise((ok) => setTimeout(ok, 25));
|
|
228
|
-
|
|
249
|
+
// Sent again each round: one group kill can miss a child being forked as it lands (see killGroupNow).
|
|
250
|
+
while (groupAlive(child.pid)) {
|
|
229
251
|
killGroup(child, "SIGKILL");
|
|
230
|
-
|
|
252
|
+
await new Promise((ok) => setTimeout(ok, 25));
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
for (const path of r.files) rmSync(path, { force: true });
|
|
@@ -266,7 +288,7 @@ export function createLocalMachines(options: LocalMachinesOptions = {}): LocalMa
|
|
|
266
288
|
if (r.stopping) return;
|
|
267
289
|
say(`${key} instance ${m.instance_id} exited ${signal ?? code}`);
|
|
268
290
|
// A process that ends on its own leaves its Machine stopped, and its group is killed with it.
|
|
269
|
-
|
|
291
|
+
killGroupNow(child);
|
|
270
292
|
if (m.instance_id === r.instance && m.state === "started") m.state = "stopped";
|
|
271
293
|
});
|
|
272
294
|
say(`started ${key} instance ${m.instance_id}: ${files.length} files, ${argv.join(" ")}${port ? `, on 127.0.0.1:${port}` : ""}`);
|
|
@@ -287,7 +309,8 @@ export function createLocalMachines(options: LocalMachinesOptions = {}): LocalMa
|
|
|
287
309
|
let stderr = "";
|
|
288
310
|
child.stdout!.on("data", (d) => (stdout += d));
|
|
289
311
|
child.stderr!.on("data", (d) => (stderr += d));
|
|
290
|
-
|
|
312
|
+
// killGroupNow: a child missed by one group kill would hold the output pipes, and "close" would wait for it.
|
|
313
|
+
const timer = setTimeout(() => killGroupNow(child), timeoutSecs * 1000);
|
|
291
314
|
child.once("error", (error) => {
|
|
292
315
|
clearTimeout(timer);
|
|
293
316
|
done({ exit_code: 127, stdout, stderr: `${stderr}${error.message}` });
|
|
@@ -347,7 +370,7 @@ export function createLocalMachines(options: LocalMachinesOptions = {}): LocalMa
|
|
|
347
370
|
const killAll = () => {
|
|
348
371
|
for (const r of running.values()) {
|
|
349
372
|
r.stopping = true;
|
|
350
|
-
|
|
373
|
+
killGroupNow(r.child);
|
|
351
374
|
}
|
|
352
375
|
};
|
|
353
376
|
const onSignal = (sig: NodeJS.Signals) => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress output for the fly Op activities (#2516, #3200).
|
|
3
|
+
*
|
|
4
|
+
* An Op step's stdout is its output, so activities write their progress lines
|
|
5
|
+
* ("created: sprite/x", "pruned: ...") to stderr and keep stdout free for
|
|
6
|
+
* machine-readable results.
|
|
7
|
+
*/
|
|
8
|
+
export function logProgress(line: string): void {
|
|
9
|
+
process.stderr.write(`${line}\n`);
|
|
10
|
+
}
|
|
@@ -27,6 +27,7 @@ import { promisify } from "node:util";
|
|
|
27
27
|
import { expandServiceCommand, inStartOrder, boxServices, type BoxServiceDeclaration } from "./box-services";
|
|
28
28
|
import { findSpriteEnv } from "./sprite-service-converge";
|
|
29
29
|
import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
|
|
30
|
+
import { logProgress } from "./progress";
|
|
30
31
|
|
|
31
32
|
const execFileAsync = promisify(execFile);
|
|
32
33
|
|
|
@@ -101,13 +102,13 @@ export async function spriteApplyNetworkPolicy(
|
|
|
101
102
|
if (cur.status >= 300) throw new Error(`sprite ${args.id} get policy failed (${cur.status}): ${cur.text}`);
|
|
102
103
|
const live = (safeJson(cur.text) as { rules?: NetworkRule[] } | undefined)?.rules ?? [];
|
|
103
104
|
if (networkRulesEqual(live, args.rules)) {
|
|
104
|
-
|
|
105
|
+
logProgress(`policy: sprite/${args.id} already converged (${args.rules.length} rules)`);
|
|
105
106
|
return { changed: false };
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const res = await http("POST", url, { rules: args.rules }, undefined, signal);
|
|
109
110
|
if (res.status >= 300) throw new Error(`sprite ${args.id} set policy failed (${res.status}): ${res.text}`);
|
|
110
|
-
|
|
111
|
+
logProgress(`policy: sprite/${args.id} applied ${args.rules.length} rules (${base})`);
|
|
111
112
|
return { changed: true };
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -276,7 +277,7 @@ async function applyThroughApi(
|
|
|
276
277
|
if (res.status >= 300) throw new Error(`sprite ${args.id} apply service ${s.name} failed (${res.status}): ${res.text}`);
|
|
277
278
|
applied.push(s.name);
|
|
278
279
|
}
|
|
279
|
-
|
|
280
|
+
logProgress(`services: sprite/${args.id} applied ${applied.length}/${args.services.length} (${base})`);
|
|
280
281
|
|
|
281
282
|
const started: string[] = [];
|
|
282
283
|
if (args.start) {
|
|
@@ -286,7 +287,7 @@ async function applyThroughApi(
|
|
|
286
287
|
if (res.status >= 300) throw new Error(`sprite ${args.id} start service ${name} failed (${res.status}): ${res.text}`);
|
|
287
288
|
started.push(name);
|
|
288
289
|
}
|
|
289
|
-
|
|
290
|
+
logProgress(`services: sprite/${args.id} started ${started.length} in dependency order`);
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
return { applied, started };
|
|
@@ -419,7 +420,7 @@ async function applyThroughSpriteEnv(args: SpriteApplyServicesArgs, signal?: Abo
|
|
|
419
420
|
const cur = live.get(s.name);
|
|
420
421
|
if (!cur) {
|
|
421
422
|
await spriteEnv(spriteEnvCreateArgs(s, cmd));
|
|
422
|
-
|
|
423
|
+
logProgress(`services: created ${s.name}`);
|
|
423
424
|
services.push({ name: s.name, action: "created" });
|
|
424
425
|
continue;
|
|
425
426
|
}
|
|
@@ -427,13 +428,13 @@ async function applyThroughSpriteEnv(args: SpriteApplyServicesArgs, signal?: Abo
|
|
|
427
428
|
if (why) {
|
|
428
429
|
await spriteEnv(["services", "delete", s.name]);
|
|
429
430
|
await spriteEnv(spriteEnvCreateArgs(s, cmd));
|
|
430
|
-
|
|
431
|
+
logProgress(`services: replaced ${s.name} (${why})`);
|
|
431
432
|
services.push({ name: s.name, action: "replaced" });
|
|
432
433
|
continue;
|
|
433
434
|
}
|
|
434
435
|
if (args.restart) {
|
|
435
436
|
await spriteEnv(["services", "restart", s.name]);
|
|
436
|
-
|
|
437
|
+
logProgress(`services: restarted ${s.name}`);
|
|
437
438
|
services.push({ name: s.name, action: "restarted" });
|
|
438
439
|
continue;
|
|
439
440
|
}
|
|
@@ -445,12 +446,12 @@ async function applyThroughSpriteEnv(args: SpriteApplyServicesArgs, signal?: Abo
|
|
|
445
446
|
for (const entry of services) {
|
|
446
447
|
if (entry.action !== "left" || UP.has(live.get(entry.name)?.status ?? "")) continue;
|
|
447
448
|
await spriteEnv(["services", "start", entry.name]);
|
|
448
|
-
|
|
449
|
+
logProgress(`services: started ${entry.name}`);
|
|
449
450
|
entry.action = "started";
|
|
450
451
|
started.push(entry.name);
|
|
451
452
|
}
|
|
452
453
|
}
|
|
453
454
|
const applied = services.filter((e) => e.action === "created" || e.action === "replaced").map((e) => e.name);
|
|
454
|
-
|
|
455
|
+
logProgress(`services: applied ${applied.length}/${targets.length} through sprite-env, started ${started.length}`);
|
|
455
456
|
return { applied, started, services };
|
|
456
457
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { resolveSpritesEndpoint, resolveSpritesToken } from "./sprites";
|
|
20
|
+
import { logProgress } from "./progress";
|
|
20
21
|
|
|
21
22
|
function safeJson(text: string): unknown {
|
|
22
23
|
try {
|
|
@@ -166,7 +167,7 @@ export async function spriteWriteFile(
|
|
|
166
167
|
if (res.status >= 300) {
|
|
167
168
|
throw new Error(`sprite ${args.id} write ${args.path} failed (${res.status}): ${res.text}`);
|
|
168
169
|
}
|
|
169
|
-
|
|
170
|
+
logProgress(`wrote: sprite/${args.id}:${args.path} (${args.content.length}b)`);
|
|
170
171
|
return {};
|
|
171
172
|
}
|
|
172
173
|
|
|
@@ -221,6 +222,6 @@ export async function spriteRemove(
|
|
|
221
222
|
if (res.status >= 300 && res.status !== 404) {
|
|
222
223
|
throw new Error(`sprite ${args.id} remove ${args.path} failed (${res.status}): ${res.text}`);
|
|
223
224
|
}
|
|
224
|
-
|
|
225
|
+
logProgress(`removed: sprite/${args.id}:${args.path}`);
|
|
225
226
|
return {};
|
|
226
227
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { spriteCreate, type SpritesHttp } from "./sprites";
|
|
3
|
+
import { spriteApplyServices } from "./sprite-config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #3200: an Op step's stdout is its output, so the Sprites activities write
|
|
7
|
+
* progress to stderr (as the Machines applier does since #2516) and leave
|
|
8
|
+
* stdout clean.
|
|
9
|
+
*/
|
|
10
|
+
describe("Sprites activities keep stdout clean (#3200)", () => {
|
|
11
|
+
afterEach(() => vi.restoreAllMocks());
|
|
12
|
+
|
|
13
|
+
test("create and services apply write progress to stderr only", async () => {
|
|
14
|
+
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
15
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
16
|
+
const stderr: string[] = [];
|
|
17
|
+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
|
|
18
|
+
stderr.push(String(chunk));
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
const http: SpritesHttp = async (method, url) => {
|
|
22
|
+
if (method === "POST" && url.endsWith("/v1/sprites")) return { status: 200, text: JSON.stringify({ id: "box-1", url: "https://box-1.sprites.app" }) };
|
|
23
|
+
if (method === "GET" && url.endsWith("/services")) return { status: 200, text: "[]" };
|
|
24
|
+
return { status: 200, text: "{}" };
|
|
25
|
+
};
|
|
26
|
+
const endpoint = "http://localhost:4290";
|
|
27
|
+
|
|
28
|
+
const created = await spriteCreate({ name: "box-1", endpoint }, undefined, http);
|
|
29
|
+
const applied = await spriteApplyServices(
|
|
30
|
+
{
|
|
31
|
+
id: "box-1",
|
|
32
|
+
endpoint,
|
|
33
|
+
start: true,
|
|
34
|
+
services: [
|
|
35
|
+
{ name: "db", cmd: "postgres" },
|
|
36
|
+
{ name: "web", cmd: "node server.js", needs: ["db"] },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
undefined,
|
|
40
|
+
http,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(created.id).toBe("box-1");
|
|
44
|
+
expect(applied.started).toEqual(["db", "web"]);
|
|
45
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
46
|
+
expect(log).not.toHaveBeenCalled();
|
|
47
|
+
const err = stderr.join("");
|
|
48
|
+
expect(err).toMatch(/created: sprite\/box-1/);
|
|
49
|
+
expect(err).toMatch(/services: sprite\/box-1 applied 2\/2/);
|
|
50
|
+
expect(err).toMatch(/services: sprite\/box-1 started 2 in dependency order/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -35,6 +35,7 @@ import { boxServices } from "./box-services";
|
|
|
35
35
|
import { spriteServiceList, spriteServiceStart, spriteServiceStop } from "./sprite-services";
|
|
36
36
|
import type { SpritesHttp } from "./sprites";
|
|
37
37
|
import { defaultSpritesHttp } from "./sprites";
|
|
38
|
+
import { logProgress } from "./progress";
|
|
38
39
|
|
|
39
40
|
const execFileAsync = promisify(execFile);
|
|
40
41
|
|
|
@@ -284,7 +285,7 @@ export async function spriteServiceRestart(
|
|
|
284
285
|
if (!bin) throw new Error("no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
|
|
285
286
|
await execFileAsync(bin, ["services", "restart", name], { signal, timeout: 120_000 });
|
|
286
287
|
}
|
|
287
|
-
|
|
288
|
+
logProgress(`restarted: ${name}`);
|
|
288
289
|
if (!health) return { name, healthy: null };
|
|
289
290
|
|
|
290
291
|
const until = Date.now() + (args.waitMs ?? 60_000);
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
|
|
31
|
+
import { logProgress } from "./progress";
|
|
31
32
|
|
|
32
33
|
// ── URL building (pure) ─────────────────────────────────────────────────────
|
|
33
34
|
|
|
@@ -197,7 +198,7 @@ export async function spriteServiceCreate(
|
|
|
197
198
|
if (svc.state.status === "failed") {
|
|
198
199
|
throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
+
logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
201
202
|
return svc;
|
|
202
203
|
}
|
|
203
204
|
|
|
@@ -249,7 +250,7 @@ export async function spriteServiceStart(
|
|
|
249
250
|
if (svc.state.status === "failed") {
|
|
250
251
|
throw new Error(`sprite ${args.id} service ${args.name} failed to start: ${svc.state.error ?? "unknown error"}`);
|
|
251
252
|
}
|
|
252
|
-
|
|
253
|
+
logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
253
254
|
return svc;
|
|
254
255
|
}
|
|
255
256
|
|
|
@@ -270,7 +271,7 @@ export async function spriteServiceStop(
|
|
|
270
271
|
throw new Error(`sprite ${args.id} service ${args.name} stop failed (${res.status}): ${res.text}`);
|
|
271
272
|
}
|
|
272
273
|
const svc = await spriteServiceGet({ id: args.id, name: args.name, endpoint: args.endpoint, token: args.token }, signal, http);
|
|
273
|
-
|
|
274
|
+
logProgress(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
|
|
274
275
|
return svc;
|
|
275
276
|
}
|
|
276
277
|
|
|
@@ -288,7 +289,7 @@ export async function spriteServiceDelete(
|
|
|
288
289
|
if (res.status >= 300 && res.status !== 404) {
|
|
289
290
|
throw new Error(`sprite ${args.id} service ${args.name} delete failed (${res.status}): ${res.text}`);
|
|
290
291
|
}
|
|
291
|
-
|
|
292
|
+
logProgress(`deleted: sprite/${args.id}/${args.name}`);
|
|
292
293
|
return {};
|
|
293
294
|
}
|
|
294
295
|
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// every project that imports the lexicon and needs the run fallback (#2613).
|
|
36
36
|
|
|
37
37
|
import { sleep } from "@intentius/chant/op";
|
|
38
|
+
import { logProgress } from "./progress";
|
|
38
39
|
|
|
39
40
|
export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
|
|
40
41
|
|
|
@@ -420,7 +421,7 @@ export async function spriteCreate(
|
|
|
420
421
|
const res = await http("POST", spritesUrl(base), spriteCreateBody(args), undefined, signal);
|
|
421
422
|
if (res.status >= 300) throw new Error(`sprite ${args.name} create failed (${res.status}): ${res.text}`);
|
|
422
423
|
const result = parseCreateResponse(res.text, args.name);
|
|
423
|
-
|
|
424
|
+
logProgress(`created: sprite/${result.id} (${base})`);
|
|
424
425
|
return result;
|
|
425
426
|
}
|
|
426
427
|
|
|
@@ -524,7 +525,7 @@ export async function spriteCheckpoint(
|
|
|
524
525
|
const res = await http("POST", spriteCheckpointUrl(base, args.id), body, undefined, signal);
|
|
525
526
|
if (res.status >= 300) throw new Error(`sprite ${args.id} checkpoint failed (${res.status}): ${res.text}`);
|
|
526
527
|
const result = parseCheckpointNdjson(res.text);
|
|
527
|
-
|
|
528
|
+
logProgress(`checkpoint: sprite/${args.id} @${result.checkpointId} (${base})`);
|
|
528
529
|
return result;
|
|
529
530
|
}
|
|
530
531
|
|
|
@@ -572,7 +573,7 @@ export async function spriteRestore(
|
|
|
572
573
|
if (res.status >= 300) {
|
|
573
574
|
throw new Error(`sprite ${args.id} restore to "${target}" failed (${res.status}): ${res.text}`);
|
|
574
575
|
}
|
|
575
|
-
|
|
576
|
+
logProgress(`restored: sprite/${args.id} to ${target} (${base})`);
|
|
576
577
|
return {};
|
|
577
578
|
}
|
|
578
579
|
|
|
@@ -587,7 +588,7 @@ export async function spriteDestroy(
|
|
|
587
588
|
if (res.status >= 300 && res.status !== 404) {
|
|
588
589
|
throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
|
|
589
590
|
}
|
|
590
|
-
|
|
591
|
+
logProgress(`destroyed: sprite/${args.id} (${base})`);
|
|
591
592
|
return {};
|
|
592
593
|
}
|
|
593
594
|
|
|
@@ -656,7 +657,7 @@ export async function spriteUrl(
|
|
|
656
657
|
const check = await http("GET", target, undefined, undefined, signal);
|
|
657
658
|
const ok = args.status !== undefined ? check.status === args.status : check.status >= 200 && check.status < 300;
|
|
658
659
|
if (ok) {
|
|
659
|
-
|
|
660
|
+
logProgress(`url: sprite/${args.id} answers on ${target} (${check.status})`);
|
|
660
661
|
return { url };
|
|
661
662
|
}
|
|
662
663
|
lastErr = `status ${check.status} (want ${args.status ?? "2xx"})`;
|