@intentius/chant-lexicon-fly 0.16.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 (149) hide show
  1. package/README.md +91 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +27 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +6 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +18 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/fly-deploy.d.ts +110 -0
  19. package/dist/composites/fly-deploy.d.ts.map +1 -0
  20. package/dist/describe-resources.d.ts +74 -0
  21. package/dist/describe-resources.d.ts.map +1 -0
  22. package/dist/detect.d.ts +22 -0
  23. package/dist/detect.d.ts.map +1 -0
  24. package/dist/export-resources.d.ts +25 -0
  25. package/dist/export-resources.d.ts.map +1 -0
  26. package/dist/generated/index.d.ts +42 -0
  27. package/dist/generated/index.d.ts.map +1 -0
  28. package/dist/generated/runtime.d.ts +5 -0
  29. package/dist/generated/runtime.d.ts.map +1 -0
  30. package/dist/import/generator.d.ts +21 -0
  31. package/dist/import/generator.d.ts.map +1 -0
  32. package/dist/import/live-export.d.ts +42 -0
  33. package/dist/import/live-export.d.ts.map +1 -0
  34. package/dist/import/parser.d.ts +40 -0
  35. package/dist/import/parser.d.ts.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/integrity.json +9 -0
  39. package/dist/lint/post-synth/fly-helpers.d.ts +15 -0
  40. package/dist/lint/post-synth/fly-helpers.d.ts.map +1 -0
  41. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts +10 -0
  42. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts.map +1 -0
  43. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +12 -0
  44. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -0
  45. package/dist/lint/post-synth/index.d.ts +3 -0
  46. package/dist/lint/post-synth/index.d.ts.map +1 -0
  47. package/dist/lint/rules/guest-sizing.d.ts +9 -0
  48. package/dist/lint/rules/guest-sizing.d.ts.map +1 -0
  49. package/dist/lint/rules/index.d.ts +7 -0
  50. package/dist/lint/rules/index.d.ts.map +1 -0
  51. package/dist/lint/rules/no-secret-literals.d.ts +13 -0
  52. package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
  53. package/dist/lint/rules/valid-region.d.ts +9 -0
  54. package/dist/lint/rules/valid-region.d.ts.map +1 -0
  55. package/dist/lsp/completions.d.ts +9 -0
  56. package/dist/lsp/completions.d.ts.map +1 -0
  57. package/dist/lsp/hover.d.ts +9 -0
  58. package/dist/lsp/hover.d.ts.map +1 -0
  59. package/dist/manifest.json +4 -0
  60. package/dist/meta.json +285 -0
  61. package/dist/op/activities/flaps.d.ts +29 -0
  62. package/dist/op/activities/flaps.d.ts.map +1 -0
  63. package/dist/op/activities/fly-apply.d.ts +382 -0
  64. package/dist/op/activities/fly-apply.d.ts.map +1 -0
  65. package/dist/op/activities/index.d.ts +12 -0
  66. package/dist/op/activities/index.d.ts.map +1 -0
  67. package/dist/ownership.d.ts +27 -0
  68. package/dist/ownership.d.ts.map +1 -0
  69. package/dist/package-cli.d.ts +3 -0
  70. package/dist/package-cli.d.ts.map +1 -0
  71. package/dist/plugin.d.ts +8 -0
  72. package/dist/plugin.d.ts.map +1 -0
  73. package/dist/pseudo.d.ts +23 -0
  74. package/dist/pseudo.d.ts.map +1 -0
  75. package/dist/serializer.d.ts +36 -0
  76. package/dist/serializer.d.ts.map +1 -0
  77. package/dist/spec/fetch.d.ts +12 -0
  78. package/dist/spec/fetch.d.ts.map +1 -0
  79. package/dist/spec/parse.d.ts +58 -0
  80. package/dist/spec/parse.d.ts.map +1 -0
  81. package/dist/types/index.d.ts +590 -0
  82. package/dist/validate-cli.d.ts +3 -0
  83. package/dist/validate-cli.d.ts.map +1 -0
  84. package/dist/validate.d.ts +15 -0
  85. package/dist/validate.d.ts.map +1 -0
  86. package/package.json +76 -0
  87. package/src/codegen/docs-cli.ts +7 -0
  88. package/src/codegen/docs.ts +208 -0
  89. package/src/codegen/generate-cli.ts +36 -0
  90. package/src/codegen/generate-lexicon.ts +52 -0
  91. package/src/codegen/generate-typescript.ts +69 -0
  92. package/src/codegen/generate.ts +106 -0
  93. package/src/codegen/naming.ts +45 -0
  94. package/src/codegen/package.ts +25 -0
  95. package/src/composites/fly-deploy.ts +169 -0
  96. package/src/describe-resources.test.ts +188 -0
  97. package/src/describe-resources.ts +0 -0
  98. package/src/detect.ts +64 -0
  99. package/src/export-resources.ts +123 -0
  100. package/src/generated/.gitkeep +0 -0
  101. package/src/generated/index.d.ts +590 -0
  102. package/src/generated/index.ts +47 -0
  103. package/src/generated/lexicon-fly.json +285 -0
  104. package/src/generated/runtime.ts +4 -0
  105. package/src/import/generator.test.ts +123 -0
  106. package/src/import/generator.ts +242 -0
  107. package/src/import/live-export.test.ts +150 -0
  108. package/src/import/live-export.ts +107 -0
  109. package/src/import/parser.test.ts +132 -0
  110. package/src/import/parser.ts +185 -0
  111. package/src/import/roundtrip.test.ts +77 -0
  112. package/src/index.ts +23 -0
  113. package/src/lint/post-synth/fly-helpers.ts +25 -0
  114. package/src/lint/post-synth/fly010-machine-requires-image.test.ts +59 -0
  115. package/src/lint/post-synth/fly010-machine-requires-image.ts +39 -0
  116. package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +92 -0
  117. package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +61 -0
  118. package/src/lint/post-synth/index.ts +9 -0
  119. package/src/lint/rules/guest-sizing.ts +100 -0
  120. package/src/lint/rules/index.ts +15 -0
  121. package/src/lint/rules/no-secret-literals.ts +67 -0
  122. package/src/lint/rules/rules.test.ts +127 -0
  123. package/src/lint/rules/valid-region.ts +58 -0
  124. package/src/lsp/completions.test.ts +10 -0
  125. package/src/lsp/completions.ts +14 -0
  126. package/src/lsp/hover.test.ts +10 -0
  127. package/src/lsp/hover.ts +14 -0
  128. package/src/op/activities/describe-resources.integration.test.ts +105 -0
  129. package/src/op/activities/flaps.test.ts +29 -0
  130. package/src/op/activities/flaps.ts +44 -0
  131. package/src/op/activities/fly-apply.integration.test.ts +245 -0
  132. package/src/op/activities/fly-apply.test.ts +513 -0
  133. package/src/op/activities/fly-apply.ts +1014 -0
  134. package/src/op/activities/index.ts +48 -0
  135. package/src/ownership.ts +32 -0
  136. package/src/package-cli.ts +24 -0
  137. package/src/plugin.test.ts +17 -0
  138. package/src/plugin.ts +285 -0
  139. package/src/pseudo.test.ts +32 -0
  140. package/src/pseudo.ts +29 -0
  141. package/src/serializer.test.ts +232 -0
  142. package/src/serializer.ts +293 -0
  143. package/src/skills/chant-fly-ops.md +55 -0
  144. package/src/skills/chant-fly-patterns.md +71 -0
  145. package/src/skills/chant-fly.md +123 -0
  146. package/src/spec/fetch.ts +34 -0
  147. package/src/spec/parse.ts +323 -0
  148. package/src/validate-cli.ts +5 -0
  149. package/src/validate.ts +28 -0
