@intentius/chant-lexicon-fly 0.100.0 → 0.102.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.
Files changed (70) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/codegen/naming.d.ts.map +1 -1
  3. package/dist/codegen/package.d.ts +1 -1
  4. package/dist/components/capability-plugin.d.ts.map +1 -1
  5. package/dist/composites/fly-deploy.d.ts +2 -0
  6. package/dist/composites/fly-deploy.d.ts.map +1 -1
  7. package/dist/composites/fly-site.d.ts +2 -2
  8. package/dist/generated/index.d.ts +41 -41
  9. package/dist/generated/index.d.ts.map +1 -1
  10. package/dist/import/generator.d.ts.map +1 -1
  11. package/dist/import/parser.d.ts.map +1 -1
  12. package/dist/integrity.json +5 -5
  13. package/dist/manifest.json +1 -1
  14. package/dist/okf/types/Secret.md +1 -0
  15. package/dist/op/activities/flaps.d.ts.map +1 -1
  16. package/dist/op/activities/fly-apply.d.ts +87 -8
  17. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  18. package/dist/op/activities/index.d.ts +2 -2
  19. package/dist/op/activities/index.d.ts.map +1 -1
  20. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  21. package/dist/op/activities/machines-fake.d.ts +10 -1
  22. package/dist/op/activities/machines-fake.d.ts.map +1 -1
  23. package/dist/op/activities/machines-local.d.ts.map +1 -1
  24. package/dist/op/activities/progress.d.ts +9 -0
  25. package/dist/op/activities/progress.d.ts.map +1 -0
  26. package/dist/op/activities/sprite-config.d.ts.map +1 -1
  27. package/dist/op/activities/sprite-fs.d.ts.map +1 -1
  28. package/dist/op/activities/sprite-service-converge.d.ts.map +1 -1
  29. package/dist/op/activities/sprite-services.d.ts.map +1 -1
  30. package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
  31. package/dist/op/activities/sprites.d.ts.map +1 -1
  32. package/dist/op/activity-contracts.d.ts +10 -10
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/release-metadata.d.ts.map +1 -1
  35. package/dist/serializer.d.ts.map +1 -1
  36. package/dist/skills/chant-fly-ops.md +8 -0
  37. package/dist/skills/chant-fly-patterns.md +1 -1
  38. package/dist/spec/parse.d.ts.map +1 -1
  39. package/dist/types/index.d.ts +2 -1
  40. package/package.json +3 -3
  41. package/src/codegen/docs.ts +16 -1
  42. package/src/composites/fly-deploy.ts +2 -0
  43. package/src/describe-resources.test.ts +18 -1
  44. package/src/describe-resources.ts +1 -1
  45. package/src/generated/index.d.ts +2 -1
  46. package/src/generated/index.ts +1 -1
  47. package/src/op/activities/fly-apply.integration.test.ts +12 -5
  48. package/src/op/activities/fly-apply.test.ts +483 -2
  49. package/src/op/activities/fly-apply.ts +229 -45
  50. package/src/op/activities/index.ts +27 -1
  51. package/src/op/activities/machines-contract.docker.integration.test.ts +22 -1
  52. package/src/op/activities/machines-contract.ts +5 -1
  53. package/src/op/activities/machines-fake.ts +69 -2
  54. package/src/op/activities/machines-local-cli.ts +3 -0
  55. package/src/op/activities/machines-local.test.ts +48 -12
  56. package/src/op/activities/machines-local.ts +28 -5
  57. package/src/op/activities/progress.ts +10 -0
  58. package/src/op/activities/sprite-config.ts +10 -9
  59. package/src/op/activities/sprite-fs.ts +3 -2
  60. package/src/op/activities/sprite-progress.test.ts +52 -0
  61. package/src/op/activities/sprite-service-converge.ts +2 -1
  62. package/src/op/activities/sprite-services.ts +5 -4
  63. package/src/op/activities/sprites.ts +6 -5
  64. package/src/serializer.test.ts +17 -2
  65. package/src/serializer.ts +16 -7
  66. package/src/skills/chant-fly-ops.md +8 -0
  67. package/src/skills/chant-fly-patterns.md +1 -1
  68. package/src/spec/parse.ts +25 -2
  69. package/src/teardown.test.ts +22 -3
  70. package/src/teardown.ts +1 -1
