@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
@@ -26,6 +26,7 @@ import { readFileSync } from "node:fs";
26
26
  import { sleep } from "@intentius/chant/op";
27
27
  import { hasOwnershipMarker } from "@intentius/chant/ownership";
28
28
  import { FLY_METADATA_OWNERSHIP_KEYS } from "../../ownership";
29
+ import { logProgress } from "./progress";
29
30
  import {
30
31
  applyResult,
31
32
  type ApplyResult,
@@ -50,8 +51,16 @@ export interface FlapsRequest {
50
51
  * POSTs these and excludes them from any drift/diff.
51
52
  */
52
53
  applyOnly?: boolean;
54
+ /**
55
+ * A Secret declared without a value: set outside chant. `flyApply` never
56
+ * POSTs it; it checks the app has the secret and fails naming it if not.
57
+ */
58
+ mustExist?: boolean;
53
59
  }
54
60
 
61
+ // Progress lines go to stderr (#2516); the helper is shared with the Sprites activities.
62
+ export { logProgress } from "./progress";
63
+
55
64
  /** The serializer's whole output: entity name → flaps create request. */
56
65
  export type FlyPlan = Record<string, FlapsRequest>;
57
66
 
@@ -111,9 +120,13 @@ export function isIpRequest(req: FlapsRequest): boolean {
111
120
  return /^\/v1\/apps\/[^/]+\/ip_assignments\/?$/.test(req.endpoint);
112
121
  }
113
122
 
114
- /** True when a request creates a Certificate (`.../certificates`). Pure. */
123
+ /**
124
+ * True when a request creates a Certificate: `.../certificates/acme`, or the
125
+ * bare `.../certificates` that plans built before #3114 carry. Both apply the
126
+ * same way, through {@link applyCert}. Pure.
127
+ */
115
128
  export function isCertRequest(req: FlapsRequest): boolean {
116
- return /^\/v1\/apps\/[^/]+\/certificates\/?$/.test(req.endpoint);
129
+ return /^\/v1\/apps\/[^/]+\/certificates(\/acme)?\/?$/.test(req.endpoint);
117
130
  }
118
131
 
119
132
  /** True when a request sets a Secret (`.../secrets/{name}`). Pure. */
@@ -235,6 +248,9 @@ const ipUrl = (base: string, app: string, ip: string): string => `${ipsUrl(base,
235
248
  const certsUrl = (base: string, app: string): string => `${appUrl(base, app)}/certificates`;
236
249
  const certUrl = (base: string, app: string, hostname: string): string =>
237
250
  `${certsUrl(base, app)}/${encodeURIComponent(hostname)}`;
251
+ // Certificates are listed at `.../certificates` but created at `.../acme`:
252
+ // flaps has no POST on the list path and answers one with 404 (#3114).
253
+ const acmeCertUrl = (base: string, app: string): string => `${certsUrl(base, app)}/acme`;
238
254
  const secretsUrl = (base: string, app: string): string => `${appUrl(base, app)}/secrets`;
239
255
  const secretUrl = (base: string, app: string, secretName: string): string =>
240
256
  `${secretsUrl(base, app)}/${encodeURIComponent(secretName)}`;
@@ -487,22 +503,103 @@ export async function applyMachine(
487
503
  return { action: "updated", id: m.id, name };
488
504
  }
489
505
 
490
- /** Lease → destroy → wait for the machine to be reaped. */
506
+ /** How {@link destroyMachine} treats a machine that is still running. */
507
+ export interface DestroyOpts extends WaitOpts {
508
+ /**
509
+ * Destroy with `?force=true`, which kills a running machine at once, instead
510
+ * of stopping it first. Default false: stop, wait for `stopped`, then delete.
511
+ */
512
+ force?: boolean;
513
+ /** The machine's last listed state. It decides whether a stop is needed first; unknown means try the delete. */
514
+ state?: string;
515
+ /** The machine's name, for error messages. */
516
+ name?: string;
517
+ }
518
+
519
+ /**
520
+ * States flaps destroys without `force`. A started machine answers a bare
521
+ * DELETE with 412 failed_precondition (#3115).
522
+ */
523
+ const DESTROYABLE_STATES = new Set(["stopped", "suspended", "failed", "created"]);
524
+
525
+ /** True when a destroy response is flaps refusing a machine that is not stopped. Pure. */
526
+ export function isNotStoppedRefusal(status: number, text: string): boolean {
527
+ return status === 412 || (status >= 400 && /failed_precondition/.test(text));
528
+ }
529
+
530
+ /**
531
+ * Stop a machine under a lease, then wait for `stopped` with no lease held (a
532
+ * slow stop can outlive the lease TTL). A machine already `stopping` is only
533
+ * waited on. A wait that runs out fails naming the machine.
534
+ */
535
+ async function stopForDestroy(
536
+ ctx: ApplyCtx,
537
+ app: string,
538
+ id: string,
539
+ http: FlyHttp,
540
+ signal: AbortSignal | undefined,
541
+ opts: DestroyOpts,
542
+ state: string | undefined,
543
+ ): Promise<void> {
544
+ const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
545
+ if (state !== "stopping") {
546
+ const res = await withLease(ctx, app, id, http, signal, (nonce) =>
547
+ http("POST", `${machineUrl(ctx.base, app, id)}/stop`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
548
+ );
549
+ if (res.status === 404) return;
550
+ if (res.status >= 300) throw new Error(`machine ${label} stop before destroy failed (${res.status}): ${res.text}`);
551
+ }
552
+ const { timeoutSecs, intervalMs, deadlineMs } = opts;
553
+ try {
554
+ await waitForMachine(ctx, app, id, "", http, signal, { timeoutSecs, intervalMs, deadlineMs, state: "stopped" });
555
+ } catch (err) {
556
+ const why = err instanceof Error ? err.message : String(err);
557
+ throw new Error(
558
+ `machine ${label} did not stop, so it was not destroyed (${why}). ` +
559
+ `Check the machine, or destroy it with force: true to kill it without a stop.`,
560
+ );
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Destroy a machine and wait for it to be reaped. flaps refuses a bare DELETE
566
+ * on a running machine (412, #3115), so by default a machine that is not
567
+ * stopped is stopped first (each mutation under a lease), and a 412 from the
568
+ * delete (a machine that started after it was listed) gets one stop and retry.
569
+ * With `force`, the delete carries `?force=true` and no stop is sent.
570
+ */
491
571
  export async function destroyMachine(
492
572
  ctx: ApplyCtx,
493
573
  app: string,
494
574
  id: string,
495
575
  http: FlyHttp,
496
576
  signal?: AbortSignal,
497
- opts: WaitOpts = {},
577
+ opts: DestroyOpts = {},
498
578
  ): Promise<void> {
499
- const res = await withLease(ctx, app, id, http, signal, (nonce) =>
500
- http("DELETE", machineUrl(ctx.base, app, id), undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
501
- );
502
- if (res.status >= 300 && res.status !== 404) {
503
- throw new Error(`machine ${app}/${id} destroy failed (${res.status}): ${res.text}`);
579
+ const { force, state } = opts;
580
+ const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
581
+ const waitOpts = { timeoutSecs: opts.timeoutSecs, intervalMs: opts.intervalMs, deadlineMs: opts.deadlineMs };
582
+ const gone = state === "destroyed" || state === "destroying";
583
+
584
+ if (!gone) {
585
+ if (!force && state !== undefined && !DESTROYABLE_STATES.has(state)) {
586
+ await stopForDestroy(ctx, app, id, http, signal, opts, state);
587
+ }
588
+ const url = force ? `${machineUrl(ctx.base, app, id)}?force=true` : machineUrl(ctx.base, app, id);
589
+ const del = () =>
590
+ withLease(ctx, app, id, http, signal, (nonce) =>
591
+ http("DELETE", url, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal),
592
+ );
593
+ let res = await del();
594
+ if (!force && isNotStoppedRefusal(res.status, res.text)) {
595
+ await stopForDestroy(ctx, app, id, http, signal, opts, undefined);
596
+ res = await del();
597
+ }
598
+ if (res.status >= 300 && res.status !== 404) {
599
+ throw new Error(`machine ${label} destroy failed (${res.status}): ${res.text}`);
600
+ }
504
601
  }
505
- await waitForMachine(ctx, app, id, "", http, signal, { ...opts, state: "destroyed" });
602
+ await waitForMachine(ctx, app, id, "", http, signal, { ...waitOpts, state: "destroyed" });
506
603
  }
507
604
 
508
605
  /** Delete an app (idempotent; a 404 means it is already gone). */
@@ -522,7 +619,8 @@ export async function deleteApp(
522
619
  * Owned-only prune (D2): for one app, destroy the chant-owned machines whose
523
620
  * name is not in `keep`. An unmarked machine (no `managed-by: chant`) is never
524
621
  * touched, so it survives an apply that would otherwise delete it. Machines
525
- * already tearing down are skipped.
622
+ * already tearing down are skipped. A running machine is stopped before it is
623
+ * destroyed unless `opts.force` is set (see {@link destroyMachine}).
526
624
  */
527
625
  export async function pruneMachines(
528
626
  ctx: ApplyCtx,
@@ -530,14 +628,14 @@ export async function pruneMachines(
530
628
  keep: Set<string>,
531
629
  http: FlyHttp,
532
630
  signal?: AbortSignal,
533
- opts: WaitOpts = {},
631
+ opts: WaitOpts & { force?: boolean } = {},
534
632
  ): Promise<Array<{ app: string; name: string; id: string }>> {
535
633
  const pruned: Array<{ app: string; name: string; id: string }> = [];
536
634
  for (const m of await listMachines(ctx, app, http, signal)) {
537
635
  if (!isChantOwned(m.config?.metadata) || keep.has(m.name)) continue;
538
636
  if (m.state === "destroyed" || m.state === "destroying") continue;
539
- await destroyMachine(ctx, app, m.id, http, signal, opts);
540
- console.log(`pruned: ${app}/${m.name} (${ctx.base})`);
637
+ await destroyMachine(ctx, app, m.id, http, signal, { ...opts, state: m.state, name: m.name });
638
+ logProgress(`pruned: ${app}/${m.name} (${ctx.base})`);
541
639
  pruned.push({ app, name: m.name, id: m.id });
542
640
  }
543
641
  return pruned;
@@ -627,13 +725,49 @@ export async function listIps(ctx: ApplyCtx, app: string, http: FlyHttp, signal?
627
725
  return body?.ips ?? [];
628
726
  }
629
727
 
630
- /** List an app's live certificates (`{ certificates: [...] }`). */
728
+ /**
729
+ * The page size asked of the certificate list. Fly pages it whether or not a
730
+ * `limit` is given (default 25, max 500), so asking for the max keeps the
731
+ * common case to one request.
732
+ */
733
+ export const CERT_PAGE_LIMIT = 500;
734
+
735
+ /**
736
+ * The most certificate pages {@link listCerts} reads before it gives up. At
737
+ * {@link CERT_PAGE_LIMIT} per page that is 100,000 certificates, far past any
738
+ * real app, so hitting it means the API never stopped handing out a cursor.
739
+ */
740
+ export const MAX_CERT_PAGES = 200;
741
+
742
+ /**
743
+ * List an app's live certificates, every page of them (#3224). Fly answers
744
+ * `{ certificates, next_cursor?, total_count }` and pages the list even
745
+ * without a `limit`; this follows `next_cursor` until it is absent. A cursor
746
+ * the API already handed out, or more than {@link MAX_CERT_PAGES} pages,
747
+ * throws rather than looping or returning a partial list, because callers
748
+ * read "not listed" as "absent" (re-apply creates it, prune skips it).
749
+ */
631
750
  export async function listCerts(ctx: ApplyCtx, app: string, http: FlyHttp, signal?: AbortSignal): Promise<FlapsCert[]> {
632
- const res = await http("GET", certsUrl(ctx.base, app), undefined, undefined, signal);
633
- if (res.status === 404) return [];
634
- if (res.status >= 300) throw new Error(`certificate list failed for ${app} (${res.status}): ${res.text}`);
635
- const body = parseJson(res.text) as { certificates?: FlapsCert[] } | undefined;
636
- return body?.certificates ?? [];
751
+ const certs: FlapsCert[] = [];
752
+ const seen = new Set<string>();
753
+ let cursor: string | undefined;
754
+ for (let page = 0; page < MAX_CERT_PAGES; page++) {
755
+ const q = new URLSearchParams({ limit: String(CERT_PAGE_LIMIT) });
756
+ if (cursor !== undefined) q.set("cursor", cursor);
757
+ const res = await http("GET", `${certsUrl(ctx.base, app)}?${q.toString()}`, undefined, undefined, signal);
758
+ // A 404 on the first page is an absent app; on a later one the list is
759
+ // half-read, so it is an error like any other.
760
+ if (res.status === 404 && page === 0) return [];
761
+ if (res.status >= 300) throw new Error(`certificate list failed for ${app} (${res.status}): ${res.text}`);
762
+ const body = parseJson(res.text) as { certificates?: FlapsCert[]; next_cursor?: string } | undefined;
763
+ certs.push(...(body?.certificates ?? []));
764
+ const next = body?.next_cursor;
765
+ if (!next) return certs;
766
+ if (seen.has(next)) throw new Error(`certificate list for ${app} repeated cursor ${JSON.stringify(next)} after ${page + 1} page(s)`);
767
+ seen.add(next);
768
+ cursor = next;
769
+ }
770
+ throw new Error(`certificate list for ${app} still had a next_cursor after ${MAX_CERT_PAGES} pages`);
637
771
  }
638
772
 
639
773
  /** List an app's live secrets (`{ secrets: [...] }`; digests only, never values). */
@@ -679,7 +813,11 @@ export async function applyIp(
679
813
  return { action: "created", type };
680
814
  }
681
815
 
682
- /** Create a certificate if absent (idempotent by hostname). */
816
+ /**
817
+ * Create an ACME certificate if absent (idempotent by hostname). Always POSTs
818
+ * `{ hostname }` to `.../certificates/acme`, whatever endpoint the plan entry
819
+ * names, so a plan built before #3114 applies correctly too.
820
+ */
683
821
  export async function applyCert(
684
822
  ctx: ApplyCtx,
685
823
  app: string,
@@ -692,11 +830,42 @@ export async function applyCert(
692
830
  if ((await listCerts(ctx, app, http, signal)).some((c) => c.hostname === hostname)) {
693
831
  return { action: "noop", hostname };
694
832
  }
695
- const res = await http("POST", certsUrl(ctx.base, app), req.body, undefined, signal);
833
+ const res = await http("POST", acmeCertUrl(ctx.base, app), { hostname }, undefined, signal);
696
834
  if (res.status >= 300) throw new Error(`certificate ${app}/${hostname} create failed (${res.status}): ${res.text}`);
697
835
  return { action: "created", hostname };
698
836
  }
699
837
 
838
+ /**
839
+ * True when a plan entry is a Secret with no value, which apply must never
840
+ * POST. Plans built before `mustExist` existed mark it with an empty body.
841
+ * Pure.
842
+ */
843
+ export function isValuelessSecret(req: FlapsRequest): boolean {
844
+ return isSecretRequest(req) && (req.mustExist === true || req.body.value === undefined);
845
+ }
846
+
847
+ /**
848
+ * Check a value-less secret (set outside chant) exists on the app. Never
849
+ * POSTs. Throws naming the secret when the app does not have it.
850
+ */
851
+ export async function checkSecretExists(
852
+ ctx: ApplyCtx,
853
+ app: string,
854
+ name: string,
855
+ http: FlyHttp,
856
+ signal?: AbortSignal,
857
+ ): Promise<{ action: "exists"; name: string }> {
858
+ const live = await listSecrets(ctx, app, http, signal);
859
+ if (!live.some((s) => s.name === name)) {
860
+ throw new Error(
861
+ `secret ${app}/${name} is declared without a value, so it must already exist, ` +
862
+ `and app ${app} does not have it. Set it with \`fly secrets set ${name}=...\` ` +
863
+ `or give the Secret a value.`,
864
+ );
865
+ }
866
+ return { action: "exists", name };
867
+ }
868
+
700
869
  /**
701
870
  * Set a secret (D7, apply-only): always POST, never read back for a diff. flaps
702
871
  * returns only a digest, so there is nothing to compare — every apply re-sets it.
@@ -727,7 +896,7 @@ export async function pruneVolumes(
727
896
  if (keep.has(v.name)) continue;
728
897
  const res = await http("DELETE", volumeUrl(ctx.base, app, v.id), undefined, undefined, signal);
729
898
  if (res.status >= 300 && res.status !== 404) throw new Error(`volume ${app}/${v.name} delete failed (${res.status}): ${res.text}`);
730
- console.log(`pruned: volume/${app}/${v.name} (${ctx.base})`);
899
+ logProgress(`pruned: volume/${app}/${v.name} (${ctx.base})`);
731
900
  pruned.push({ app, name: v.name, id: v.id });
732
901
  }
733
902
  return pruned;
@@ -746,7 +915,7 @@ export async function pruneIps(
746
915
  if (keep.has(ipType(ip.shared, ip.ip))) continue;
747
916
  const res = await http("DELETE", ipUrl(ctx.base, app, ip.ip), undefined, undefined, signal);
748
917
  if (res.status >= 300 && res.status !== 404) throw new Error(`ip ${app}/${ip.ip} delete failed (${res.status}): ${res.text}`);
749
- console.log(`pruned: ip/${app}/${ip.ip} (${ctx.base})`);
918
+ logProgress(`pruned: ip/${app}/${ip.ip} (${ctx.base})`);
750
919
  pruned.push({ app, address: ip.ip });
751
920
  }
752
921
  return pruned;
@@ -765,7 +934,7 @@ export async function pruneCerts(
765
934
  if (keep.has(c.hostname)) continue;
766
935
  const res = await http("DELETE", certUrl(ctx.base, app, c.hostname), undefined, undefined, signal);
767
936
  if (res.status >= 300 && res.status !== 404) throw new Error(`certificate ${app}/${c.hostname} delete failed (${res.status}): ${res.text}`);
768
- console.log(`pruned: certificate/${app}/${c.hostname} (${ctx.base})`);
937
+ logProgress(`pruned: certificate/${app}/${c.hostname} (${ctx.base})`);
769
938
  pruned.push({ app, hostname: c.hostname });
770
939
  }
771
940
  return pruned;
@@ -787,7 +956,7 @@ export async function pruneSecrets(
787
956
  if (keep.has(s.name)) continue;
788
957
  const res = await http("DELETE", secretUrl(ctx.base, app, s.name), undefined, undefined, signal);
789
958
  if (res.status >= 300 && res.status !== 404) throw new Error(`secret ${app}/${s.name} delete failed (${res.status}): ${res.text}`);
790
- console.log(`pruned: secret/${app}/${s.name} (${ctx.base})`);
959
+ logProgress(`pruned: secret/${app}/${s.name} (${ctx.base})`);
791
960
  pruned.push({ app, name: s.name });
792
961
  }
793
962
  return pruned;
@@ -817,6 +986,12 @@ export interface FlyApplyArgs {
817
986
  * default.
818
987
  */
