@intentius/chant-lexicon-fly 0.101.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
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
2
  import { describeApplyConformance } from "@intentius/chant-test-utils";
3
3
  import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
@@ -38,13 +38,18 @@ import {
38
38
  withLease,
39
39
  waitForMachine,
40
40
  flyApply,
41
+ flyDelete,
41
42
  toApplyResult,
43
+ listCerts,
44
+ CERT_PAGE_LIMIT,
45
+ MAX_CERT_PAGES,
42
46
  DEFAULT_FLAPS_BASE_URL,
43
47
  LEASE_NONCE_HEADER,
44
48
  type FlyHttp,
45
49
  type FlapsRequest,
46
50
  type ApplyCtx,
47
51
  } from "./fly-apply";
52
+ import { createMachinesFake, CERT_PAGE_DEFAULT } from "./machines-fake";
48
53
 
49
54
  const CTX: ApplyCtx = { base: "http://localhost:4280" };
50
55
  const NO_WAIT = { intervalMs: 0, deadlineMs: 5_000 };
@@ -313,6 +318,7 @@ describe("owned-only prune", () => {
313
318
  };
314
319
  }
315
320
  if (method === "POST" && url.endsWith("/lease")) return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N" } }) };
321
+ if (method === "POST" && url.endsWith("/stop")) return { status: 200, text: "{}" };
316
322
  if (method === "DELETE" && /\/machines\/[^/]+$/.test(url)) {
317
323
  destroyed.push(url.split("/").pop() ?? "");
318
324
  return { status: 200, text: "{}" };
@@ -327,6 +333,169 @@ describe("owned-only prune", () => {
327
333
  });
328
334
  });
329
335
 
