@intentius/chant-lexicon-fly 0.124.30 → 0.124.32

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 (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,911 @@
1
+ /**
2
+ * fly (Machines API / "flaps") native applier — #739.
3
+ *
4
+ * Peer of `gcpApply` (#706): a direct-REST applier that does its own diff,
5
+ * owned-only prune, and mutation waiting. GCP has no server-side declarative
6
+ * apply, and neither does flaps, so this loop is hand-rolled — GET-then-
7
+ * create/update per resource, poll `/wait` after each mutation, and prune only
8
+ * what chant owns. Unlike GCP's long-running operations, flaps gates mutations
9
+ * behind machine leases (a nonce echoed in the `fly-machine-lease-nonce`
10
+ * header), so the tricky paths here are the lease-conflict retry and waiting on
11
+ * the correct new `instance_id` after an update.
12
+ *
13
+ * Input is the #738 serializer's output: a JSON object keyed by entity name,
14
+ * each value a single flaps create request `{ endpoint, method, body }` (app →
15
+ * `/v1/apps`, machine → `/v1/apps/{app}/machines`). A machine endpoint may carry
16
+ * a literal `{app}` placeholder when the stack declares more than one app; it is
17
+ * resolved from the owning app at apply time.
18
+ *
19
+ * D1: direct REST, no flyctl, no state file. D2: owned-only prune via the
20
+ * `managed-by: chant` machine metadata. D3: endpoint override via
21
+ * `FLY_FLAPS_BASE_URL` (or an explicit `endpoint` arg), so the same code points
22
+ * at real Fly or at mudflaps (`:4280`).
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import { sleep } from "@intentius/chant/op";
26
+ import { hasOwnershipMarker } from "@intentius/chant/ownership";
27
+ import { FLY_METADATA_OWNERSHIP_KEYS } from "../../ownership.js";
28
+ import { logProgress } from "./progress.js";
29
+ import { applyResult, } from "@intentius/chant/apply";
30
+ /** Default flaps host when neither an `endpoint` arg nor `FLY_FLAPS_BASE_URL` is set. */
31
+ export const DEFAULT_FLAPS_BASE_URL = "https://api.machines.dev";
32
+ /** Header carrying a lease nonce on a mutating request, matching fly-go/flaps. */
33
+ export const LEASE_NONCE_HEADER = "fly-machine-lease-nonce";
34
+ // Progress lines go to stderr (#2516); the helper is shared with the Sprites activities.
35
+ export { logProgress } from "./progress.js";
36
+ // ── Pure helpers (unit-testable without http) ─────────────────────────────────
37
+ /**
38
+ * Resolve the flaps base URL (D3): an explicit `endpoint` arg wins, then the
39
+ * `FLY_FLAPS_BASE_URL` env, then the real-Fly default. The trailing slash is
40
+ * stripped so `${base}/v1/...` never doubles up. Pure.
41
+ */
42
+ export function resolveEndpoint(args = {}, env = process.env) {
43
+ const base = args.endpoint || env.FLY_FLAPS_BASE_URL || DEFAULT_FLAPS_BASE_URL;
44
+ return base.replace(/\/$/, "");
45
+ }
46
+ /** Parse the serializer's JSON output into a plan. Pure. */
47
+ export function parsePlan(content) {
48
+ return JSON.parse(content);
49
+ }
50
+ /** True when a request creates an App (`POST /v1/apps`). Pure. */
51
+ export function isAppRequest(req) {
52
+ return /^\/v1\/apps\/?$/.test(req.endpoint);
53
+ }
54
+ /** True when a request creates a Machine (`.../machines`). Pure. */
55
+ export function isMachineRequest(req) {
56
+ return /^\/v1\/apps\/[^/]+\/machines\/?$/.test(req.endpoint);
57
+ }
58
+ /** The app segment of a machine endpoint, or the literal `{app}` placeholder. Pure. */
59
+ export function machineAppSegment(endpoint) {
60
+ const m = endpoint.match(/^\/v1\/apps\/([^/]+)\/machines\/?$/);
61
+ if (!m)
62
+ throw new Error(`not a machine endpoint: ${endpoint}`);
63
+ return decodeURIComponent(m[1]);
64
+ }
65
+ /** True when a request creates a Volume (`.../volumes`). Pure. */
66
+ export function isVolumeRequest(req) {
67
+ return /^\/v1\/apps\/[^/]+\/volumes\/?$/.test(req.endpoint);
68
+ }
69
+ /** True when a request assigns an IP (`.../ip_assignments`). Pure. */
70
+ export function isIpRequest(req) {
71
+ return /^\/v1\/apps\/[^/]+\/ip_assignments\/?$/.test(req.endpoint);
72
+ }
73
+ /**
74
+ * True when a request creates a Certificate: `.../certificates/acme`, or the
75
+ * bare `.../certificates` that plans built before #3114 carry. Both apply the
76
+ * same way, through {@link applyCert}. Pure.
77
+ */
78
+ export function isCertRequest(req) {
79
+ return /^\/v1\/apps\/[^/]+\/certificates(\/acme)?\/?$/.test(req.endpoint);
80
+ }
81
+ /** True when a request sets a Secret (`.../secrets/{name}`). Pure. */
82
+ export function isSecretRequest(req) {
83
+ return /^\/v1\/apps\/[^/]+\/secrets\/[^/]+\/?$/.test(req.endpoint);
84
+ }
85
+ /**
86
+ * The app segment of any app-scoped resource endpoint (volumes, ip_assignments,
87
+ * certificates, secrets), or the literal `{app}` placeholder. Pure.
88
+ */
89
+ export function resourceAppSegment(endpoint) {
90
+ const m = endpoint.match(/^\/v1\/apps\/([^/]+)\//);
91
+ if (!m)
92
+ throw new Error(`not an app-scoped endpoint: ${endpoint}`);
93
+ return decodeURIComponent(m[1]);
94
+ }
95
+ /** The secret name segment of a `.../secrets/{name}` endpoint. Pure. */
96
+ export function secretNameSegment(endpoint) {
97
+ const m = endpoint.match(/^\/v1\/apps\/[^/]+\/secrets\/([^/]+)\/?$/);
98
+ if (!m)
99
+ throw new Error(`not a secret endpoint: ${endpoint}`);
100
+ return decodeURIComponent(m[1]);
101
+ }
102
+ /**
103
+ * Resolve a machine's owning app: the endpoint segment as-is, unless it is the
104
+ * `{app}` placeholder the serializer leaves when it can't decide — then fall
105
+ * back to the stack's sole app. Throws when neither settles it. Pure.
106
+ */
107
+ export function resolveApp(segment, soleApp) {
108
+ if (segment !== "{app}")
109
+ return segment;
110
+ if (soleApp)
111
+ return soleApp;
112
+ throw new Error("machine endpoint has an unresolved {app} placeholder and the stack declares no single app");
113
+ }
114
+ /** The `app_name` an app request creates. Pure. */
115
+ export function appNameFromRequest(req) {
116
+ const name = req.body.app_name;
117
+ if (typeof name !== "string" || !name)
118
+ throw new Error("app request has no app_name");
119
+ return name;
120
+ }
121
+ /**
122
+ * True when a machine's live metadata carries chant's ownership marker (D2).
123
+ * Pure. Reads the key convention the fly lexicon declares via the #686 seam
124
+ * (`FLY_METADATA_OWNERSHIP_KEYS`) through core's `hasOwnershipMarker`, so the
125
+ * prune filter and the serializer's stamp can never drift apart — the marker
126
+ * key lives in exactly one place.
127
+ */
128
+ export function isChantOwned(metadata) {
129
+ return hasOwnershipMarker(metadata ?? undefined, FLY_METADATA_OWNERSHIP_KEYS);
130
+ }
131
+ /** Structural equality over two config values, order-insensitive. Pure. */
132
+ export function configEqual(a, b) {
133
+ return canonical(a) === canonical(b);
134
+ }
135
+ function canonical(v) {
136
+ return JSON.stringify(sortKeys(v));
137
+ }
138
+ function sortKeys(v) {
139
+ if (Array.isArray(v))
140
+ return v.map(sortKeys);
141
+ if (v && typeof v === "object") {
142
+ const out = {};
143
+ for (const k of Object.keys(v).sort()) {
144
+ out[k] = sortKeys(v[k]);
145
+ }
146
+ return out;
147
+ }
148
+ return v;
149
+ }
150
+ /**
151
+ * The lease-conflict retry decision. A 409 whose body mentions a lease is a
152
+ * stale/lost nonce (the `lease_currently_held` acquire envelope, or the "machine
153
+ * is leased" gate on a mutation) — the caller should re-acquire and retry once.
154
+ * A 409 that is not lease-shaped (e.g. "app already exists") is not retried. Pure.
155
+ */
156
+ export function isLeaseConflict(status, text) {
157
+ return status === 409 && /lease/i.test(text);
158
+ }
159
+ function parseJson(text) {
160
+ try {
161
+ return JSON.parse(text);
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ /** Pull the nonce out of a lease-acquire success envelope. */
168
+ function parseNonce(text) {
169
+ const body = parseJson(text);
170
+ return body?.data?.nonce;
171
+ }
172
+ function parseMachine(text) {
173
+ const m = parseJson(text);
174
+ if (!m?.id)
175
+ throw new Error(`unexpected machine response: ${text}`);
176
+ return m;
177
+ }
178
+ // ── URL builders ──────────────────────────────────────────────────────────────
179
+ const appsUrl = (base) => `${base}/v1/apps`;
180
+ const appUrl = (base, app) => `${appsUrl(base)}/${encodeURIComponent(app)}`;
181
+ const machinesUrl = (base, app) => `${appUrl(base, app)}/machines`;
182
+ const machineUrl = (base, app, id) => `${machinesUrl(base, app)}/${encodeURIComponent(id)}`;
183
+ const leaseUrl = (base, app, id) => `${machineUrl(base, app, id)}/lease`;
184
+ const volumesUrl = (base, app) => `${appUrl(base, app)}/volumes`;
185
+ const volumeUrl = (base, app, id) => `${volumesUrl(base, app)}/${encodeURIComponent(id)}`;
186
+ const ipsUrl = (base, app) => `${appUrl(base, app)}/ip_assignments`;
187
+ const ipUrl = (base, app, ip) => `${ipsUrl(base, app)}/${encodeURIComponent(ip)}`;
188
+ const certsUrl = (base, app) => `${appUrl(base, app)}/certificates`;
189
+ const certUrl = (base, app, hostname) => `${certsUrl(base, app)}/${encodeURIComponent(hostname)}`;
190
+ // Certificates are listed at `.../certificates` but created at `.../acme`:
191
+ // flaps has no POST on the list path and answers one with 404 (#3114).
192
+ const acmeCertUrl = (base, app) => `${certsUrl(base, app)}/acme`;
193
+ const secretsUrl = (base, app) => `${appUrl(base, app)}/secrets`;
194
+ const secretUrl = (base, app, secretName) => `${secretsUrl(base, app)}/${encodeURIComponent(secretName)}`;
195
+ function waitUrl(base, app, id, state, version, timeoutSecs) {
196
+ const q = new URLSearchParams({ state, timeout: String(timeoutSecs) });
197
+ if (version)
198
+ q.set("version", version);
199
+ return `${machineUrl(base, app, id)}/wait?${q.toString()}`;
200
+ }
201
+ /**
202
+ * Default `fetch`-based client. Sends `Authorization: Bearer <token>` when a
203
+ * token is set (real Fly); mudflaps ignores it. The token defaults to
204
+ * `FLY_API_TOKEN` at call time.
205
+ */
206
+ export function defaultFlyHttp(token) {
207
+ return async (method, url, body, headers, signal) => {
208
+ const h = { ...headers };
209
+ if (body !== undefined)
210
+ h["content-type"] = "application/json";
211
+ const tok = token ?? process.env.FLY_API_TOKEN;
212
+ if (tok)
213
+ h["authorization"] = `Bearer ${tok}`;
214
+ const res = await fetch(url, {
215
+ method,
216
+ headers: Object.keys(h).length ? h : undefined,
217
+ body: body === undefined ? undefined : JSON.stringify(body),
218
+ signal,
219
+ });
220
+ return { status: res.status, text: await res.text() };
221
+ };
222
+ }
223
+ // ── Leases (the crown-jewel path) ────────────────────────────────────────────
224
+ /**
225
+ * Acquire a lease, retrying once on a lease-conflict 409. The retry is what
226
+ * lets a stale/lost hold clear (the prior lease expired or was released) before
227
+ * the mutation goes out with a fresh nonce. Returns the nonce.
228
+ */
229
+ export async function acquireLease(ctx, app, id, http, signal) {
230
+ const url = leaseUrl(ctx.base, app, id);
231
+ const body = { ttl: 60, description: "chant apply" };
232
+ let res = await http("POST", url, body, undefined, signal);
233
+ if (isLeaseConflict(res.status, res.text)) {
234
+ res = await http("POST", url, body, undefined, signal);
235
+ }
236
+ if (res.status >= 300) {
237
+ throw new Error(`lease acquire failed for ${app}/${id} (${res.status}): ${res.text}`);
238
+ }
239
+ const nonce = parseNonce(res.text);
240
+ if (!nonce)
241
+ throw new Error(`lease acquire returned no nonce for ${app}/${id}: ${res.text}`);
242
+ return nonce;
243
+ }
244
+ /** Best-effort lease release. A cleared lease (post-destroy) 404s — ignored. */
245
+ export async function releaseLease(ctx, app, id, nonce, http, signal) {
246
+ try {
247
+ await http("DELETE", leaseUrl(ctx.base, app, id), undefined, { [LEASE_NONCE_HEADER]: nonce }, signal);
248
+ }
249
+ catch {
250
+ // Release is best-effort; a leaked lease expires on its TTL.
251
+ }
252
+ }
253
+ /**
254
+ * Run a lease-gated mutation: acquire → mutate (nonce in the header) → release.
255
+ * On a lease-conflict 409 from the mutation (a stale/lost nonce), re-acquire a
256
+ * fresh nonce and retry the mutation once. `mutate` receives the nonce so the
257
+ * caller can put it in the header; it returns the raw response. Returns the
258
+ * final mutation response.
259
+ */
260
+ export async function withLease(ctx, app, id, http, signal, mutate) {
261
+ let nonce = await acquireLease(ctx, app, id, http, signal);
262
+ try {
263
+ let res = await mutate(nonce);
264
+ if (isLeaseConflict(res.status, res.text)) {
265
+ nonce = await acquireLease(ctx, app, id, http, signal);
266
+ res = await mutate(nonce);
267
+ }
268
+ return res;
269
+ }
270
+ finally {
271
+ await releaseLease(ctx, app, id, nonce, http, signal);
272
+ }
273
+ }
274
+ // ── Wait ───────────────────────────────────────────────────────────────────────
275
+ /**
276
+ * Poll `GET .../wait` until the machine reaches `state` at the given `version`
277
+ * (its new `instance_id`). flaps clamps its own timeout to 60s and answers 408
278
+ * on expiry, so the client re-polls until its own deadline. A destroyed+reaped
279
+ * machine satisfies a `state=destroyed` wait (flaps returns `ok` on the missing
280
+ * machine). `http` and the interval are injectable so tests avoid real waits.
281
+ */
282
+ export async function waitForMachine(ctx, app, id, version, http, signal, opts = {}) {
283
+ const state = opts.state ?? "started";
284
+ const timeoutSecs = opts.timeoutSecs ?? 60;
285
+ const interval = opts.intervalMs ?? 1000;
286
+ const deadline = Date.now() + (opts.deadlineMs ?? 300000);
287
+ const url = waitUrl(ctx.base, app, id, state, version, timeoutSecs);
288
+ let attempt = 0;
289
+ while (Date.now() < deadline) {
290
+ if (signal?.aborted)
291
+ throw new Error("waitForMachine aborted");
292
+ attempt++;
293
+ const res = await http("GET", url, undefined, undefined, signal);
294
+ if (res.status === 200 && parseJson(res.text)?.ok === true) {
295
+ return;
296
+ }
297
+ if (res.status === 200 || res.status === 408) {
298
+ // Not settled yet (or the server-side long-poll timed out) — re-poll.
299
+ await sleep(interval, signal);
300
+ continue;
301
+ }
302
+ throw new Error(`wait failed for ${app}/${id} (${res.status}): ${res.text}`);
303
+ }
304
+ throw new Error(`machine ${app}/${id} did not reach ${state} within the deadline`);
305
+ }
306
+ // ── Resource operations ────────────────────────────────────────────────────────
307
+ /** Create the app if absent (idempotent). A create-time conflict means it exists. */
308
+ export async function applyApp(ctx, req, http, signal) {
309
+ const app = appNameFromRequest(req);
310
+ const get = await http("GET", appUrl(ctx.base, app), undefined, undefined, signal);
311
+ if (get.status === 200)
312
+ return { app, created: false };
313
+ if (get.status !== 404)
314
+ throw new Error(`app ${app} lookup failed (${get.status}): ${get.text}`);
315
+ const res = await http("POST", appsUrl(ctx.base), req.body, undefined, signal);
316
+ if (res.status === 409)
317
+ return { app, created: false }; // already exists (raced)
318
+ if (res.status >= 300)
319
+ throw new Error(`app ${app} create failed (${res.status}): ${res.text}`);
320
+ return { app, created: true };
321
+ }
322
+ /** List an app's live machines. */
323
+ export async function listMachines(ctx, app, http, signal) {
324
+ const res = await http("GET", machinesUrl(ctx.base, app), undefined, undefined, signal);
325
+ if (res.status === 404)
326
+ return [];
327
+ if (res.status >= 300)
328
+ throw new Error(`machine list failed for ${app} (${res.status}): ${res.text}`);
329
+ const list = parseJson(res.text);
330
+ return Array.isArray(list) ? list : [];
331
+ }
332
+ /**
333
+ * Reconcile one machine: create it when absent; when present, update it only if
334
+ * its config drifted (else no-op). A create/update is followed by a wait on the
335
+ * machine's new `instance_id`. Updates go through a lease. The declared machine
336
+ * is identified by name (falling back to the plan entity name), so re-applying
337
+ * an unchanged machine is a no-op.
338
+ */
339
+ export async function applyMachine(ctx, app, entityName, req, http, signal, opts = {}) {
340
+ const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
341
+ const body = { ...req.body, name };
342
+ const desiredConfig = req.body.config;
343
+ const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
344
+ if (!live) {
345
+ const res = await http("POST", machinesUrl(ctx.base, app), body, undefined, signal);
346
+ if (res.status >= 300)
347
+ throw new Error(`machine ${app}/${name} create failed (${res.status}): ${res.text}`);
348
+ const m = parseMachine(res.text);
349
+ await waitForMachine(ctx, app, m.id, m.instance_id, http, signal, opts);
350
+ return { action: "created", id: m.id, name };
351
+ }
352
+ if (configEqual(desiredConfig, live.config)) {
353
+ return { action: "noop", id: live.id, name };
354
+ }
355
+ const res = await withLease(ctx, app, live.id, http, signal, (nonce) => http("POST", machineUrl(ctx.base, app, live.id), body, { [LEASE_NONCE_HEADER]: nonce }, signal));
356
+ if (res.status >= 300)
357
+ throw new Error(`machine ${app}/${name} update failed (${res.status}): ${res.text}`);
358
+ const m = parseMachine(res.text);
359
+ await waitForMachine(ctx, app, m.id, m.instance_id, http, signal, opts);
360
+ return { action: "updated", id: m.id, name };
361
+ }
362
+ /**
363
+ * States flaps destroys without `force`. A started machine answers a bare
364
+ * DELETE with 412 failed_precondition (#3115).
365
+ */
366
+ const DESTROYABLE_STATES = new Set(["stopped", "suspended", "failed", "created"]);
367
+ /** True when a destroy response is flaps refusing a machine that is not stopped. Pure. */
368
+ export function isNotStoppedRefusal(status, text) {
369
+ return status === 412 || (status >= 400 && /failed_precondition/.test(text));
370
+ }
371
+ /**
372
+ * Stop a machine under a lease, then wait for `stopped` with no lease held (a
373
+ * slow stop can outlive the lease TTL). A machine already `stopping` is only
374
+ * waited on. A wait that runs out fails naming the machine.
375
+ */
376
+ async function stopForDestroy(ctx, app, id, http, signal, opts, state) {
377
+ const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
378
+ if (state !== "stopping") {
379
+ const res = await withLease(ctx, app, id, http, signal, (nonce) => http("POST", `${machineUrl(ctx.base, app, id)}/stop`, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal));
380
+ if (res.status === 404)
381
+ return;
382
+ if (res.status >= 300)
383
+ throw new Error(`machine ${label} stop before destroy failed (${res.status}): ${res.text}`);
384
+ }
385
+ const { timeoutSecs, intervalMs, deadlineMs } = opts;
386
+ try {
387
+ await waitForMachine(ctx, app, id, "", http, signal, { timeoutSecs, intervalMs, deadlineMs, state: "stopped" });
388
+ }
389
+ catch (err) {
390
+ const why = err instanceof Error ? err.message : String(err);
391
+ throw new Error(`machine ${label} did not stop, so it was not destroyed (${why}). ` +
392
+ `Check the machine, or destroy it with force: true to kill it without a stop.`);
393
+ }
394
+ }
395
+ /**
396
+ * Destroy a machine and wait for it to be reaped. flaps refuses a bare DELETE
397
+ * on a running machine (412, #3115), so by default a machine that is not
398
+ * stopped is stopped first (each mutation under a lease), and a 412 from the
399
+ * delete (a machine that started after it was listed) gets one stop and retry.
400
+ * With `force`, the delete carries `?force=true` and no stop is sent.
401
+ */
402
+ export async function destroyMachine(ctx, app, id, http, signal, opts = {}) {
403
+ const { force, state } = opts;
404
+ const label = opts.name ? `${app}/${opts.name} (${id})` : `${app}/${id}`;
405
+ const waitOpts = { timeoutSecs: opts.timeoutSecs, intervalMs: opts.intervalMs, deadlineMs: opts.deadlineMs };
406
+ const gone = state === "destroyed" || state === "destroying";
407
+ if (!gone) {
408
+ if (!force && state !== undefined && !DESTROYABLE_STATES.has(state)) {
409
+ await stopForDestroy(ctx, app, id, http, signal, opts, state);
410
+ }
411
+ const url = force ? `${machineUrl(ctx.base, app, id)}?force=true` : machineUrl(ctx.base, app, id);
412
+ const del = () => withLease(ctx, app, id, http, signal, (nonce) => http("DELETE", url, undefined, { [LEASE_NONCE_HEADER]: nonce }, signal));
413
+ let res = await del();
414
+ if (!force && isNotStoppedRefusal(res.status, res.text)) {
415
+ await stopForDestroy(ctx, app, id, http, signal, opts, undefined);
416
+ res = await del();
417
+ }
418
+ if (res.status >= 300 && res.status !== 404) {
419
+ throw new Error(`machine ${label} destroy failed (${res.status}): ${res.text}`);
420
+ }
421
+ }
422
+ await waitForMachine(ctx, app, id, "", http, signal, { ...waitOpts, state: "destroyed" });
423
+ }
424
+ /** Delete an app (idempotent; a 404 means it is already gone). */
425
+ export async function deleteApp(ctx, app, http, signal) {
426
+ const res = await http("DELETE", appUrl(ctx.base, app), undefined, undefined, signal);
427
+ if (res.status === 404)
428
+ return { app, deleted: false };
429
+ if (res.status >= 300)
430
+ throw new Error(`app ${app} delete failed (${res.status}): ${res.text}`);
431
+ return { app, deleted: true };
432
+ }
433
+ /**
434
+ * Owned-only prune (D2): for one app, destroy the chant-owned machines whose
435
+ * name is not in `keep`. An unmarked machine (no `managed-by: chant`) is never
436
+ * touched, so it survives an apply that would otherwise delete it. Machines
437
+ * already tearing down are skipped. A running machine is stopped before it is
438
+ * destroyed unless `opts.force` is set (see {@link destroyMachine}).
439
+ */
440
+ export async function pruneMachines(ctx, app, keep, http, signal, opts = {}) {
441
+ const pruned = [];
442
+ for (const m of await listMachines(ctx, app, http, signal)) {
443
+ if (!isChantOwned(m.config?.metadata) || keep.has(m.name))
444
+ continue;
445
+ if (m.state === "destroyed" || m.state === "destroying")
446
+ continue;
447
+ await destroyMachine(ctx, app, m.id, http, signal, { ...opts, state: m.state, name: m.name });
448
+ logProgress(`pruned: ${app}/${m.name} (${ctx.base})`);
449
+ pruned.push({ app, name: m.name, id: m.id });
450
+ }
451
+ return pruned;
452
+ }
453
+ /**
454
+ * The declared identity of an IP: its `type` collapses to a family key the
455
+ * live-list can be mapped back to (shared v4 / dedicated v4 / v6). This lets a
456
+ * re-apply of the same declared type be a no-op even though the address is
457
+ * server-allocated. Pure.
458
+ */
459
+ export function ipType(shared, address) {
460
+ if (shared)
461
+ return "shared_v4";
462
+ return address.includes(":") ? "v6" : "v4";
463
+ }
464
+ /** The declared IP family from a request body's `type`. Pure. */
465
+ export function declaredIpType(type) {
466
+ if (type === "shared_v4")
467
+ return "shared_v4";
468
+ if (type === "v6" || type === "private_v6")
469
+ return "v6";
470
+ return "v4";
471
+ }
472
+ /** List an app's live volumes (flaps returns a bare array). */
473
+ export async function listVolumes(ctx, app, http, signal) {
474
+ const res = await http("GET", volumesUrl(ctx.base, app), undefined, undefined, signal);
475
+ if (res.status === 404)
476
+ return [];
477
+ if (res.status >= 300)
478
+ throw new Error(`volume list failed for ${app} (${res.status}): ${res.text}`);
479
+ const list = parseJson(res.text);
480
+ return Array.isArray(list) ? list : [];
481
+ }
482
+ /** List an app's live IP assignments (`{ ips: [...] }`). */
483
+ export async function listIps(ctx, app, http, signal) {
484
+ const res = await http("GET", ipsUrl(ctx.base, app), undefined, undefined, signal);
485
+ if (res.status === 404)
486
+ return [];
487
+ if (res.status >= 300)
488
+ throw new Error(`ip list failed for ${app} (${res.status}): ${res.text}`);
489
+ const body = parseJson(res.text);
490
+ return body?.ips ?? [];
491
+ }
492
+ /**
493
+ * The page size asked of the certificate list. Fly pages it whether or not a
494
+ * `limit` is given (default 25, max 500), so asking for the max keeps the
495
+ * common case to one request.
496
+ */
497
+ export const CERT_PAGE_LIMIT = 500;
498
+ /**
499
+ * The most certificate pages {@link listCerts} reads before it gives up. At
500
+ * {@link CERT_PAGE_LIMIT} per page that is 100,000 certificates, far past any
501
+ * real app, so hitting it means the API never stopped handing out a cursor.
502
+ */
503
+ export const MAX_CERT_PAGES = 200;
504
+ /**
505
+ * List an app's live certificates, every page of them (#3224). Fly answers
506
+ * `{ certificates, next_cursor?, total_count }` and pages the list even
507
+ * without a `limit`; this follows `next_cursor` until it is absent. A cursor
508
+ * the API already handed out, or more than {@link MAX_CERT_PAGES} pages,
509
+ * throws rather than looping or returning a partial list, because callers
510
+ * read "not listed" as "absent" (re-apply creates it, prune skips it).
511
+ */
512
+ export async function listCerts(ctx, app, http, signal) {
513
+ const certs = [];
514
+ const seen = new Set();
515
+ let cursor;
516
+ for (let page = 0; page < MAX_CERT_PAGES; page++) {
517
+ const q = new URLSearchParams({ limit: String(CERT_PAGE_LIMIT) });
518
+ if (cursor !== undefined)
519
+ q.set("cursor", cursor);
520
+ const res = await http("GET", `${certsUrl(ctx.base, app)}?${q.toString()}`, undefined, undefined, signal);
521
+ // A 404 on the first page is an absent app; on a later one the list is
522
+ // half-read, so it is an error like any other.
523
+ if (res.status === 404 && page === 0)
524
+ return [];
525
+ if (res.status >= 300)
526
+ throw new Error(`certificate list failed for ${app} (${res.status}): ${res.text}`);
527
+ const body = parseJson(res.text);
528
+ certs.push(...(body?.certificates ?? []));
529
+ const next = body?.next_cursor;
530
+ if (!next)
531
+ return certs;
532
+ if (seen.has(next))
533
+ throw new Error(`certificate list for ${app} repeated cursor ${JSON.stringify(next)} after ${page + 1} page(s)`);
534
+ seen.add(next);
535
+ cursor = next;
536
+ }
537
+ throw new Error(`certificate list for ${app} still had a next_cursor after ${MAX_CERT_PAGES} pages`);
538
+ }
539
+ /** List an app's live secrets (`{ secrets: [...] }`; digests only, never values). */
540
+ export async function listSecrets(ctx, app, http, signal) {
541
+ const res = await http("GET", secretsUrl(ctx.base, app), undefined, undefined, signal);
542
+ if (res.status === 404)
543
+ return [];
544
+ if (res.status >= 300)
545
+ throw new Error(`secret list failed for ${app} (${res.status}): ${res.text}`);
546
+ const body = parseJson(res.text);
547
+ return body?.secrets ?? [];
548
+ }
549
+ /** Create a volume if absent (idempotent by name). Machines that mount it apply after. */
550
+ export async function applyVolume(ctx, app, entityName, req, http, signal) {
551
+ const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
552
+ if ((await listVolumes(ctx, app, http, signal)).some((v) => v.name === name)) {
553
+ return { action: "noop", name };
554
+ }
555
+ const res = await http("POST", volumesUrl(ctx.base, app), { ...req.body, name }, undefined, signal);
556
+ if (res.status >= 300)
557
+ throw new Error(`volume ${app}/${name} create failed (${res.status}): ${res.text}`);
558
+ return { action: "created", name };
559
+ }
560
+ /** Assign an IP if the declared type is not already present (idempotent by family). */
561
+ export async function applyIp(ctx, app, req, http, signal) {
562
+ const type = declaredIpType(req.body.type);
563
+ const present = new Set((await listIps(ctx, app, http, signal)).map((ip) => ipType(ip.shared, ip.ip)));
564
+ if (present.has(type))
565
+ return { action: "noop", type };
566
+ const res = await http("POST", ipsUrl(ctx.base, app), req.body, undefined, signal);
567
+ if (res.status >= 300)
568
+ throw new Error(`ip assign failed for ${app} (${res.status}): ${res.text}`);
569
+ return { action: "created", type };
570
+ }
571
+ /**
572
+ * Create an ACME certificate if absent (idempotent by hostname). Always POSTs
573
+ * `{ hostname }` to `.../certificates/acme`, whatever endpoint the plan entry
574
+ * names, so a plan built before #3114 applies correctly too.
575
+ */
576
+ export async function applyCert(ctx, app, req, http, signal) {
577
+ const hostname = String(req.body.hostname ?? "");
578
+ if (!hostname)
579
+ throw new Error(`certificate request for ${app} has no hostname`);
580
+ if ((await listCerts(ctx, app, http, signal)).some((c) => c.hostname === hostname)) {
581
+ return { action: "noop", hostname };
582
+ }
583
+ const res = await http("POST", acmeCertUrl(ctx.base, app), { hostname }, undefined, signal);
584
+ if (res.status >= 300)
585
+ throw new Error(`certificate ${app}/${hostname} create failed (${res.status}): ${res.text}`);
586
+ return { action: "created", hostname };
587
+ }
588
+ /**
589
+ * True when a plan entry is a Secret with no value, which apply must never
590
+ * POST. Plans built before `mustExist` existed mark it with an empty body.
591
+ * Pure.
592
+ */
593
+ export function isValuelessSecret(req) {
594
+ return isSecretRequest(req) && (req.mustExist === true || req.body.value === undefined);
595
+ }
596
+ /**
597
+ * Check a value-less secret (set outside chant) exists on the app. Never
598
+ * POSTs. Throws naming the secret when the app does not have it.
599
+ */
600
+ export async function checkSecretExists(ctx, app, name, http, signal) {
601
+ const live = await listSecrets(ctx, app, http, signal);
602
+ if (!live.some((s) => s.name === name)) {
603
+ throw new Error(`secret ${app}/${name} is declared without a value, so it must already exist, ` +
604
+ `and app ${app} does not have it. Set it with \`fly secrets set ${name}=...\` ` +
605
+ `or give the Secret a value.`);
606
+ }
607
+ return { action: "exists", name };
608
+ }
609
+ /**
610
+ * Set a secret (D7, apply-only): always POST, never read back for a diff. flaps
611
+ * returns only a digest, so there is nothing to compare — every apply re-sets it.
612
+ */
613
+ export async function applySecret(ctx, app, name, req, http, signal) {
614
+ const res = await http("POST", secretUrl(ctx.base, app, name), req.body, undefined, signal);
615
+ if (res.status >= 300)
616
+ throw new Error(`secret ${app}/${name} set failed (${res.status}): ${res.text}`);
617
+ return { action: "set", name };
618
+ }
619
+ /** App-scoped prune (D2): destroy volumes the plan no longer declares (by name). */
620
+ export async function pruneVolumes(ctx, app, keep, http, signal) {
621
+ const pruned = [];
622
+ for (const v of await listVolumes(ctx, app, http, signal)) {
623
+ if (keep.has(v.name))
624
+ continue;
625
+ const res = await http("DELETE", volumeUrl(ctx.base, app, v.id), undefined, undefined, signal);
626
+ if (res.status >= 300 && res.status !== 404)
627
+ throw new Error(`volume ${app}/${v.name} delete failed (${res.status}): ${res.text}`);
628
+ logProgress(`pruned: volume/${app}/${v.name} (${ctx.base})`);
629
+ pruned.push({ app, name: v.name, id: v.id });
630
+ }
631
+ return pruned;
632
+ }
633
+ /** App-scoped prune (D2): release IP assignments whose type the plan no longer declares. */
634
+ export async function pruneIps(ctx, app, keep, http, signal) {
635
+ const pruned = [];
636
+ for (const ip of await listIps(ctx, app, http, signal)) {
637
+ if (keep.has(ipType(ip.shared, ip.ip)))
638
+ continue;
639
+ const res = await http("DELETE", ipUrl(ctx.base, app, ip.ip), undefined, undefined, signal);
640
+ if (res.status >= 300 && res.status !== 404)
641
+ throw new Error(`ip ${app}/${ip.ip} delete failed (${res.status}): ${res.text}`);
642
+ logProgress(`pruned: ip/${app}/${ip.ip} (${ctx.base})`);
643
+ pruned.push({ app, address: ip.ip });
644
+ }
645
+ return pruned;
646
+ }
647
+ /** App-scoped prune (D2): destroy certificates whose hostname the plan no longer declares. */
648
+ export async function pruneCerts(ctx, app, keep, http, signal) {
649
+ const pruned = [];
650
+ for (const c of await listCerts(ctx, app, http, signal)) {
651
+ if (keep.has(c.hostname))
652
+ continue;
653
+ const res = await http("DELETE", certUrl(ctx.base, app, c.hostname), undefined, undefined, signal);
654
+ if (res.status >= 300 && res.status !== 404)
655
+ throw new Error(`certificate ${app}/${c.hostname} delete failed (${res.status}): ${res.text}`);
656
+ logProgress(`pruned: certificate/${app}/${c.hostname} (${ctx.base})`);
657
+ pruned.push({ app, hostname: c.hostname });
658
+ }
659
+ return pruned;
660
+ }
661
+ /**
662
+ * App-scoped prune (D2) for apply-only secrets: a declared-then-removed secret
663
+ * is still prunable by name, even though it never enters a drift/diff.
664
+ */
665
+ export async function pruneSecrets(ctx, app, keep, http, signal) {
666
+ const pruned = [];
667
+ for (const s of await listSecrets(ctx, app, http, signal)) {
668
+ if (keep.has(s.name))
669
+ continue;
670
+ const res = await http("DELETE", secretUrl(ctx.base, app, s.name), undefined, undefined, signal);
671
+ if (res.status >= 300 && res.status !== 404)
672
+ throw new Error(`secret ${app}/${s.name} delete failed (${res.status}): ${res.text}`);
673
+ logProgress(`pruned: secret/${app}/${s.name} (${ctx.base})`);
674
+ pruned.push({ app, name: s.name });
675
+ }
676
+ return pruned;
677
+ }
678
+ /**
679
+ * The native fly applier (#739, #741): read the serialized plan and apply it
680
+ * straight to flaps in dependency order — app → volumes → machines → ips →
681
+ * certificates → secrets — then optionally prune. Machines prune owned-only via
682
+ * the metadata marker (D2); the metadata-less types (volumes/ips/certs/secrets)
683
+ * prune app-scoped: everything the plan no longer declares under a managed app.
684
+ * Secrets are apply-only (D7): set, never read back for a diff. `http` is
685
+ * injectable for tests.
686
+ */
687
+ /**
688
+ * Normalize a flyApply result into core's apply envelope (#1446).
689
+ *
690
+ * fly returns eleven arrays because it applies six entity classes and prunes
691
+ * five. That detail is the applier's own and stays; this projects it onto the
692
+ * shared tri-state so a caller can read any applier's result the same way.
693
+ *
694
+ * `notAttempted` is always empty for fly, and that is not an oversight: an entry
695
+ * the applier cannot classify now throws (#1457) rather than being skipped,
696
+ * because the serializer produced it and the two being out of sync is a bug in
697
+ * the lexicon, not a resource the user declined.
698
+ */
699
+ function readPlanFile(planPath) {
700
+ if (!planPath)
701
+ throw new Error("flyApply needs a plan: pass planPath (the fly build output) or plan");
702
+ return parsePlan(readFileSync(planPath, "utf8"));
703
+ }
704
+ export function toApplyResult(result) {
705
+ // fly says "noop" where the contract says "unchanged".
706
+ const act = (a) => a === "noop" ? "unchanged" : a;
707
+ const applied = [
708
+ ...result.apps.map((x) => ({
709
+ kind: "app",
710
+ name: x.app,
711
+ action: (x.created ? "created" : "unchanged"),
712
+ })),
713
+ ...result.machines.map((x) => ({ kind: "machine", name: x.name, action: act(x.action) })),
714
+ ...result.volumes.map((x) => ({ kind: "volume", name: x.name, action: act(x.action) })),
715
+ ...result.ips.map((x) => ({ kind: "ip", name: x.type, action: act(x.action) })),
716
+ ...result.certs.map((x) => ({ kind: "cert", name: x.hostname, action: act(x.action) })),
717
+ // Secrets are apply-only (D7) — POSTed every run, never read back, so a
718
+ // set secret reports updated. A value-less secret is only checked, so it
719
+ // reports unchanged.
720
+ ...result.secrets.map((x) => ({
721
+ kind: "secret",
722
+ name: x.name,
723
+ action: (x.action === "exists" ? "unchanged" : "updated"),
724
+ })),
725
+ ];
726
+ const pruned = [
727
+ ...result.pruned.map((x) => ({ kind: "machine", name: x.name, deleted: true })),
728
+ ...result.prunedVolumes.map((x) => ({ kind: "volume", name: x.name, deleted: true })),
729
+ ...result.prunedIps.map((x) => ({ kind: "ip", name: x.address, deleted: true })),
730
+ ...result.prunedCerts.map((x) => ({ kind: "cert", name: x.hostname, deleted: true })),
731
+ ...result.prunedSecrets.map((x) => ({ kind: "secret", name: x.name, deleted: true })),
732
+ ];
733
+ return applyResult(applied, pruned, []);
734
+ }
735
+ export async function flyApply(args, signal, http = defaultFlyHttp(args.token)) {
736
+ const plan = args.plan ?? readPlanFile(args.planPath);
737
+ const ctx = { base: resolveEndpoint(args) };
738
+ const opts = args.wait ?? {};
739
+ const appReqs = [];
740
+ const machineReqs = [];
741
+ const volumeReqs = [];
742
+ const ipReqs = [];
743
+ const certReqs = [];
744
+ const secretReqs = [];
745
+ for (const [entityName, req] of Object.entries(plan)) {
746
+ if (isAppRequest(req))
747
+ appReqs.push(req);
748
+ else if (isMachineRequest(req))
749
+ machineReqs.push([entityName, req]);
750
+ else if (isVolumeRequest(req))
751
+ volumeReqs.push([entityName, req]);
752
+ else if (isIpRequest(req))
753
+ ipReqs.push([entityName, req]);
754
+ else if (isCertRequest(req))
755
+ certReqs.push([entityName, req]);
756
+ else if (isSecretRequest(req))
757
+ secretReqs.push([entityName, req]);
758
+ // #1457: this used to fall off the end. An entry matching none of the six
759
+ // predicates was discarded — no log, no bucket, absent from all eleven
760
+ // arrays flyApply returns — and the result read as a full apply.
761
+ //
762
+ // Failing is right here, rather than reporting it as not-attempted. The
763
+ // serializer produced this entry, so the applier not recognising it is a
764
+ // version skew between the two halves of the same lexicon — a bug in one of
765
+ // them, not a resource the user chose to skip. It happens exactly when a new
766
+ // Fly resource type lands on the serializer side alone.
767
+ else {
768
+ throw new Error(`fly plan entry "${entityName}" matches no request predicate ` +
769
+ `(${req.method} ${req.endpoint}). The serializer emitted a shape this applier ` +
770
+ `does not recognise — they are out of sync. Applying would silently skip it.`);
771
+ }
772
+ }
773
+ const appNames = appReqs.map(appNameFromRequest);
774
+ const soleApp = appNames.length === 1 ? appNames[0] : undefined;
775
+ // Apps first.
776
+ const apps = [];
777
+ for (const req of appReqs) {
778
+ const result = await applyApp(ctx, req, http, signal);
779
+ logProgress(`${result.created ? "created" : "unchanged"}: app/${result.app} (${ctx.base})`);
780
+ apps.push(result);
781
+ }
782
+ // Keep-sets per app, one per resource kind. Seeded empty for every declared
783
+ // app so an app whose resources were all removed still prunes them. These are
784
+ // app-scoped (D2): metadata-less types are owned wholesale under a managed app.
785
+ const keepMachines = new Map();
786
+ const keepVolumes = new Map();
787
+ const keepIps = new Map();
788
+ const keepCerts = new Map();
789
+ const keepSecrets = new Map();
790
+ for (const app of appNames) {
791
+ keepMachines.set(app, new Set());
792
+ keepVolumes.set(app, new Set());
793
+ keepIps.set(app, new Set());
794
+ keepCerts.set(app, new Set());
795
+ keepSecrets.set(app, new Set());
796
+ }
797
+ const track = (m, app, key) => {
798
+ const set = m.get(app) ?? new Set();
799
+ set.add(key);
800
+ m.set(app, set);
801
+ };
802
+ // Volumes before machines: a machine's `config.mounts[]` references a volume
803
+ // by name, so the volume must exist first.
804
+ const volumes = [];
805
+ for (const [entityName, req] of volumeReqs) {
806
+ const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
807
+ const result = await applyVolume(ctx, app, entityName, req, http, signal);
808
+ track(keepVolumes, app, result.name);
809
+ logProgress(`${result.action}: volume/${app}/${result.name} (${ctx.base})`);
810
+ volumes.push({ app, name: result.name, action: result.action });
811
+ }
812
+ const machines = [];
813
+ for (const [entityName, req] of machineReqs) {
814
+ const app = resolveApp(machineAppSegment(req.endpoint), soleApp);
815
+ const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
816
+ track(keepMachines, app, name);
817
+ const result = await applyMachine(ctx, app, entityName, req, http, signal, opts);
818
+ logProgress(`${result.action}: machine/${app}/${result.name} (${ctx.base})`);
819
+ machines.push({ app, name: result.name, action: result.action });
820
+ }
821
+ // IPs, certificates, secrets after machines (independent of them).
822
+ const ips = [];
823
+ for (const [entityName, req] of ipReqs) {
824
+ const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
825
+ const result = await applyIp(ctx, app, req, http, signal);
826
+ track(keepIps, app, result.type);
827
+ logProgress(`${result.action}: ip/${app}/${result.type} (${ctx.base})`);
828
+ ips.push({ app, type: result.type, action: result.action });
829
+ }
830
+ const certs = [];
831
+ for (const [, req] of certReqs) {
832
+ const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
833
+ const result = await applyCert(ctx, app, req, http, signal);
834
+ track(keepCerts, app, result.hostname);
835
+ logProgress(`${result.action}: certificate/${app}/${result.hostname} (${ctx.base})`);
836
+ certs.push({ app, hostname: result.hostname, action: result.action });
837
+ }
838
+ // Secrets are apply-only (D7): a secret with a value is set every run and
839
+ // never read back for a diff. A value-less one is only checked for.
840
+ const secrets = [];
841
+ for (const [, req] of secretReqs) {
842
+ const app = resolveApp(resourceAppSegment(req.endpoint), soleApp);
843
+ const name = secretNameSegment(req.endpoint);
844
+ track(keepSecrets, app, name);
845
+ const result = isValuelessSecret(req)
846
+ ? await checkSecretExists(ctx, app, name, http, signal)
847
+ : await applySecret(ctx, app, name, req, http, signal);
848
+ logProgress(`${result.action}: secret/${app}/${result.name} (${ctx.base})`);
849
+ secrets.push({ app, name: result.name, action: result.action });
850
+ }
851
+ const pruned = [];
852
+ const prunedVolumes = [];
853
+ const prunedIps = [];
854
+ const prunedCerts = [];
855
+ const prunedSecrets = [];
856
+ if (args.prune) {
857
+ for (const [app, keep] of keepMachines) {
858
+ pruned.push(...(await pruneMachines(ctx, app, keep, http, signal, { ...opts, force: args.force })));
859
+ }
860
+ for (const [app, keep] of keepVolumes) {
861
+ prunedVolumes.push(...(await pruneVolumes(ctx, app, keep, http, signal)));
862
+ }
863
+ for (const [app, keep] of keepIps) {
864
+ prunedIps.push(...(await pruneIps(ctx, app, keep, http, signal)));
865
+ }
866
+ for (const [app, keep] of keepCerts) {
867
+ prunedCerts.push(...(await pruneCerts(ctx, app, keep, http, signal)));
868
+ }
869
+ for (const [app, keep] of keepSecrets) {
870
+ prunedSecrets.push(...(await pruneSecrets(ctx, app, keep, http, signal)));
871
+ }
872
+ }
873
+ return { apps, machines, volumes, ips, certs, secrets, pruned, prunedVolumes, prunedIps, prunedCerts, prunedSecrets };
874
+ }
875
+ /**
876
+ * The inverse of {@link flyApply}: destroy the machines the plan declares, then
877
+ * delete the apps (dependents before their app). A running machine is stopped
878
+ * first unless `args.force` is set. Idempotent — already-absent
879
+ * resources are a no-op. `http` is injectable for tests.
880
+ */
881
+ export async function flyDelete(args, signal, http = defaultFlyHttp(args.token)) {
882
+ const plan = args.plan ?? readPlanFile(args.planPath);
883
+ const ctx = { base: resolveEndpoint(args) };
884
+ const opts = args.wait ?? {};
885
+ const appReqs = [];
886
+ const machineReqs = [];
887
+ for (const [entityName, req] of Object.entries(plan)) {
888
+ if (isAppRequest(req))
889
+ appReqs.push(req);
890
+ else if (isMachineRequest(req))
891
+ machineReqs.push([entityName, req]);
892
+ }
893
+ const soleApp = appReqs.length === 1 ? appNameFromRequest(appReqs[0]) : undefined;
894
+ const machines = [];
895
+ for (const [entityName, req] of machineReqs) {
896
+ const app = resolveApp(machineAppSegment(req.endpoint), soleApp);
897
+ const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName;
898
+ const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name);
899
+ if (!live)
900
+ continue;
901
+ await destroyMachine(ctx, app, live.id, http, signal, { ...opts, force: args.force, state: live.state, name });
902
+ machines.push({ app, name });
903
+ }
904
+ const apps = [];
905
+ for (const req of appReqs) {
906
+ const result = await deleteApp(ctx, appNameFromRequest(req), http, signal);
907
+ logProgress(`${result.deleted ? "deleted" : "absent"}: app/${result.app} (${ctx.base})`);
908
+ apps.push(result);
909
+ }
910
+ return { machines, apps };
911
+ }