819
988
  prune?: boolean;
989
+ /**
990
+ * Destroy machines (prune, `flyDelete`) with `?force=true`, killing a running
991
+ * machine at once. Default false: stop it, wait for `stopped`, then destroy,
992
+ * so it can shut down on its configured kill signal and timeout.
993
+ */
994
+ force?: boolean;
820
995
  /** Wait-loop tuning (mainly for tests). */
821
996
  wait?: WaitOpts;
822
997
  }
@@ -853,7 +1028,7 @@ export function toApplyResult(result: {
853
1028
  volumes: Array<{ app: string; name: string; action: "created" | "noop" }>;
854
1029
  ips: Array<{ app: string; type: string; action: "created" | "noop" }>;
855
1030
  certs: Array<{ app: string; hostname: string; action: "created" | "noop" }>;
856
- secrets: Array<{ app: string; name: string }>;
1031
+ secrets: Array<{ app: string; name: string; action?: "set" | "exists" }>;
857
1032
  pruned: Array<{ app: string; name: string; id: string }>;
858
1033
  prunedVolumes: Array<{ app: string; name: string; id: string }>;
859
1034
  prunedIps: Array<{ app: string; address: string }>;
@@ -873,9 +1048,14 @@ export function toApplyResult(result: {
873
1048
  ...result.volumes.map((x) => ({ kind: "volume", name: x.name, action: act(x.action) })),
874
1049
  ...result.ips.map((x) => ({ kind: "ip", name: x.type, action: act(x.action) })),
875
1050
  ...result.certs.map((x) => ({ kind: "cert", name: x.hostname, action: act(x.action) })),
876
- // Secrets are apply-only (D7) — POSTed every run, never read back, so there
877
- // is no unchanged to report.
878
- ...result.secrets.map((x) => ({ kind: "secret", name: x.name, action: "updated" as const })),
1051
+ // Secrets are apply-only (D7) — POSTed every run, never read back, so a
1052
+ // set secret reports updated. A value-less secret is only checked, so it
1053
+ // reports unchanged.
1054
+ ...result.secrets.map((x) => ({
1055
+ kind: "secret",
1056
+ name: x.name,
1057
+ action: (x.action === "exists" ? "unchanged" : "updated") as AppliedResource["action"],
1058
+ })),
879
1059
  ];
880
1060
  const pruned: PrunedResource[] = [
881
1061
  ...result.pruned.map((x) => ({ kind: "machine", name: x.name, deleted: true })),
@@ -897,7 +1077,7 @@ export async function flyApply(
897
1077
  volumes: Array<{ app: string; name: string; action: "created" | "noop" }>;
898
1078
  ips: Array<{ app: string; type: string; action: "created" | "noop" }>;
899
1079
  certs: Array<{ app: string; hostname: string; action: "created" | "noop" }>;
900
- secrets: Array<{ app: string; name: string }>;
1080
+ secrets: Array<{ app: string; name: string; action?: "set" | "exists" }>;
901
1081
  pruned: Array<{ app: string; name: string; id: string }>;
902
1082
  prunedVolumes: Array<{ app: string; name: string; id: string }>;
903
1083
  prunedIps: Array<{ app: string; address: string }>;
@@ -946,7 +1126,7 @@ export async function flyApply(
946
1126
  const apps: Array<{ app: string; created: boolean }> = [];
947
1127
  for (const req of appReqs) {
948
1128
  const result = await applyApp(ctx, req, http, signal);
949
- console.log(`${result.created ? "created" : "unchanged"}: app/${result.app} (${ctx.base})`);
1129
+ logProgress(`${result.created ? "created" : "unchanged"}: app/${result.app} (${ctx.base})`);
950
1130
  apps.push(result);
951
1131
  }
952
1132
 
@@ -978,7 +1158,7 @@ export async function flyApply(
978
1158
  const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
979
1159
  const result = await applyVolume(ctx, app, entityName, req, http, signal);
980
1160
  track(keepVolumes, app, result.name);
981
- console.log(`${result.action}: volume/${app}/${result.name} (${ctx.base})`);
1161
+ logProgress(`${result.action}: volume/${app}/${result.name} (${ctx.base})`);
982
1162
  volumes.push({ app, name: result.name, action: result.action });
983
1163
  }
984
1164
 
@@ -988,7 +1168,7 @@ export async function flyApply(
988
1168
  const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
989
1169
  track(keepMachines, app, name);
990
1170
  const result = await applyMachine(ctx, app, entityName, req, http, signal, opts);
991
- console.log(`${result.action}: machine/${app}/${result.name} (${ctx.base})`);
1171
+ logProgress(`${result.action}: machine/${app}/${result.name} (${ctx.base})`);
992
1172
  machines.push({ app, name: result.name, action: result.action });
993
1173
  }
994
1174
 
@@ -998,7 +1178,7 @@ export async function flyApply(
998
1178
  const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
999
1179
  const result = await applyIp(ctx, app, req, http, signal);
1000
1180
  track(keepIps, app, result.type);
1001
- console.log(`${result.action}: ip/${app}/${result.type} (${ctx.base})`);
1181
+ logProgress(`${result.action}: ip/${app}/${result.type} (${ctx.base})`);
1002
1182
  ips.push({ app, type: result.type, action: result.action });
1003
1183
  }
1004
1184
 
@@ -1007,19 +1187,22 @@ export async function flyApply(
1007
1187
  const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
1008
1188
  const result = await applyCert(ctx, app, req, http, signal);
1009
1189
  track(keepCerts, app, result.hostname);
1010
- console.log(`${result.action}: certificate/${app}/${result.hostname} (${ctx.base})`);
1190
+ logProgress(`${result.action}: certificate/${app}/${result.hostname} (${ctx.base})`);
1011
1191
  certs.push({ app, hostname: result.hostname, action: result.action });
1012
1192
  }
1013
1193
 
1014
- // Secrets are apply-only (D7): always set, never read back for a diff.
1015
- const secrets: Array<{ app: string; name: string }> = [];
1194
+ // Secrets are apply-only (D7): a secret with a value is set every run and
1195
+ // never read back for a diff. A value-less one is only checked for.
1196
+ const secrets: Array<{ app: string; name: string; action: "set" | "exists" }> = [];
1016
1197
  for (const [, req] of secretReqs) {
1017
1198
  const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
1018
1199
  const name = secretNameSegment(req.endpoint);
1019
1200
  track(keepSecrets, app, name);
1020
- const result = await applySecret(ctx, app, name, req, http, signal);
1021
- console.log(`set: secret/${app}/${result.name} (${ctx.base})`);
1022
- secrets.push({ app, name: result.name });
1201
+ const result = isValuelessSecret(req)
1202
+ ? await checkSecretExists(ctx, app, name, http, signal)
1203
+ : await applySecret(ctx, app, name, req, http, signal);
1204
+ logProgress(`${result.action}: secret/${app}/${result.name} (${ctx.base})`);
1205
+ secrets.push({ app, name: result.name, action: result.action });
1023
1206
  }
1024
1207
 
1025
1208
  const pruned: Array<{ app: string; name: string; id: string }> = [];
@@ -1029,7 +1212,7 @@ export async function flyApply(
1029
1212
  const prunedSecrets: Array<{ app: string; name: string }> = [];
1030
1213
  if (args.prune) {
1031
1214
  for (const [app, keep] of keepMachines) {
1032
- pruned.push(...(await pruneMachines(ctx, app, keep, http, signal, opts)));
1215
+ pruned.push(...(await pruneMachines(ctx, app, keep, http, signal, { ...opts, force: args.force })));
1033
1216
  }
1034
1217
  for (const [app, keep] of keepVolumes) {
1035
1218
  prunedVolumes.push(...(await pruneVolumes(ctx, app, keep, http, signal)));
@@ -1050,7 +1233,8 @@ export async function flyApply(
1050
1233
 
1051
1234
  /**
1052
1235
  * The inverse of {@link flyApply}: destroy the machines the plan declares, then
1053
- * delete the apps (dependents before their app). Idempotent — already-absent
1236
+ * delete the apps (dependents before their app). A running machine is stopped
1237
+ * first unless `args.force` is set. Idempotent — already-absent
1054
1238
  * resources are a no-op. `http` is injectable for tests.
1055
1239
  */
1056
1240
  export async function flyDelete(
@@ -1076,14 +1260,14 @@ export async function flyDelete(
1076
1260
  const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
1077
1261
  const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
1078
1262
  if (!live) continue;
1079
- await destroyMachine(ctx, app, live.id, http, signal, opts);
1263
+ await destroyMachine(ctx, app, live.id, http, signal, { ...opts, force: args.force, state: live.state, name });
1080
1264
  machines.push({ app, name });
1081
1265
  }
1082
1266
 
1083
1267
  const apps: Array<{ app: string; deleted: boolean }> = [];
1084
1268
  for (const req of appReqs) {
1085
1269
  const result = await deleteApp(ctx, appNameFromRequest(req), http, signal);
1086
- console.log(`${result.deleted ? "deleted" : "absent"}: app/${result.app} (${ctx.base})`);
1270
+ logProgress(`${result.deleted ? "deleted" : "absent"}: app/${result.app} (${ctx.base})`);
1087
1271
  apps.push(result);
1088
1272
  }
1089
1273
 
@@ -14,6 +14,11 @@ export {
14
14
  destroyMachine,
15
15
  deleteApp,
16
16
  listMachines,
17
+ listVolumes,
18
+ listIps,
19
+ listCerts,
20
+ listSecrets,
21
+ checkSecretExists,
17
22
  pruneMachines,
18
23
  waitForMachine,
19
24
  acquireLease,
@@ -23,7 +28,16 @@ export {
23
28
  parsePlan,
24
29
  isAppRequest,
25
30
  isMachineRequest,
31
+ isVolumeRequest,
32
+ isIpRequest,
33
+ isCertRequest,
34
+ isSecretRequest,
35
+ isValuelessSecret,
26
36
  machineAppSegment,
37
+ resourceAppSegment,
38
+ secretNameSegment,
39
+ ipType,
40
+ declaredIpType,
27
41
  resolveApp,
28
42
  appNameFromRequest,
29
43
  isChantOwned,
@@ -33,7 +47,19 @@ export {
33
47
  DEFAULT_FLAPS_BASE_URL,
34
48
  LEASE_NONCE_HEADER,
35
49
  } from "./fly-apply";
36
- export type { FlyApplyArgs, FlyPlan, FlapsRequest, FlapsMachine, FlyHttp, WaitOpts, ApplyCtx } from "./fly-apply";
50
+ export type {
51
+ FlyApplyArgs,
52
+ FlyPlan,
53
+ FlapsRequest,
54
+ FlapsMachine,
55
+ FlapsVolume,
56
+ FlapsIp,
57
+ FlapsCert,
58
+ FlapsSecret,
59
+ FlyHttp,
60
+ WaitOpts,
61
+ ApplyCtx,
62
+ } from "./fly-apply";
37
63
 
38
64
  // The release a Machine serves and the site steps over the Machines API
39
65
  // (#2736, ws-056): upload and start, exec (migrations), restart, stop, verify,
@@ -16,6 +16,17 @@ const PORT = 4283;
16
16
  let available = false;
17
17
  let endpoint = "";
18
18
 
19
+ /**
20
+ * Contract endpoints the pinned mudflaps is known not to serve, each with the
21
+ * upstream issue. The applier calls the real flaps path; the docker-gated
22
+ * apply test shims these. A gap the pin has since closed fails the last test
23
+ * below, so the entry and the shim get removed with the pin bump.
24
+ */
25
+ const MUDFLAPS_KNOWN_GAPS = new Map<string, string>([
26
+ // ACME certificate create (#3114); mudflaps only serves POST .../certificates.
27
+ [normalizeEndpoint("POST", "/v1/apps/{app}/certificates/acme"), "INTENTIUS/mudflaps#69"],
28
+ ]);
29
+
19
30
  /** Parse a mudflaps health `implemented` entry ("METHOD path (note)") to a normalized key. */
20
31
  function normalizeImplemented(entry: string): string {
21
32
  const stripped = entry.replace(/\s*\(.*\)\s*$/, "").trim();
@@ -52,7 +63,7 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
52
63
 
53
64
  const served = new Set((health.implemented ?? []).map(normalizeImplemented));
54
65
 
55
- const missing = [...contractKeys()].filter((k) => !served.has(k));
66
+ const missing = [...contractKeys()].filter((k) => !served.has(k) && !MUDFLAPS_KNOWN_GAPS.has(k));
56
67
  // A non-empty list means flyApply calls something mudflaps can't serve — a
57
68
  // real fidelity gap between the applier and the pinned emulator.
58
69
  expect(missing, `mudflaps is missing contract endpoints: ${missing.join(", ")}`).toEqual([]);
@@ -78,4 +89,14 @@ describe("Machines contract ⊆ mudflaps implemented paths", () => {
78
89
  const missing = MACHINE_RELEASE_CONTRACT.map((e) => normalizeEndpoint(e.method, e.path)).filter((k) => !served.has(k));
79
90
  expect(missing, `mudflaps is missing release endpoints: ${missing.join(", ")}`).toEqual([]);
80
91
  });
92
+
93
+ test("every known mudflaps gap is still a gap (drop the entry when the pin closes it)", async (ctx) => {
94
+ if (!available) ctx.skip();
95
+ const res = await fetch(`${endpoint}/_mudflaps/health`);
96
+ const health = (await res.json()) as { implemented?: string[] };
97
+ const served = new Set((health.implemented ?? []).map(normalizeImplemented));
98
+ for (const [key, issue] of MUDFLAPS_KNOWN_GAPS) {
99
+ expect(served.has(key), `${key} is served now (${issue}): remove it from MUDFLAPS_KNOWN_GAPS and the apply-test shim`).toBe(false);
100
+ }
101
+ });
81
102
  });
@@ -39,6 +39,9 @@ export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
39
39
  { method: "GET", path: "/v1/apps/{app}/machines", op: "listMachines" },
40
40
  { method: "POST", path: "/v1/apps/{app}/machines", op: "createMachine" },
41
41
  { method: "POST", path: "/v1/apps/{app}/machines/{id}", op: "updateMachine" },
42
+ // A running machine is stopped before the destroy (#3115); `?force=true` is a
43
+ // query, so it stays out of the path template.
44
+ { method: "POST", path: "/v1/apps/{app}/machines/{id}/stop", op: "stopForDestroy" },
42
45
  { method: "DELETE", path: "/v1/apps/{app}/machines/{id}", op: "destroyMachine" },
43
46
  { method: "GET", path: "/v1/apps/{app}/machines/{id}/wait", op: "waitForMachine" },
44
47
  // Leases
@@ -54,7 +57,8 @@ export const MACHINES_CONTRACT: readonly MachinesEndpoint[] = [
54
57
  { method: "DELETE", path: "/v1/apps/{app}/ip_assignments/{ip}", op: "releaseIp" },
55
58
  // Certificates
56
59
  { method: "GET", path: "/v1/apps/{app}/certificates", op: "listCerts" },
57
- { method: "POST", path: "/v1/apps/{app}/certificates", op: "addCert" },
60
+ // Created at /acme: flaps has no POST on the list path (#3114).
61
+ { method: "POST", path: "/v1/apps/{app}/certificates/acme", op: "addCert" },
58
62
  { method: "DELETE", path: "/v1/apps/{app}/certificates/{hostname}", op: "deleteCert" },
59
63
  // Secrets
60
64
  { method: "GET", path: "/v1/apps/{app}/secrets", op: "listSecrets" },