@@ -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
- /** A fresh in-memory flaps. */
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 method === "GET" ? json(200, { certificates: [] }) : json(200, {});
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", "sleep 300 & exec sleep 301"] } } });
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
- expect(alive(pid)).toBe(true);
201
- const exited = new Promise((ok) => host.once("exit", ok));
202
- if (how !== "exit") host.kill(how);
203
- await exited;
204
- for (let i = 0; i < 50 && alive(pid); i++) await new Promise((ok) => setTimeout(ok, 20));
205
- expect(alive(pid)).toBe(false);
206
- // Nor does anything the command started in its group.
207
- const left = spawnSync("pgrep", ["-g", String(pid)], { encoding: "utf8" }).stdout.trim();
208
- expect(left).toBe("");
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
- if (groupAlive(child.pid)) {
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
- while (groupAlive(child.pid)) await new Promise((ok) => setTimeout(ok, 25));
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
- killGroup(child, "SIGKILL");
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
- const timer = setTimeout(() => killGroup(child, "SIGKILL"), timeoutSecs * 1000);
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
- killGroup(r.child, "SIGKILL");
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
- console.log(`policy: sprite/${args.id} already converged (${args.rules.length} rules)`);
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
- console.log(`policy: sprite/${args.id} applied ${args.rules.length} rules (${base})`);
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
- console.log(`services: sprite/${args.id} applied ${applied.length}/${args.services.length} (${base})`);
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
- console.log(`services: sprite/${args.id} started ${started.length} in dependency order`);
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
- console.log(`services: created ${s.name}`);
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
- console.log(`services: replaced ${s.name} (${why})`);
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
- console.log(`services: restarted ${s.name}`);
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
- console.log(`services: started ${entry.name}`);
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
- console.log(`services: applied ${applied.length}/${targets.length} through sprite-env, started ${started.length}`);
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
- console.log(`wrote: sprite/${args.id}:${args.path} (${args.content.length}b)`);
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
- console.log(`removed: sprite/${args.id}:${args.path}`);
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
- console.log(`restarted: ${name}`);
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
- console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
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
- console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
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
- console.log(`service: sprite/${args.id}/${args.name} ${svc.state.status} (${base})`);
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
- console.log(`deleted: sprite/${args.id}/${args.name}`);
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
- console.log(`created: sprite/${result.id} (${base})`);
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
- console.log(`checkpoint: sprite/${args.id} @${result.checkpointId} (${base})`);
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
- console.log(`restored: sprite/${args.id} to ${target} (${base})`);
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
- console.log(`destroyed: sprite/${args.id} (${base})`);
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
- console.log(`url: sprite/${args.id} answers on ${target} (${check.status})`);
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"})`;
@@ -146,14 +146,14 @@ describe("fly serializer", () => {
146
146
  });
147
147
  });
148
148
 
149
- it("emits the Certificate create body (createCertificateRequest: hostname) under the app", () => {
149
+ it("emits the Certificate create body (createAcmeCertificateRequest: hostname) at /certificates/acme (#3114)", () => {
150
150
  const entities = stack(
151
151
  ["app", new App({ name: "my-app" })],
152
152
  ["cert", new Certificate({ hostname: "example.com" })],
153
153
  );
154
154
  const out = JSON.parse(flySerializer.serialize(entities) as string);
155
155
  expect(out.cert).toEqual({
156
- endpoint: "/v1/apps/my-app/certificates",
156
+ endpoint: "/v1/apps/my-app/certificates/acme",
157
157
  method: "POST",
158
158
  body: { hostname: "example.com" },
159
159
  });
@@ -172,6 +172,21 @@ describe("fly serializer", () => {
172
172
  applyOnly: true,
173
173
  });
174
174
  });
175
+
176
+ it("marks a value-less Secret mustExist and takes its name from `name` (#2516)", () => {
177
+ const entities = stack(
178
+ ["app", new App({ name: "my-app" })],
179
+ ["dbUrl", new Secret({ name: "DATABASE_URL" })],
180
+ );
181
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
182
+ expect(out.dbUrl).toEqual({
183
+ endpoint: "/v1/apps/my-app/secrets/DATABASE_URL",
184
+ method: "POST",
185
+ body: {},
186
+ applyOnly: true,
187
+ mustExist: true,
188
+ });
189
+ });
175
190
  });
176
191
 
177
192
  // ── Pseudo-parameters (Fly.Region / Fly.OrgSlug) ────────────────────────────