@@ -0,0 +1,513 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ resolveEndpoint,
7
+ parsePlan,
8
+ isAppRequest,
9
+ isMachineRequest,
10
+ isVolumeRequest,
11
+ isIpRequest,
12
+ isCertRequest,
13
+ isSecretRequest,
14
+ resourceAppSegment,
15
+ secretNameSegment,
16
+ machineAppSegment,
17
+ resolveApp,
18
+ appNameFromRequest,
19
+ isChantOwned,
20
+ configEqual,
21
+ isLeaseConflict,
22
+ ipType,
23
+ declaredIpType,
24
+ applyApp,
25
+ applyMachine,
26
+ applyVolume,
27
+ applyIp,
28
+ applyCert,
29
+ applySecret,
30
+ destroyMachine,
31
+ pruneMachines,
32
+ pruneVolumes,
33
+ pruneIps,
34
+ pruneCerts,
35
+ pruneSecrets,
36
+ acquireLease,
37
+ withLease,
38
+ waitForMachine,
39
+ flyApply,
40
+ DEFAULT_FLAPS_BASE_URL,
41
+ LEASE_NONCE_HEADER,
42
+ type FlyHttp,
43
+ type FlapsRequest,
44
+ type ApplyCtx,
45
+ } from "./fly-apply";
46
+
47
+ const CTX: ApplyCtx = { base: "http://localhost:4280" };
48
+ const NO_WAIT = { intervalMs: 0, deadlineMs: 5_000 };
49
+
50
+ const OWNED_META = { "managed-by": "chant" };
51
+
52
+ function machineConfig(image: string): Record<string, unknown> {
53
+ return { image, metadata: { ...OWNED_META } };
54
+ }
55
+
56
+ /** A scripted flaps machine as flaps would return it from list/get/create. */
57
+ function liveMachine(
58
+ name: string,
59
+ image: string,
60
+ extra: Partial<{ id: string; state: string; instance_id: string; owned: boolean }> = {},
61
+ ): Record<string, unknown> {
62
+ const metadata = extra.owned === false ? { role: "db" } : { ...OWNED_META };
63
+ return {
64
+ id: extra.id ?? `m-${name}`,
65
+ name,
66
+ state: extra.state ?? "started",
67
+ instance_id: extra.instance_id ?? "INST0",
68
+ config: { image, metadata },
69
+ };
70
+ }
71
+
72
+ // ── Pure helpers ────────────────────────────────────────────────────────────
73
+
74
+ describe("resolveEndpoint (D3)", () => {
75
+ test("explicit arg wins", () => {
76
+ expect(resolveEndpoint({ endpoint: "http://localhost:4280/" }, {} as NodeJS.ProcessEnv)).toBe(
77
+ "http://localhost:4280",
78
+ );
79
+ });
80
+
81
+ test("FLY_FLAPS_BASE_URL env when no arg", () => {
82
+ expect(resolveEndpoint({}, { FLY_FLAPS_BASE_URL: "http://mf:4280" } as NodeJS.ProcessEnv)).toBe(
83
+ "http://mf:4280",
84
+ );
85
+ });
86
+
87
+ test("arg beats env", () => {
88
+ expect(
89
+ resolveEndpoint({ endpoint: "http://arg:1" }, { FLY_FLAPS_BASE_URL: "http://env:2" } as NodeJS.ProcessEnv),
90
+ ).toBe("http://arg:1");
91
+ });
92
+
93
+ test("default is real Fly", () => {
94
+ expect(resolveEndpoint({}, {} as NodeJS.ProcessEnv)).toBe(DEFAULT_FLAPS_BASE_URL);
95
+ });
96
+ });
97
+
98
+ describe("plan classification + app resolution", () => {
99
+ test("isAppRequest / isMachineRequest", () => {
100
+ expect(isAppRequest({ endpoint: "/v1/apps", method: "POST", body: {} })).toBe(true);
101
+ expect(isMachineRequest({ endpoint: "/v1/apps", method: "POST", body: {} })).toBe(false);
102
+ expect(isMachineRequest({ endpoint: "/v1/apps/foo/machines", method: "POST", body: {} })).toBe(true);
103
+ expect(isAppRequest({ endpoint: "/v1/apps/foo/machines", method: "POST", body: {} })).toBe(false);
104
+ });
105
+
106
+ test("machineAppSegment + resolveApp with placeholder", () => {
107
+ expect(machineAppSegment("/v1/apps/foo/machines")).toBe("foo");
108
+ expect(machineAppSegment("/v1/apps/{app}/machines")).toBe("{app}");
109
+ expect(resolveApp("foo", undefined)).toBe("foo");
110
+ expect(resolveApp("{app}", "sole")).toBe("sole");
111
+ expect(() => resolveApp("{app}", undefined)).toThrow(/placeholder/);
112
+ });
113
+
114
+ test("appNameFromRequest / parsePlan", () => {
115
+ expect(appNameFromRequest({ endpoint: "/v1/apps", method: "POST", body: { app_name: "a" } })).toBe("a");
116
+ expect(parsePlan('{"a":{"endpoint":"/v1/apps","method":"POST","body":{}}}')).toHaveProperty("a");
117
+ });
118
+
119
+ test("isChantOwned only for the exact marker", () => {
120
+ expect(isChantOwned({ "managed-by": "chant" })).toBe(true);
121
+ expect(isChantOwned({ "managed-by": "other" })).toBe(false);
122
+ expect(isChantOwned(undefined)).toBe(false);
123
+ });
124
+
125
+ test("configEqual is order-insensitive, catches image drift", () => {
126
+ expect(configEqual({ image: "a", metadata: { x: "1", y: "2" } }, { metadata: { y: "2", x: "1" }, image: "a" })).toBe(
127
+ true,
128
+ );
129
+ expect(configEqual({ image: "a" }, { image: "b" })).toBe(false);
130
+ });
131
+
132
+ test("isLeaseConflict: 409 lease bodies only", () => {
133
+ expect(isLeaseConflict(409, '{"code":"lease_currently_held"}')).toBe(true);
134
+ expect(isLeaseConflict(409, '{"error":"machine is leased; supply the header"}')).toBe(true);
135
+ expect(isLeaseConflict(409, '{"error":"app already exists"}')).toBe(false);
136
+ expect(isLeaseConflict(200, "lease")).toBe(false);
137
+ });
138
+ });
139
+
140
+ // ── applyApp ────────────────────────────────────────────────────────────────
141
+
142
+ describe("applyApp", () => {
143
+ const req: FlapsRequest = { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo", org_slug: "personal" } };
144
+
145
+ test("absent (GET 404) → create (POST /v1/apps)", async () => {
146
+ const calls: Array<{ method: string; url: string }> = [];
147
+ const http: FlyHttp = async (method, url) => {
148
+ calls.push({ method, url });
149
+ return method === "GET" ? { status: 404, text: "" } : { status: 201, text: "{}" };
150
+ };
151
+ expect(await applyApp(CTX, req, http)).toEqual({ app: "demo", created: true });
152
+ expect(calls.map((c) => c.method)).toEqual(["GET", "POST"]);
153
+ });
154
+
155
+ test("present (GET 200) → no create", async () => {
156
+ const http: FlyHttp = async (method) => (method === "GET" ? { status: 200, text: "{}" } : { status: 500, text: "" });
157
+ expect(await applyApp(CTX, req, http)).toEqual({ app: "demo", created: false });
158
+ });
159
+ });
160
+
161
+ // ── applyMachine ─────────────────────────────────────────────────────────────
162
+
163
+ describe("applyMachine create → wait", () => {
164
+ test("create posts, then waits on the new instance_id", async () => {
165
+ const waits: string[] = [];
166
+ const http: FlyHttp = async (method, url) => {
167
+ if (method === "GET" && url.endsWith("/machines")) return { status: 200, text: "[]" }; // list: none
168
+ if (method === "POST" && url.endsWith("/machines")) {
169
+ return { status: 200, text: JSON.stringify({ id: "m1", name: "web", state: "creating", instance_id: "NEW1", config: machineConfig("img:1") }) };
170
+ }
171
+ if (method === "GET" && url.includes("/wait")) {
172
+ waits.push(url);
173
+ return { status: 200, text: JSON.stringify({ ok: true }) };
174
+ }
175
+ return { status: 500, text: url };
176
+ };
177
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/machines", method: "POST", body: { name: "web", config: machineConfig("img:1") } };
178
+ const res = await applyMachine(CTX, "demo", "web-entity", req, http, undefined, NO_WAIT);
179
+ expect(res).toEqual({ action: "created", id: "m1", name: "web" });
180
+ expect(waits).toHaveLength(1);
181
+ expect(waits[0]).toContain("state=started");
182
+ expect(waits[0]).toContain("version=NEW1");
183
+ });
184
+
185
+ test("re-apply with an unchanged config is a no-op (no POST, no wait)", async () => {
186
+ const methods: string[] = [];
187
+ const http: FlyHttp = async (method, url) => {
188
+ methods.push(`${method} ${url.includes("/wait") ? "wait" : url.endsWith("/machines") ? "machines" : "other"}`);
189
+ if (method === "GET" && url.endsWith("/machines")) {
190
+ return { status: 200, text: JSON.stringify([liveMachine("web", "img:1")]) };
191
+ }
192
+ return { status: 500, text: "" };
193
+ };
194
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/machines", method: "POST", body: { name: "web", config: machineConfig("img:1") } };
195
+ const res = await applyMachine(CTX, "demo", "web", req, http, undefined, NO_WAIT);
196
+ expect(res).toEqual({ action: "noop", id: "m-web", name: "web" });
197
+ expect(methods).toEqual(["GET machines"]); // list only
198
+ });
199
+
200
+ test("image change → lease → update → wait on the NEW version", async () => {
201
+ const seen: string[] = [];
202
+ const nonces: string[] = [];
203
+ const http: FlyHttp = async (method, url, _body, headers) => {
204
+ if (method === "GET" && url.endsWith("/machines")) {
205
+ return { status: 200, text: JSON.stringify([liveMachine("web", "img:1", { instance_id: "OLD" })]) };
206
+ }
207
+ if (method === "POST" && url.endsWith("/lease")) {
208
+ return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N1" } }) };
209
+ }
210
+ if (method === "POST" && /\/machines\/m-web$/.test(url)) {
211
+ seen.push("update");
212
+ nonces.push(headers?.[LEASE_NONCE_HEADER] ?? "");
213
+ return { status: 200, text: JSON.stringify({ id: "m-web", name: "web", state: "replacing", instance_id: "NEW2", config: machineConfig("img:2") }) };
214
+ }
215
+ if (method === "DELETE" && url.endsWith("/lease")) return { status: 200, text: "{}" };
216
+ if (method === "GET" && url.includes("/wait")) {
217
+ seen.push(url.includes("version=NEW2") ? "wait-new" : "wait-old");
218
+ return { status: 200, text: JSON.stringify({ ok: true }) };
219
+ }
220
+ return { status: 500, text: url };
221
+ };
222
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/machines", method: "POST", body: { name: "web", config: machineConfig("img:2") } };
223
+ const res = await applyMachine(CTX, "demo", "web", req, http, undefined, NO_WAIT);
224
+ expect(res).toEqual({ action: "updated", id: "m-web", name: "web" });
225
+ expect(seen).toEqual(["update", "wait-new"]);
226
+ expect(nonces).toEqual(["N1"]);
227
+ });
228
+ });
229
+
230
+ // ── lease-conflict retry (the crown-jewel path) ──────────────────────────────
231
+
232
+ describe("lease-conflict retry", () => {
233
+ test("mutation 409 (stale nonce) → re-acquire fresh nonce → retry succeeds", async () => {
234
+ // Scripted flaps: acquire yields N1 then N2; the first update (nonce N1)
235
+ // 409s as leased, the second (nonce N2) succeeds.
236
+ const acquired = ["N1", "N2"];
237
+ let acquireIdx = 0;
238
+ const updateNonces: string[] = [];
239
+ const http: FlyHttp = async (method, url, _body, headers) => {
240
+ if (method === "GET" && url.endsWith("/machines")) {
241
+ return { status: 200, text: JSON.stringify([liveMachine("web", "img:1")]) };
242
+ }
243
+ if (method === "POST" && url.endsWith("/lease")) {
244
+ return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: acquired[acquireIdx++] } }) };
245
+ }
246
+ if (method === "POST" && /\/machines\/m-web$/.test(url)) {
247
+ const nonce = headers?.[LEASE_NONCE_HEADER] ?? "";
248
+ updateNonces.push(nonce);
249
+ if (nonce === "N1") {
250
+ return { status: 409, text: JSON.stringify({ error: "machine is leased; supply the fly-machine-lease-nonce header", status: 409 }) };
251
+ }
252
+ return { status: 200, text: JSON.stringify({ id: "m-web", name: "web", state: "replacing", instance_id: "NEW9", config: machineConfig("img:2") }) };
253
+ }
254
+ if (method === "DELETE" && url.endsWith("/lease")) return { status: 200, text: "{}" };
255
+ if (method === "GET" && url.includes("/wait")) return { status: 200, text: JSON.stringify({ ok: true }) };
256
+ return { status: 500, text: url };
257
+ };
258
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/machines", method: "POST", body: { name: "web", config: machineConfig("img:2") } };
259
+ const res = await applyMachine(CTX, "demo", "web", req, http, undefined, NO_WAIT);
260
+ expect(res.action).toBe("updated");
261
+ // Each attempt carried the nonce it acquired: stale N1 first, fresh N2 on retry.
262
+ expect(updateNonces).toEqual(["N1", "N2"]);
263
+ expect(acquireIdx).toBe(2); // acquired twice
264
+ });
265
+
266
+ test("acquireLease retries once past a lease_currently_held 409", async () => {
267
+ let n = 0;
268
+ const http: FlyHttp = async () => {
269
+ n++;
270
+ if (n === 1) {
271
+ return { status: 409, text: JSON.stringify({ status: "error", code: "lease_currently_held", message: "machine lease currently held" }) };
272
+ }
273
+ return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "FRESH" } }) };
274
+ };
275
+ expect(await acquireLease(CTX, "demo", "m1", http)).toBe("FRESH");
276
+ expect(n).toBe(2);
277
+ });
278
+
279
+ test("withLease releases the last-acquired nonce", async () => {
280
+ let released = "";
281
+ const http: FlyHttp = async (method, url, _body, headers) => {
282
+ if (method === "POST" && url.endsWith("/lease")) return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "NON" } }) };
283
+ if (method === "DELETE" && url.endsWith("/lease")) {
284
+ released = headers?.[LEASE_NONCE_HEADER] ?? "";
285
+ return { status: 200, text: "{}" };
286
+ }
287
+ return { status: 200, text: "{}" };
288
+ };
289
+ await withLease(CTX, "demo", "m1", http, undefined, async (nonce) => {
290
+ expect(nonce).toBe("NON");
291
+ return { status: 200, text: "{}" };
292
+ });
293
+ expect(released).toBe("NON");
294
+ });
295
+ });
296
+
297
+ // ── owned-only prune (D2) ─────────────────────────────────────────────────────
298
+
299
+ describe("owned-only prune", () => {
300
+ test("unmarked machine survives; owned-but-undeclared is destroyed", async () => {
301
+ const destroyed: string[] = [];
302
+ const http: FlyHttp = async (method, url) => {
303
+ if (method === "GET" && url.endsWith("/machines")) {
304
+ return {
305
+ status: 200,
306
+ text: JSON.stringify([
307
+ liveMachine("web", "img:1", { id: "m-web" }), // owned + declared → keep
308
+ liveMachine("orphan", "img:1", { id: "m-orphan" }), // owned + not declared → destroy
309
+ liveMachine("legacy", "img:1", { id: "m-legacy", owned: false }), // unmarked → never touch
310
+ ]),
311
+ };
312
+ }
313
+ if (method === "POST" && url.endsWith("/lease")) return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N" } }) };
314
+ if (method === "DELETE" && /\/machines\/[^/]+$/.test(url)) {
315
+ destroyed.push(url.split("/").pop() ?? "");
316
+ return { status: 200, text: "{}" };
317
+ }
318
+ if (method === "DELETE" && url.endsWith("/lease")) return { status: 200, text: "{}" };
319
+ if (method === "GET" && url.includes("/wait")) return { status: 200, text: JSON.stringify({ ok: true }) };
320
+ return { status: 500, text: url };
321
+ };
322
+ const pruned = await pruneMachines(CTX, "demo", new Set(["web"]), http, undefined, NO_WAIT);
323
+ expect(pruned).toEqual([{ app: "demo", name: "orphan", id: "m-orphan" }]);
324
+ expect(destroyed).toEqual(["m-orphan"]); // legacy (unmarked) never destroyed
325
+ });
326
+ });
327
+
328
+ // ── destroyMachine + waitForMachine ──────────────────────────────────────────
329
+
330
+ describe("destroy + wait", () => {
331
+ test("destroy leases, deletes, then waits for destroyed", async () => {
332
+ const order: string[] = [];
333
+ const http: FlyHttp = async (method, url) => {
334
+ if (method === "POST" && url.endsWith("/lease")) { order.push("lease"); return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N" } }) }; }
335
+ if (method === "DELETE" && /\/machines\/m1$/.test(url)) { order.push("destroy"); return { status: 200, text: "{}" }; }
336
+ if (method === "DELETE" && url.endsWith("/lease")) { order.push("release"); return { status: 200, text: "{}" }; }
337
+ if (method === "GET" && url.includes("/wait")) { order.push("wait"); expect(url).toContain("state=destroyed"); return { status: 200, text: JSON.stringify({ ok: true }) }; }
338
+ return { status: 500, text: url };
339
+ };
340
+ await destroyMachine(CTX, "demo", "m1", http, undefined, NO_WAIT);
341
+ expect(order).toEqual(["lease", "destroy", "release", "wait"]);
342
+ });
343
+
344
+ test("waitForMachine re-polls past a 408 timeout", async () => {
345
+ let n = 0;
346
+ const http: FlyHttp = async () => {
347
+ n++;
348
+ return n < 2 ? { status: 408, text: "timeout" } : { status: 200, text: JSON.stringify({ ok: true }) };
349
+ };
350
+ await waitForMachine(CTX, "demo", "m1", "INST", http, undefined, { intervalMs: 0, deadlineMs: 5_000 });
351
+ expect(n).toBe(2);
352
+ });
353
+ });
354
+
355
+ // ── #741 breadth: Volume + IPAddress + Certificate + Secret ───────────────────
356
+
357
+ describe("app-scoped resource classification + segments", () => {
358
+ test("each kind is recognized by its endpoint", () => {
359
+ const at = (endpoint: string): FlapsRequest => ({ endpoint, method: "POST", body: {} });
360
+ expect(isVolumeRequest(at("/v1/apps/demo/volumes"))).toBe(true);
361
+ expect(isIpRequest(at("/v1/apps/demo/ip_assignments"))).toBe(true);
362
+ expect(isCertRequest(at("/v1/apps/demo/certificates"))).toBe(true);
363
+ expect(isSecretRequest(at("/v1/apps/demo/secrets/db"))).toBe(true);
364
+ // Cross-checks: a secret endpoint is not a machine/volume request.
365
+ expect(isMachineRequest(at("/v1/apps/demo/secrets/db"))).toBe(false);
366
+ expect(isVolumeRequest(at("/v1/apps/demo/machines"))).toBe(false);
367
+ });
368
+
369
+ test("resourceAppSegment / secretNameSegment", () => {
370
+ expect(resourceAppSegment("/v1/apps/demo/volumes")).toBe("demo");
371
+ expect(resourceAppSegment("/v1/apps/demo/secrets/db-password")).toBe("demo");
372
+ expect(secretNameSegment("/v1/apps/demo/secrets/db-password")).toBe("db-password");
373
+ });
374
+
375
+ test("ipType / declaredIpType collapse to a stable family key", () => {
376
+ expect(ipType(true, "66.241.125.1")).toBe("shared_v4");
377
+ expect(ipType(false, "137.66.1.2")).toBe("v4");
378
+ expect(ipType(false, "2604:1380:ab::1")).toBe("v6");
379
+ expect(declaredIpType("shared_v4")).toBe("shared_v4");
380
+ expect(declaredIpType("v6")).toBe("v6");
381
+ expect(declaredIpType("private_v6")).toBe("v6");
382
+ expect(declaredIpType("dedicated_v4")).toBe("v4");
383
+ });
384
+ });
385
+
386
+ describe("applyVolume / applyIp / applyCert idempotency (drift handling)", () => {
387
+ const volReq: FlapsRequest = { endpoint: "/v1/apps/demo/volumes", method: "POST", body: { name: "data", region: "iad", size_gb: 10 } };
388
+
389
+ test("volume absent → create; present (by name) → no-op", async () => {
390
+ let posted = 0;
391
+ const empty: FlyHttp = async (method) => (method === "GET" ? { status: 200, text: "[]" } : ((posted++), { status: 200, text: "{}" }));
392
+ expect(await applyVolume(CTX, "demo", "data", volReq, empty)).toEqual({ action: "created", name: "data" });
393
+ expect(posted).toBe(1);
394
+
395
+ const present: FlyHttp = async (method) =>
396
+ method === "GET" ? { status: 200, text: JSON.stringify([{ id: "v1", name: "data" }]) } : { status: 500, text: "" };
397
+ expect(await applyVolume(CTX, "demo", "data", volReq, present)).toEqual({ action: "noop", name: "data" });
398
+ });
399
+
400
+ test("ip absent type → assign; same declared family present → no-op", async () => {
401
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/ip_assignments", method: "POST", body: { type: "shared_v4", region: "iad", org_slug: "acme" } };
402
+ let posted = 0;
403
+ const empty: FlyHttp = async (method) => (method === "GET" ? { status: 200, text: JSON.stringify({ ips: [] }) } : ((posted++), { status: 200, text: "{}" }));
404
+ expect(await applyIp(CTX, "demo", req, empty)).toEqual({ action: "created", type: "shared_v4" });
405
+ expect(posted).toBe(1);
406
+
407
+ const present: FlyHttp = async (method) =>
408
+ method === "GET" ? { status: 200, text: JSON.stringify({ ips: [{ ip: "66.241.125.1", shared: true }] }) } : { status: 500, text: "" };
409
+ expect(await applyIp(CTX, "demo", req, present)).toEqual({ action: "noop", type: "shared_v4" });
410
+ });
411
+
412
+ test("cert absent → create; present (by hostname) → no-op", async () => {
413
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/certificates", method: "POST", body: { hostname: "example.com" } };
414
+ const present: FlyHttp = async (method) =>
415
+ method === "GET" ? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "example.com" }] }) } : { status: 500, text: "" };
416
+ expect(await applyCert(CTX, "demo", req, present)).toEqual({ action: "noop", hostname: "example.com" });
417
+ });
418
+ });
419
+
420
+ describe("secret is apply-only (D7): set, never read-diffed", () => {
421
+ test("applySecret POSTs the value with no read-back GET", async () => {
422
+ const calls: string[] = [];
423
+ const http: FlyHttp = async (method, url) => {
424
+ calls.push(`${method} ${url}`);
425
+ return { status: 200, text: JSON.stringify({ name: "db", digest: "sha256:..." }) };
426
+ };
427
+ const req: FlapsRequest = { endpoint: "/v1/apps/demo/secrets/db", method: "POST", body: { value: "s3cret" }, applyOnly: true };
428
+ expect(await applySecret(CTX, "demo", "db", req, http)).toEqual({ action: "set", name: "db" });
429
+ // No GET — the digest-only read can't diff, so the secret never enters one.
430
+ expect(calls).toEqual(["POST http://localhost:4280/v1/apps/demo/secrets/db"]);
431
+ });
432
+ });
433
+
434
+ describe("app-scoped prune (D2) — metadata-less types prune wholesale", () => {
435
+ test("pruneVolumes destroys an undeclared volume even with NO ownership marker", async () => {
436
+ // Unlike a machine (which survives when unmarked), a metadata-less volume is
437
+ // owned at the app boundary, so an undeclared one is pruned regardless.
438
+ const deleted: string[] = [];
439
+ const http: FlyHttp = async (method, url) => {
440
+ if (method === "GET") return { status: 200, text: JSON.stringify([{ id: "v-data", name: "data" }, { id: "v-old", name: "old" }]) };
441
+ if (method === "DELETE") { deleted.push(url.split("/").pop() ?? ""); return { status: 200, text: "{}" }; }
442
+ return { status: 500, text: "" };
443
+ };
444
+ const pruned = await pruneVolumes(CTX, "demo", new Set(["data"]), http);
445
+ expect(pruned).toEqual([{ app: "demo", name: "old", id: "v-old" }]);
446
+ expect(deleted).toEqual(["v-old"]);
447
+ });
448
+
449
+ test("pruneIps releases IPs whose family is not declared", async () => {
450
+ const deleted: string[] = [];
451
+ const http: FlyHttp = async (method, url) => {
452
+ if (method === "GET") return { status: 200, text: JSON.stringify({ ips: [{ ip: "66.241.125.1", shared: true }, { ip: "2604:1380:ab::1", shared: false }] }) };
453
+ if (method === "DELETE") { deleted.push(url.split("/").pop() ?? ""); return { status: 200, text: "{}" }; }
454
+ return { status: 500, text: "" };
455
+ };
456
+ const pruned = await pruneIps(CTX, "demo", new Set(["shared_v4"]), http);
457
+ expect(pruned).toEqual([{ app: "demo", address: "2604:1380:ab::1" }]);
458
+ expect(deleted).toEqual(["2604%3A1380%3Aab%3A%3A1"]); // v6 released, shared_v4 kept
459
+ });
460
+
461
+ test("pruneCerts and pruneSecrets drop undeclared entries by name", async () => {
462
+ const certHttp: FlyHttp = async (method) =>
463
+ method === "GET"
464
+ ? { status: 200, text: JSON.stringify({ certificates: [{ hostname: "keep.com" }, { hostname: "drop.com" }] }) }
465
+ : { status: 200, text: "{}" };
466
+ expect(await pruneCerts(CTX, "demo", new Set(["keep.com"]), certHttp)).toEqual([{ app: "demo", hostname: "drop.com" }]);
467
+
468
+ const secretHttp: FlyHttp = async (method) =>
469
+ method === "GET"
470
+ ? { status: 200, text: JSON.stringify({ secrets: [{ name: "keep" }, { name: "drop" }] }) }
471
+ : { status: 200, text: "{}" };
472
+ expect(await pruneSecrets(CTX, "demo", new Set(["keep"]), secretHttp)).toEqual([{ app: "demo", name: "drop" }]);
473
+ });
474
+ });
475
+
476
+ describe("flyApply orders volumes before the machines that mount them", () => {
477
+ test("a mounting machine finds its volume already created", async () => {
478
+ const tmp = mkdtempSync(join(tmpdir(), "fly-order-"));
479
+ try {
480
+ const plan = {
481
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "demo" } },
482
+ web: {
483
+ endpoint: "/v1/apps/demo/machines",
484
+ method: "POST",
485
+ body: { name: "web", config: { image: "img:1", mounts: [{ volume: "data", path: "/data" }], metadata: { "managed-by": "chant" } } },
486
+ },
487
+ data: { endpoint: "/v1/apps/demo/volumes", method: "POST", body: { name: "data", region: "iad", size_gb: 10 } },
488
+ };
489
+ const planPath = join(tmp, "plan.json");
490
+ writeFileSync(planPath, JSON.stringify(plan));
491
+
492
+ const order: string[] = [];
493
+ const http: FlyHttp = async (method, url) => {
494
+ if (method === "GET" && /\/apps\/demo$/.test(url)) return { status: 404, text: "" };
495
+ if (method === "POST" && /\/apps$/.test(url)) { order.push("app"); return { status: 200, text: "{}" }; }
496
+ if (method === "GET" && url.endsWith("/volumes")) return { status: 200, text: "[]" };
497
+ if (method === "POST" && url.endsWith("/volumes")) { order.push("volume"); return { status: 200, text: JSON.stringify({ id: "v1", name: "data" }) }; }
498
+ if (method === "GET" && url.endsWith("/machines")) return { status: 200, text: "[]" };
499
+ if (method === "POST" && url.endsWith("/machines")) { order.push("machine"); return { status: 200, text: JSON.stringify({ id: "m1", name: "web", state: "created", instance_id: "I1", config: { image: "img:1" } }) }; }
500
+ if (method === "GET" && url.includes("/wait")) return { status: 200, text: JSON.stringify({ ok: true }) };
501
+ return { status: 500, text: url };
502
+ };
503
+
504
+ const out = await flyApply({ planPath, endpoint: "http://localhost:4280", wait: { intervalMs: 0, deadlineMs: 5_000 } }, undefined, http);
505
+ expect(out.volumes).toEqual([{ app: "demo", name: "data", action: "created" }]);
506
+ expect(out.machines).toEqual([{ app: "demo", name: "web", action: "created" }]);
507
+ // The volume was created before the machine that mounts it.
508
+ expect(order).toEqual(["app", "volume", "machine"]);
509
+ } finally {
510
+ rmSync(tmp, { recursive: true, force: true });
511
+ }
512
+ });
513
+ });