336
+ /**
337
+ * #3115: flaps destroys a machine only when it is stopped (or suspended,
338
+ * failed, created) unless the DELETE carries ?force=true; a bare DELETE on a
339
+ * started machine is a 412 failed_precondition. The in-memory flaps answers the
340
+ * same way, so a bare delete of a running machine fails here.
341
+ */
342
+ describe("destroying a running machine (#3115)", () => {
343
+ const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
344
+ const machine = (name: string) => ({
345
+ endpoint: "/v1/apps/demo/machines",
346
+ method: "POST",
347
+ body: { name, config: { image: "nginx", metadata: { ...OWNED_META } } },
348
+ });
349
+ const planWith = (entries: Record<string, unknown>): string => {
350
+ const dir = mkdtempSync(join(tmpdir(), "fly-3115-"));
351
+ const planPath = join(dir, "plan.json");
352
+ writeFileSync(planPath, JSON.stringify(entries));
353
+ return planPath;
354
+ };
355
+
356
+ /** A scripted flaps that records each call and answers like Fly for one machine `m1` in `state`. */
357
+ function scripted(state: string, overrides: (method: string, url: string) => { status: number; text: string } | undefined = () => undefined) {
358
+ const calls: string[] = [];
359
+ const http: FlyHttp = async (method, url) => {
360
+ const path = url.replace(CTX.base, "");
361
+ calls.push(`${method} ${path}`);
362
+ const o = overrides(method, path);
363
+ if (o) return o;
364
+ if (method === "POST" && path.endsWith("/lease")) return { status: 200, text: JSON.stringify({ data: { nonce: "N" } }) };
365
+ if (method === "DELETE" && path.endsWith("/lease")) return { status: 200, text: "{}" };
366
+ if (method === "POST" && path.endsWith("/stop")) { state = "stopped"; return { status: 200, text: "{}" }; }
367
+ if (method === "GET" && path.includes("/wait")) {
368
+ const want = new URL(url).searchParams.get("state");
369
+ return { status: 200, text: JSON.stringify({ ok: want === "destroyed" || want === state }) };
370
+ }
371
+ if (method === "DELETE" && /\/machines\/m1(\?force=true)?$/.test(path)) {
372
+ if (!path.endsWith("?force=true") && !["stopped", "suspended", "failed", "created"].includes(state)) {
373
+ return { status: 412, text: JSON.stringify({ error: "failed_precondition: unable to destroy machine, not currently stopped" }) };
374
+ }
375
+ return { status: 200, text: "{}" };
376
+ }
377
+ return { status: 500, text: `unscripted: ${method} ${path}` };
378
+ };
379
+ return { http, calls };
380
+ }
381
+ const mutations = (calls: string[]) => calls.filter((c) => !c.startsWith("GET"));
382
+
383
+ test("a started machine is stopped under a lease, waited to stopped, then destroyed under a lease", async () => {
384
+ const { http, calls } = scripted("started");
385
+ await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started" });
386
+ expect(calls).toEqual([
387
+ "POST /v1/apps/demo/machines/m1/lease",
388
+ "POST /v1/apps/demo/machines/m1/stop",
389
+ "DELETE /v1/apps/demo/machines/m1/lease",
390
+ "GET /v1/apps/demo/machines/m1/wait?state=stopped&timeout=60",
391
+ "POST /v1/apps/demo/machines/m1/lease",
392
+ "DELETE /v1/apps/demo/machines/m1",
393
+ "DELETE /v1/apps/demo/machines/m1/lease",
394
+ "GET /v1/apps/demo/machines/m1/wait?state=destroyed&timeout=60",
395
+ ]);
396
+ });
397
+
398
+ test.each(["stopped", "suspended", "failed"])("a %s machine is destroyed without a stop", async (state) => {
399
+ const { http, calls } = scripted(state);
400
+ await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state });
401
+ expect(mutations(calls)).toEqual([
402
+ "POST /v1/apps/demo/machines/m1/lease",
403
+ "DELETE /v1/apps/demo/machines/m1",
404
+ "DELETE /v1/apps/demo/machines/m1/lease",
405
+ ]);
406
+ });
407
+
408
+ test("a stopping machine is waited on, not stopped again", async () => {
409
+ const { http, calls } = scripted("stopped");
410
+ await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "stopping" });
411
+ expect(calls).not.toContain("POST /v1/apps/demo/machines/m1/stop");
412
+ expect(calls).toContain("GET /v1/apps/demo/machines/m1/wait?state=stopped&timeout=60");
413
+ });
414
+
415
+ test("force sends DELETE ?force=true and no stop", async () => {
416
+ const { http, calls } = scripted("started");
417
+ await destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started", force: true });
418
+ expect(mutations(calls)).toEqual([
419
+ "POST /v1/apps/demo/machines/m1/lease",
420
+ "DELETE /v1/apps/demo/machines/m1?force=true",
421
+ "DELETE /v1/apps/demo/machines/m1/lease",
422
+ ]);
423
+ });
424
+
425
+ test("a 412 from the delete (state unknown, or started since the list) gets one stop and a retry", async () => {
426
+ const { http, calls } = scripted("started");
427
+ await destroyMachine(CTX, "demo", "m1", http, undefined, NO_WAIT);
428
+ expect(mutations(calls).filter((c) => !c.endsWith("/lease"))).toEqual([
429
+ "DELETE /v1/apps/demo/machines/m1",
430
+ "POST /v1/apps/demo/machines/m1/stop",
431
+ "DELETE /v1/apps/demo/machines/m1",
432
+ ]);
433
+ });
434
+
435
+ test("a stop that never reaches stopped fails naming the machine, and no delete is sent", async () => {
436
+ const { http, calls } = scripted("started", (method, path) =>
437
+ method === "POST" && path.endsWith("/stop") ? { status: 200, text: "{}" } : undefined,
438
+ );
439
+ await expect(
440
+ destroyMachine(CTX, "demo", "m1", http, undefined, { intervalMs: 0, deadlineMs: 20, state: "started", name: "web" }),
441
+ ).rejects.toThrow(/machine demo\/web \(m1\) did not stop, so it was not destroyed.*force: true/);
442
+ expect(calls).not.toContain("DELETE /v1/apps/demo/machines/m1");
443
+ });
444
+
445
+ test("a failed stop fails naming the machine", async () => {
446
+ const { http } = scripted("started", (method, path) =>
447
+ method === "POST" && path.endsWith("/stop") ? { status: 500, text: "boom" } : undefined,
448
+ );
449
+ await expect(
450
+ destroyMachine(CTX, "demo", "m1", http, undefined, { ...NO_WAIT, state: "started", name: "web" }),
451
+ ).rejects.toThrow("machine demo/web (m1) stop before destroy failed (500): boom");
452
+ });
453
+
454
+ test("the in-memory flaps refuses a bare DELETE on a started machine and honours force", async () => {
455
+ const fake = createMachinesFake();
456
+ await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
457
+ const id = fake.machine("demo", "web")!.id;
458
+ expect((await fake.http("DELETE", `${CTX.base}/v1/apps/demo/machines/${id}`)).status).toBe(412);
459
+ expect((await fake.http("DELETE", `${CTX.base}/v1/apps/demo/machines/${id}?force=true`)).status).toBe(200);
460
+ expect(fake.machine("demo", "web")).toBeUndefined();
461
+ });
462
+
463
+ test("prune removes a running machine: stop, then delete", async () => {
464
+ const fake = createMachinesFake();
465
+ await flyApply({ planPath: planWith({ app, web: machine("web"), worker: machine("worker") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
466
+ const id = fake.machine("demo", "worker")!.id;
467
+ expect(fake.machine("demo", "worker")!.state).toBe("started");
468
+
469
+ const out = await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT, prune: true }, undefined, fake.http);
470
+ expect(out.pruned).toEqual([{ app: "demo", name: "worker", id }]);
471
+ expect(fake.machine("demo", "worker")).toBeUndefined();
472
+ expect(fake.machine("demo", "web")!.state).toBe("started");
473
+ expect(fake.calls).toContain(`POST /v1/apps/demo/machines/${id}/stop`);
474
+ });
475
+
476
+ test("prune with force removes a running machine without a stop", async () => {
477
+ const fake = createMachinesFake();
478
+ await flyApply({ planPath: planWith({ app, web: machine("web"), worker: machine("worker") }), endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
479
+ const id = fake.machine("demo", "worker")!.id;
480
+ await flyApply({ planPath: planWith({ app, web: machine("web") }), endpoint: CTX.base, wait: NO_WAIT, prune: true, force: true }, undefined, fake.http);
481
+ expect(fake.machine("demo", "worker")).toBeUndefined();
482
+ expect(fake.calls).not.toContain(`POST /v1/apps/demo/machines/${id}/stop`);
483
+ });
484
+
485
+ test("flyDelete tears down an app whose machines are running", async () => {
486
+ const fake = createMachinesFake();
487
+ const planPath = planWith({ app, web: machine("web"), worker: machine("worker") });
488
+ await flyApply({ planPath, endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
489
+ const out = await flyDelete({ planPath, endpoint: CTX.base, wait: NO_WAIT }, undefined, fake.http);
490
+ expect(out.machines).toEqual([
491
+ { app: "demo", name: "web" },
492
+ { app: "demo", name: "worker" },
493
+ ]);
494
+ expect(out.apps).toEqual([{ app: "demo", deleted: true }]);
495
+ expect(fake.calls.filter((c) => c.endsWith("/stop"))).toHaveLength(2);
496
+ });
497
+ });
498
+
330
499
  // ── destroyMachine + waitForMachine ──────────────────────────────────────────
331
500
 
332
501
  describe("destroy + wait", () => {
@@ -361,7 +530,10 @@ describe("app-scoped resource classification + segments", () => {
361
530
  const at = (endpoint: string): FlapsRequest => ({ endpoint, method: "POST", body: {} });
362
531
  expect(isVolumeRequest(at("/v1/apps/demo/volumes"))).toBe(true);
363
532
  expect(isIpRequest(at("/v1/apps/demo/ip_assignments"))).toBe(true);
533
+ expect(isCertRequest(at("/v1/apps/demo/certificates/acme"))).toBe(true);
534
+ // A plan built before #3114 names the list path; it still classifies.
364
535
  expect(isCertRequest(at("/v1/apps/demo/certificates"))).toBe(true);
536
+ expect(isCertRequest(at("/v1/apps/demo/certificates/custom"))).toBe(false);
365
537
  expect(isSecretRequest(at("/v1/apps/demo/secrets/db"))).toBe(true);
366
538
  // Cross-checks: a secret endpoint is not a machine/volume request.
367
539
  expect(isMachineRequest(at("/v1/apps/demo/secrets/db"))).toBe(false);
@@ -412,13 +584,173 @@ describe("applyVolume / applyIp / applyCert idempotency (drift handling)", () =>
412
584
  });
413
585
 
414
586
  test("cert absent → create; present (by hostname) → no-op", async () => {
415
- const req: FlapsRequest = { endpoint: "/v1/apps/demo/certificates", method: "POST", body: { hostname: "example.com" } };
587
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname: "example.com" } };
588
+ const posts: Array<{ url: string; body: unknown }> = [];
589
+ const empty: FlyHttp = async (method, url, body) =>
590
+ method === "GET"
591
+ ? { status: 200, text: JSON.stringify({ certificates: [] }) }
592
+ : (posts.push({ url, body }), { status: 201, text: JSON.stringify({ hostname: "example.com" }) });
593
+ expect(await applyCert(CTX, "demo", req, empty)).toEqual({ action: "created", hostname: "example.com" });
594
+ expect(posts).toEqual([{ url: "http://localhost:4280/v1/apps/demo/certificates/acme", body: { hostname: "example.com" } }]);
595
+
416
596
  const present: FlyHttp = async (method) =>
417
597
  method === "GET" ? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "example.com" }] }) } : { status: 500, text: "" };
418
598
  expect(await applyCert(CTX, "demo", req, present)).toEqual({ action: "noop", hostname: "example.com" });
419
599
  });
420
600
  });
421
601
 
602
+ /**
603
+ * #3114: flaps has no POST on `.../certificates` and answers one with 404; ACME
604
+ * certificates are created at `.../certificates/acme`. The in-memory flaps
605
+ * routes certificates the same way, so a wrong path fails here.
606
+ */
607
+ describe("flyApply creates certificates at /certificates/acme (#3114)", () => {
608
+ const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
609
+ const planWith = (entries: Record<string, unknown>): string => {
610
+ const dir = mkdtempSync(join(tmpdir(), "fly-3114-"));
611
+ const planPath = join(dir, "plan.json");
612
+ writeFileSync(planPath, JSON.stringify(entries));
613
+ return planPath;
614
+ };
615
+ const run = (fake: ReturnType<typeof createMachinesFake>, entries: Record<string, unknown>, prune = false) =>
616
+ flyApply({ planPath: planWith(entries), endpoint: CTX.base, wait: NO_WAIT, prune }, undefined, fake.http);
617
+
618
+ test("the in-memory flaps answers a POST to the bare list path with 404, as Fly does", async () => {
619
+ const fake = createMachinesFake();
620
+ fake.apps.add("demo");
621
+ const res = await fake.http("POST", `${CTX.base}/v1/apps/demo/certificates`, { hostname: "example.com" });
622
+ expect(res.status).toBe(404);
623
+ expect(fake.certs.get("demo") ?? []).toEqual([]);
624
+ });
625
+
626
+ test("a certificate is created once, and a re-apply is a no-op", async () => {
627
+ const fake = createMachinesFake();
628
+ const cert = { endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname: "example.com" } };
629
+ const first = await run(fake, { app, cert });
630
+ expect(first.certs).toEqual([{ app: "demo", hostname: "example.com", action: "created" }]);
631
+ expect(fake.certs.get("demo")).toEqual(["example.com"]);
632
+ expect(fake.calls).toContain("POST /v1/apps/demo/certificates/acme");
633
+ expect(fake.calls).not.toContain("POST /v1/apps/demo/certificates");
634
+
635
+ const second = await run(fake, { app, cert });
636
+ expect(second.certs).toEqual([{ app: "demo", hostname: "example.com", action: "noop" }]);
637
+ expect(fake.calls.filter((c) => c === "POST /v1/apps/demo/certificates/acme")).toHaveLength(1);
638
+ });
639
+
640
+ test("a plan built before #3114, naming /certificates, still creates the certificate at /acme", async () => {
641
+ const fake = createMachinesFake();
642
+ const legacy = { endpoint: "/v1/apps/demo/certificates", method: "POST", body: { hostname: "example.com" } };
643
+ const out = await run(fake, { app, cert: legacy });
644
+ expect(out.certs).toEqual([{ app: "demo", hostname: "example.com", action: "created" }]);
645
+ expect(fake.certs.get("demo")).toEqual(["example.com"]);
646
+ });
647
+
648
+ test("prune deletes a certificate the plan no longer declares", async () => {
649
+ const fake = createMachinesFake();
650
+ const cert = (hostname: string) => ({ endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname } });
651
+ await run(fake, { app, keep: cert("keep.com"), drop: cert("drop.com") });
652
+ const out = await run(fake, { app, keep: cert("keep.com") }, true);
653
+ expect(out.prunedCerts).toEqual([{ app: "demo", hostname: "drop.com" }]);
654
+ expect(fake.certs.get("demo")).toEqual(["keep.com"]);
655
+ });
656
+ });
657
+
658
+ /**
659
+ * #3224: Fly pages the certificate list (25 by default, 500 at most, with a
660
+ * `next_cursor`), and the in-memory flaps does the same. Every reader goes
661
+ * through listCerts, which follows the cursor to the end.
662
+ */
663
+ describe("certificate list pagination (#3224)", () => {
664
+ const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
665
+ const cert = (hostname: string) => ({ endpoint: "/v1/apps/demo/certificates/acme", method: "POST", body: { hostname } });
666
+ const hosts = (n: number) => Array.from({ length: n }, (_, i) => `h${String(i).padStart(4, "0")}.example.com`);
667
+ const planOf = (hostnames: string[]): string => {
668
+ const dir = mkdtempSync(join(tmpdir(), "fly-3224-"));
669
+ const planPath = join(dir, "plan.json");
670
+ writeFileSync(planPath, JSON.stringify({ app, ...Object.fromEntries(hostnames.map((h) => [h, cert(h)])) }));
671
+ return planPath;
672
+ };
673
+ const run = (fake: ReturnType<typeof createMachinesFake>, hostnames: string[], prune = false) =>
674
+ flyApply({ planPath: planOf(hostnames), endpoint: CTX.base, wait: NO_WAIT, prune }, undefined, fake.http);
675
+ const certGets = (fake: ReturnType<typeof createMachinesFake>) => fake.calls.filter((c) => c === "GET /v1/apps/demo/certificates");
676
+
677
+ test("the in-memory flaps pages the list at 25 with a next_cursor, as Fly does", async () => {
678
+ const fake = createMachinesFake();
679
+ fake.apps.add("demo");
680
+ fake.certs.set("demo", hosts(30));
681
+ const first = JSON.parse((await fake.http("GET", `${CTX.base}/v1/apps/demo/certificates`)).text);
682
+ expect(first.certificates).toHaveLength(CERT_PAGE_DEFAULT);
683
+ expect(first.total_count).toBe(30);
684
+ expect(typeof first.next_cursor).toBe("string");
685
+ const second = JSON.parse((await fake.http("GET", `${CTX.base}/v1/apps/demo/certificates?cursor=${first.next_cursor}`)).text);
686
+ expect(second.certificates).toHaveLength(5);
687
+ expect(second.next_cursor).toBeUndefined();
688
+ });
689
+
690
+ test("a re-apply of more certificates than one page is a no-op", async () => {
691
+ const fake = createMachinesFake();
692
+ const all = hosts(30);
693
+ await run(fake, all);
694
+ expect(fake.certs.get("demo")).toEqual(all);
695
+ const again = await run(fake, all);
696
+ expect(again.certs.every((c) => c.action === "noop")).toBe(true);
697
+ expect(fake.calls.filter((c) => c === "POST /v1/apps/demo/certificates/acme")).toHaveLength(30);
698
+ });
699
+
700
+ test("prune removes undeclared certificates past the first page", async () => {
701
+ const fake = createMachinesFake();
702
+ const all = hosts(30);
703
+ await run(fake, all);
704
+ const out = await run(fake, all.slice(0, 3), true);
705
+ expect(out.prunedCerts.map((c) => c.hostname)).toEqual(all.slice(3));
706
+ expect(fake.certs.get("demo")).toEqual(all.slice(0, 3));
707
+ });
708
+
709
+ test("listCerts asks for the largest page and follows the cursor past it", async () => {
710
+ const fake = createMachinesFake();
711
+ fake.apps.add("demo");
712
+ const all = hosts(2 * CERT_PAGE_LIMIT + 3);
713
+ fake.certs.set("demo", all);
714
+ const listed = await listCerts(CTX, "demo", fake.http);
715
+ expect(listed.map((c) => c.hostname)).toEqual(all);
716
+ expect(certGets(fake)).toHaveLength(3);
717
+ });
718
+
719
+ test("an absent app lists no certificates", async () => {
720
+ const fake = createMachinesFake();
721
+ expect(await listCerts(CTX, "nope", fake.http)).toEqual([]);
722
+ });
723
+
724
+ test("a repeated cursor fails loudly instead of looping", async () => {
725
+ let gets = 0;
726
+ const http: FlyHttp = async () => {
727
+ gets++;
728
+ return { status: 200, text: JSON.stringify({ certificates: [{ hostname: `h${gets}` }], next_cursor: "same" }) };
729
+ };
730
+ await expect(listCerts(CTX, "demo", http)).rejects.toThrow(/repeated cursor "same"/);
731
+ expect(gets).toBe(2);
732
+ });
733
+
734
+ test("a cursor that never ends fails after the page cap", async () => {
735
+ let gets = 0;
736
+ const http: FlyHttp = async () => {
737
+ gets++;
738
+ return { status: 200, text: JSON.stringify({ certificates: [], next_cursor: `c${gets}` }) };
739
+ };
740
+ await expect(listCerts(CTX, "demo", http)).rejects.toThrow(new RegExp(`after ${MAX_CERT_PAGES} pages`));
741
+ expect(gets).toBe(MAX_CERT_PAGES);
742
+ });
743
+
744
+ test("a failed later page is an error, not a short list", async () => {
745
+ let gets = 0;
746
+ const http: FlyHttp = async () =>
747
+ ++gets === 1
748
+ ? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "a" }], next_cursor: "n" }) }
749
+ : { status: 404, text: "gone" };
750
+ await expect(listCerts(CTX, "demo", http)).rejects.toThrow(/certificate list failed for demo \(404\)/);
751
+ });
752
+ });
753
+
422
754
  describe("secret is apply-only (D7): set, never read-diffed", () => {
423
755
  test("applySecret POSTs the value with no read-back GET", async () => {
424
756
  const calls: string[] = [];
@@ -514,6 +846,109 @@ describe("flyApply orders volumes before the machines that mount them", () => {
514
846
  });
515
847
  });
516
848
 
849
+ /**
850
+ * #2516 — a Secret declared without a value is set outside chant. Posting it
851
+ * as `{}` on every apply clobbered or no-oped the real secret. Apply checks the
852
+ * app has it instead, and fails naming it when the app does not.
853
+ */
854
+ describe("flyApply never sends a value-less secret (#2516)", () => {
855
+ const planWith = (entry: Record<string, unknown>): string => {
856
+ const dir = mkdtempSync(join(tmpdir(), "fly-2516-"));
857
+ const planPath = join(dir, "plan.json");
858
+ writeFileSync(planPath, JSON.stringify(entry));
859
+ return planPath;
860
+ };
861
+ const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } };
862
+ const valueless = { endpoint: "/v1/apps/demo/secrets/DATABASE_URL", method: "POST", body: {}, applyOnly: true, mustExist: true };
863
+ const scripted = (liveSecrets: string[], calls: string[]): FlyHttp => async (method, url) => {
864
+ calls.push(`${method} ${url}`);
865
+ if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 200, text: JSON.stringify({ name: "demo" }) };
866
+ if (method === "GET" && url.endsWith("/secrets")) {
867
+ return { status: 200, text: JSON.stringify({ secrets: liveSecrets.map((name) => ({ name })) }) };
868
+ }
869
+ return { status: 200, text: "{}" };
870
+ };
871
+
872
+ test("an existing value-less secret is checked, never posted, and reports unchanged", async () => {
873
+ const calls: string[] = [];
874
+ const out = await flyApply(
875
+ { planPath: planWith({ app, db: valueless }), endpoint: "http://localhost:4280" },
876
+ undefined,
877
+ scripted(["DATABASE_URL"], calls),
878
+ );
879
+ expect(out.secrets).toEqual([{ app: "demo", name: "DATABASE_URL", action: "exists" }]);
880
+ expect(calls.filter((c) => c.includes("/secrets"))).toEqual(["GET http://localhost:4280/v1/apps/demo/secrets"]);
881
+ expect(toApplyResult(out).applied).toContainEqual({ kind: "secret", name: "DATABASE_URL", action: "unchanged" });
882
+ });
883
+
884
+ test("a missing value-less secret fails with its name and posts nothing", async () => {
885
+ const calls: string[] = [];
886
+ await expect(
887
+ flyApply({ planPath: planWith({ app, db: valueless }), endpoint: "http://localhost:4280" }, undefined, scripted([], calls)),
888
+ ).rejects.toThrow(/secret demo\/DATABASE_URL is declared without a value.*does not have it/);
889
+ expect(calls.some((c) => c.startsWith("POST") && c.includes("/secrets"))).toBe(false);
890
+ });
891
+
892
+ test("a plan from before mustExist, with an empty body, is treated the same", async () => {
893
+ const calls: string[] = [];
894
+ const legacy = { endpoint: "/v1/apps/demo/secrets/DATABASE_URL", method: "POST", body: {}, applyOnly: true };
895
+ await flyApply({ planPath: planWith({ app, db: legacy }), endpoint: "http://localhost:4280" }, undefined, scripted(["DATABASE_URL"], calls));
896
+ expect(calls.some((c) => c.startsWith("POST") && c.includes("/secrets"))).toBe(false);
897
+ });
898
+
899
+ test("a secret with a value is still set", async () => {
900
+ const calls: string[] = [];
901
+ const withValue = { endpoint: "/v1/apps/demo/secrets/API_KEY", method: "POST", body: { value: "k" }, applyOnly: true };
902
+ const out = await flyApply({ planPath: planWith({ app, key: withValue }), endpoint: "http://localhost:4280" }, undefined, scripted([], calls));
903
+ expect(out.secrets).toEqual([{ app: "demo", name: "API_KEY", action: "set" }]);
904
+ expect(calls).toContain("POST http://localhost:4280/v1/apps/demo/secrets/API_KEY");
905
+ });
906
+ });
907
+
908
+ /**
909
+ * #2516 — an Op step's stdout is its output, so the applier writes progress to
910
+ * stderr and leaves stdout clean.
911
+ */
912
+ describe("flyApply keeps stdout clean (#2516)", () => {
913
+ afterEach(() => vi.restoreAllMocks());
914
+
915
+ test("apply, prune and delete write progress to stderr only", async () => {
916
+ const dir = mkdtempSync(join(tmpdir(), "fly-2516-out-"));
917
+ const planPath = join(dir, "plan.json");
918
+ writeFileSync(
919
+ planPath,
920
+ JSON.stringify({
921
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } },
922
+ key: { endpoint: "/v1/apps/demo/secrets/API_KEY", method: "POST", body: { value: "k" }, applyOnly: true },
923
+ }),
924
+ );
925
+ const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
926
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
927
+ const stderr: string[] = [];
928
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
929
+ stderr.push(String(chunk));
930
+ return true;
931
+ });
932
+ const http: FlyHttp = async (method, url) => {
933
+ if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 404, text: "" };
934
+ if (method === "GET" && url.endsWith("/secrets")) return { status: 200, text: JSON.stringify({ secrets: [{ name: "OLD" }] }) };
935
+ if (method === "GET" && /\/(volumes|machines)$/.test(url)) return { status: 200, text: "[]" };
936
+ return { status: 200, text: "{}" };
937
+ };
938
+ try {
939
+ await flyApply({ planPath, endpoint: "http://localhost:4280", prune: true }, undefined, http);
940
+ await flyDelete({ planPath, endpoint: "http://localhost:4280" }, undefined, http);
941
+ } finally {
942
+ rmSync(dir, { recursive: true, force: true });
943
+ }
944
+ expect(stdout).not.toHaveBeenCalled();
945
+ expect(log).not.toHaveBeenCalled();
946
+ expect(stderr.join("")).toMatch(/created: app\/demo/);
947
+ expect(stderr.join("")).toMatch(/set: secret\/demo\/API_KEY/);
948
+ expect(stderr.join("")).toMatch(/pruned: secret\/demo\/OLD/);
949
+ });
950
+ });
951
+
517
952
  /**
518
953
  * #1457 — a plan entry matching none of the six request predicates used to fall
519
954
  * off the end of the classification chain. No log, no bucket, absent from all
@@ -636,4 +1071,50 @@ describeApplyConformance({
636
1071
  expectApplied: ["app/solo", "machine/web"],
637
1072
  },
638
1073
  ],
1074
+ pruneScenarios: [
1075
+ {
1076
+ // The orphan is running, so it is stopped before the delete (#3115). The
1077
+ // stop is a POST and lease releases are not resource deletes, so neither
1078
+ // is captured: the one delete is the machine's own.
1079
+ name: "a running owned orphan and a foreign machine in one app",
1080
+ run: async () => {
1081
+ const dir = mkdtempSync(join(tmpdir(), "fly-3115-prune-"));
1082
+ const write = (entries: Record<string, unknown>) => {
1083
+ const planPath = join(dir, `plan-${Object.keys(entries).length}.json`);
1084
+ writeFileSync(planPath, JSON.stringify(entries));
1085
+ return planPath;
1086
+ };
1087
+ const app = { endpoint: "/v1/apps", method: "POST", body: { app_name: "solo" } };
1088
+ const machine = (name: string) => ({
1089
+ endpoint: "/v1/apps/solo/machines",
1090
+ method: "POST",
1091
+ body: { name, config: { image: "nginx" } },
1092
+ });
1093
+ try {
1094
+ const fake = createMachinesFake();
1095
+ const wait = { intervalMs: 0, deadlineMs: 5_000 };
1096
+ await flyApply({ planPath: write({ app, web: machine("web"), orphan: machine("orphan") }), endpoint: CTX.base, wait }, undefined, fake.http);
1097
+ // A machine chant did not create: no ownership marker, running.
1098
+ fake.machines.get("solo")!.push({ id: "m-foreign", name: "foreign", state: "started", instance_id: "I-foreign", config: { image: "redis" } });
1099
+ // The serializer stamps the marker; this plan is hand-written, so stamp it on the fake.
1100
+ for (const m of fake.machines.get("solo")!) if (m.name !== "foreign") m.config.metadata = { ...OWNED_META };
1101
+ const orphanId = fake.machine("solo", "orphan")!.id;
1102
+
1103
+ const deletes: string[] = [];
1104
+ const http: FlyHttp = async (method, url, body, headers, signal) => {
1105
+ if (method === "DELETE" && !url.endsWith("/lease")) deletes.push(url.replace(orphanId, "orphan"));
1106
+ return fake.http(method, url, body, headers, signal);
1107
+ };
1108
+ const result = toApplyResult(
1109
+ await flyApply({ planPath: write({ app, web: machine("web") }), endpoint: CTX.base, wait, prune: true }, undefined, http),
1110
+ );
1111
+ return { result, deletes };
1112
+ } finally {
1113
+ rmSync(dir, { recursive: true, force: true });
1114
+ }
1115
+ },
1116
+ ownedOrphan: "/machines/orphan",
1117
+ foreign: "m-foreign",
1118
+ },
1119
+ ],
639
1120
  });