@intentius/chant-lexicon-fly 0.44.14 → 0.46.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.
@@ -12,12 +12,6 @@
12
12
  * Machines, Storage, Networking, Secrets).
13
13
  */
14
14
  export declare function serviceFromType(resourceType: string): string;
15
- /**
16
- * The reference doc URL for a fly resource type. The Machines API (flaps) is
17
- * documented at docs.machines.dev, tagged by resource; resources flaps does not
18
- * own (public IPs, certificates) link to the matching fly.io docs page.
19
- */
20
- export declare function resourceTypeUrl(resourceType: string): string;
21
15
  /**
22
16
  * Generate documentation for the fly Machines lexicon.
23
17
  */
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsBH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAiB5D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAkB5D;AAsED;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA0DjF"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAQH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAiB5D;AA2DD;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBjF"}
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAoB,MAAM,0BAA0B,CAAC;AAEtG,OAAO,EAGL,KAAK,SAAS,EACf,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAqBL,KAAK,OAAO,EAIb,MAAM,2BAA2B,CAAC;AAcnC,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,gDAAgD;IAChD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAeD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,iBAAiB,CAAC,CA4R5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,wBAAwB,GAAG;IAClC,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CACjD,EACD,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAkCrD"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAoB,MAAM,0BAA0B,CAAC;AAItG,OAAO,EAGL,KAAK,SAAS,EACf,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAqBL,KAAK,OAAO,EAIb,MAAM,2BAA2B,CAAC;AAcnC,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,gDAAgD;IAChD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,2FAA2F;IAC3F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAeD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,iBAAiB,CAAC,CAiS5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,wBAAwB,GAAG;IAClC,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CACjD,EACD,IAAI,GAAE,OAA0B,EAChC,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAkCrD"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "1fbfc95ed68886ffc1c484f80d7391106fd7a1179c7450a1ad9fbd79560aa1d4",
4
+ "manifest.json": "87eb08737e0b05d0dff013f5a3474cf9da153f6727ee8d15f637ce7f286031a4",
5
5
  "meta.json": "e46e65ba7544fafc6dbb3fd48b841904b5ae6f7cb4d517a940ed68b01626c097",
6
6
  "types/index.d.ts": "a76090adb6eda33a2c07a074183299a59e953b48dd3f2020d7ef11278d59341e",
7
7
  "rules/guest-sizing.ts": "bc749d218df8f3cc3d8fbe388205ae96e6b204ae0d6d77e8bf82cb936cc53ff6",
@@ -15,5 +15,5 @@
15
15
  "skills/chant-fly-ops.md": "b8eef6504e203133e6a35c83aabb08404eec95060705395515a0046d42269cf1",
16
16
  "skills/chant-fly-sprites.md": "89ba52db63a71a17eab483d99233a4ae67f5d34920445010e750f81c95200b4e"
17
17
  },
18
- "composite": "98afa1b841bc5430ae0e7eda44c408696adbe088a3c658d63cef009eebaf6e89"
18
+ "composite": "6e0ad16fc43da2edad2bc8bfa9a6a42ced592d6b8871914af192c11099fdb8ee"
19
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fly",
3
- "version": "0.44.14",
3
+ "version": "0.46.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fly"
6
6
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAkD,MAAM,0BAA0B,CAAC;AAuB9G;;;;GAIG;AACH,eAAO,MAAM,SAAS,EAAE,aA+RvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAkD,MAAM,0BAA0B,CAAC;AAuB9G;;;;GAIG;AACH,eAAO,MAAM,SAAS,EAAE,aA6SvB,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Env teardown for the fly lexicon (chant #1222) — both halves of the
3
+ * `teardownOwned` / `executeTeardown` capability pair, over the applier's own
4
+ * flaps primitives (op/activities/fly-apply.ts).
5
+ *
6
+ * Selection follows the two-tier ownership convention (#743, D2):
7
+ *
8
+ * - Machines carry the primary marker in `config.metadata`
9
+ * (`managed-by: chant` + `chant-stack` + `chant-env`), so a machine is a
10
+ * candidate exactly when its own metadata reads back the requested
11
+ * stack + env identity.
12
+ * - The app is the boundary for everything metadata-less (volumes, IPs,
13
+ * certificates, secrets). An app becomes a candidate only when it has at
14
+ * least one machine and EVERY machine in it carries the requested
15
+ * identity — an app also hosting a foreign or other-env machine is never
16
+ * deleted whole; only its matching machines are.
17
+ *
18
+ * Enumeration lists apps via `GET /v1/apps` (mudflaps serves it as-is; real
19
+ * flaps wants `?org_slug=`, appended from FLY_ORG / FLY_ORG_SLUG when set)
20
+ * and each app's machines. An unlistable app is a hole (#1089) — unknown,
21
+ * never absent.
22
+ *
23
+ * Execution deletes machines first (lease → destroy → wait, the applier's
24
+ * `destroyMachine`) and apps last (`deleteApp`, which takes the app-scoped
25
+ * volumes/IPs/certs with it). Before every delete the live side is re-read
26
+ * and the identity re-verified; already-gone is `deleted` (teardown is
27
+ * idempotent), an identity mismatch is `not-prunable`, never a delete.
28
+ */
29
+ import type { TeardownCandidate, TeardownEnumeration, TeardownExecution } from "@intentius/chant/lexicon";
30
+ import { type OwnershipMarker } from "@intentius/chant/ownership";
31
+ import { type FlyHttp, type WaitOpts } from "./op/activities/fly-apply.js";
32
+ export interface FlyTeardownOptions {
33
+ environment: string;
34
+ marker: OwnershipMarker;
35
+ }
36
+ /** Injection seam for tests: scripted http, explicit endpoint, shrunk waits. */
37
+ export interface FlyTeardownDeps {
38
+ http?: FlyHttp;
39
+ endpoint?: string;
40
+ wait?: WaitOpts;
41
+ }
42
+ /**
43
+ * Enumerate the env's marker-matching machines and whole-app boundaries — the
44
+ * fly half of `chant lifecycle teardown <env>` planning.
45
+ */
46
+ export declare function teardownOwned(options: FlyTeardownOptions, deps?: FlyTeardownDeps): Promise<TeardownEnumeration>;
47
+ /**
48
+ * Delete the handed-over candidates: machines first, apps last. One outcome
49
+ * per candidate, always.
50
+ */
51
+ export declare function executeTeardown(options: FlyTeardownOptions & {
52
+ candidates: TeardownCandidate[];
53
+ }, deps?: FlyTeardownDeps): Promise<TeardownExecution>;
54
+ //# sourceMappingURL=teardown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"teardown.d.ts","sourceRoot":"","sources":["../src/teardown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EAGlB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAEjF,OAAO,EAQL,KAAK,OAAO,EACZ,KAAK,QAAQ,EACd,MAAM,2BAA2B,CAAC;AAQnC,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED,gFAAgF;AAChF,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,QAAQ,CAAC;CACjB;AA2BD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,mBAAmB,CAAC,CA8C9B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,GAAG;IAAE,UAAU,EAAE,iBAAiB,EAAE,CAAA;CAAE,EACjE,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,iBAAiB,CAAC,CAuB5B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fly",
3
- "version": "0.44.14",
3
+ "version": "0.46.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -57,7 +57,7 @@
57
57
  "typescript": "^5.9.3"
58
58
  },
59
59
  "peerDependencies": {
60
- "@intentius/chant": "^0.44.14",
60
+ "@intentius/chant": "^0.46.0",
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
@@ -12,20 +12,6 @@ import { docsPipeline, writeDocsSite, type DocsConfig } from "@intentius/chant/c
12
12
 
13
13
  const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
14
14
 
15
- /**
16
- * The curated top-level fly resources (#741), in the order they read best in a
17
- * reference table. Property types (MachineConfig, MachineGuest, ...) are grouped
18
- * separately by the pipeline and are not repeated here.
19
- */
20
- const RESOURCES: Array<{ className: string; resourceType: string }> = [
21
- { className: "App", resourceType: "Fly::Machines::App" },
22
- { className: "Machine", resourceType: "Fly::Machines::Machine" },
23
- { className: "Volume", resourceType: "Fly::Machines::Volume" },
24
- { className: "IPAddress", resourceType: "Fly::Machines::IPAddress" },
25
- { className: "Certificate", resourceType: "Fly::Machines::Certificate" },
26
- { className: "Secret", resourceType: "Fly::Machines::Secret" },
27
- ];
28
-
29
15
  /**
30
16
  * Group a fly resource type into a service bucket for the reference sidebar.
31
17
  * fly type names are `Fly::Machines::<Kind>`, so the middle segment is always
@@ -51,31 +37,6 @@ export function serviceFromType(resourceType: string): string {
51
37
  }
52
38
  }
53
39
 
54
- /**
55
- * The reference doc URL for a fly resource type. The Machines API (flaps) is
56
- * documented at docs.machines.dev, tagged by resource; resources flaps does not
57
- * own (public IPs, certificates) link to the matching fly.io docs page.
58
- */
59
- export function resourceTypeUrl(resourceType: string): string {
60
- const kind = resourceType.split("::")[2] ?? resourceType;
61
- switch (kind) {
62
- case "App":
63
- return "https://docs.machines.dev/#tag/apps";
64
- case "Machine":
65
- return "https://docs.machines.dev/#tag/machines";
66
- case "Volume":
67
- return "https://docs.machines.dev/#tag/volumes";
68
- case "Secret":
69
- return "https://docs.machines.dev/#tag/secrets";
70
- case "IPAddress":
71
- return "https://fly.io/docs/networking/services/";
72
- case "Certificate":
73
- return "https://fly.io/docs/networking/custom-domains-with-fly/";
74
- default:
75
- return "https://docs.machines.dev/";
76
- }
77
- }
78
-
79
40
  const overview = `The **Fly Machines** lexicon defines Fly.io apps and machines using chant's declarative TypeScript syntax. Resources are serialized to the JSON create bodies the Machines API ("flaps") accepts, so the applier can POST them straight through and the mudflaps emulator can round-trip them offline.
80
41
 
81
42
  This lexicon is generated from Fly's published [Machines API OpenAPI spec](https://docs.machines.dev/openapi.json).
@@ -133,17 +94,6 @@ Every serialized machine carries the \`managed-by: chant\` ownership marker in \
133
94
 
134
95
  The output is applied against flaps directly (or the mudflaps emulator offline). Endpoint and auth come from \`FLY_FLAPS_BASE_URL\` and \`FLY_API_TOKEN\`.`;
135
96
 
136
- const resourcesPage = `The fly lexicon ships ${RESOURCES.length} top-level resources. Each maps to a Machines API create body; follow the reference link for the underlying API shape.
137
-
138
- | Resource | Type | Service | Machines API |
139
- |----------|------|---------|--------------|
140
- ${RESOURCES.map(
141
- (r) =>
142
- `| \`${r.className}\` | \`${r.resourceType}\` | ${serviceFromType(r.resourceType)} | [reference](${resourceTypeUrl(r.resourceType)}) |`,
143
- ).join("\n")}
144
-
145
- Property types such as \`MachineConfig\`, \`MachineGuest\`, \`MachineService\`, \`MachinePort\`, and \`MachineMount\` are authored inline on a resource and are documented alongside the resources they belong to.`;
146
-
147
97
  /**
148
98
  * Generate documentation for the fly Machines lexicon.
149
99
  */
@@ -159,42 +109,6 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
159
109
  overview,
160
110
  outputFormat,
161
111
  serviceFromType,
162
- extraPages: [
163
- {
164
- slug: "resources",
165
- title: "Resource Reference",
166
- description: "The fly resource types and their Machines API documentation links",
167
- content: resourcesPage,
168
- },
169
- {
170
- slug: "pseudo-params",
171
- title: "Pseudo-parameters",
172
- description: "Environment-resolved values: Fly.Region, Fly.OrgSlug, Fly.AppName",
173
- content: `Pseudo-parameters stand in for values that vary by environment. Reference them instead of hard-coding, and the serializer resolves each from an environment variable at build time.
174
-
175
- | Pseudo-parameter | Environment variable | Fallback |
176
- |------------------|----------------------|----------|
177
- | \`Fly.Region\` | \`FLY_REGION\` | \`iad\` |
178
- | \`Fly.OrgSlug\` | \`FLY_ORG\`, then \`FLY_ORG_SLUG\` | \`personal\` |
179
- | \`Fly.AppName\` | \`FLY_APP_NAME\` | \`app\` |
180
-
181
- \`\`\`typescript
182
- import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
183
-
184
- const app = new App({ name: "my-app", org_slug: Fly.OrgSlug });
185
-
186
- const web = new Machine({
187
- region: Fly.Region,
188
- config: new MachineConfig({
189
- image: "flyio/hellofly:latest",
190
- guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
191
- }),
192
- });
193
- \`\`\`
194
-
195
- With \`FLY_REGION=lhr\` set, \`region\` serializes to \`"lhr"\`; unset, it falls back to \`"iad"\`.`,
196
- },
197
- ],
198
112
  };
199
113
 
200
114
  const result = docsPipeline(config);
@@ -259,5 +259,31 @@ describeObservationConformance({
259
259
  fakeHttp([liveMachine("web", "nginx:1")]),
260
260
  ),
261
261
  },
262
+ {
263
+ // Marker identity (#1222): machines carry the metadata channel and
264
+ // surface stack/env verbatim; the app's `owned` verdict is inferred at
265
+ // the boundary and carries no marker of its own — absent, not guessed.
266
+ name: "a stack/env-stamped machine surfaces its marker; the app surfaces none",
267
+ declared: ["app", "web"],
268
+ expectPresent: ["app", "web"],
269
+ expectMarker: { web: { stack: "shop", env: "prod" } },
270
+ expectNoMarker: ["app"],
271
+ run: () =>
272
+ describeResources(
273
+ { environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, endpoint: ENDPOINT },
274
+ fakeHttp([
275
+ {
276
+ id: "m-web",
277
+ name: "web",
278
+ state: "started",
279
+ instance_id: "INST0",
280
+ config: {
281
+ image: "nginx:1",
282
+ metadata: { ...OWNED_META, "chant-stack": "shop", "chant-env": "prod" },
283
+ },
284
+ },
285
+ ]),
286
+ ),
287
+ },
262
288
  ],
263
289
  });
@@ -29,6 +29,8 @@
29
29
 
30
30
  import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
31
31
  import { normalizeObservation, observation, unobservedAll } from "@intentius/chant/observation";
32
+ import { readOwnership } from "@intentius/chant/ownership";
33
+ import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
32
34
  import {
33
35
  buildChangeSet,
34
36
  renderChangeSet,
@@ -311,6 +313,11 @@ export async function describeResources(
311
313
  physicalId: m.id,
312
314
  status: m.state,
313
315
  ownership: isOwned ? "owned" : "foreign",
316
+ // Marker identity (#1222): machines are the one fly type carrying the
317
+ // metadata channel, so only they surface stack/env. Apps and the
318
+ // boundary types (volumes/ips/certs) get their verdict inferred at the
319
+ // app boundary and carry no marker of their own — absent, not guessed.
320
+ marker: readOwnership(m.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS),
314
321
  attributes: pruneUndefined({
315
322
  app,
316
323
  machineName: m.name,
@@ -49,7 +49,7 @@ describe("roundtrip: declared entities → serialize → parse → generate →
49
49
  ["app", app],
50
50
  ["web", web],
51
51
  ]);
52
- const planA = JSON.parse(flySerializer.serialize(entities));
52
+ const planA = JSON.parse(flySerializer.serialize(entities) as string);
53
53
 
54
54
  // 2. Parse the plan and generate TypeScript.
55
55
  const ir = parser.parse(JSON.stringify(planA));
@@ -67,7 +67,7 @@ describe("roundtrip: declared entities → serialize → parse → generate →
67
67
 
68
68
  // 4. Re-serialize the regenerated entities.
69
69
  const regenerated = new Map<string, any>(Object.entries(mod));
70
- const planB = JSON.parse(flySerializer.serialize(regenerated));
70
+ const planB = JSON.parse(flySerializer.serialize(regenerated) as string);
71
71
 
72
72
  // The bodies survive the round-trip. Entity keys differ (variable names are
73
73
  // camelCased from the resource name), so compare the create bodies by kind.
package/src/plugin.ts CHANGED
@@ -299,6 +299,20 @@ export { app, web };
299
299
  return describeResources(options);
300
300
  },
301
301
 
302
+ // Env teardown (#1222): enumeration + execution over the machine marker
303
+ // (`managed-by` + `chant-stack` + `chant-env`) and the app boundary (#743).
304
+ // Machines are destroyed first, whole apps last — an app hosting any machine
305
+ // outside the requested identity is never deleted whole. See ./teardown.ts.
306
+ async teardownOwned(options) {
307
+ const { teardownOwned } = await import("./teardown");
308
+ return teardownOwned(options);
309
+ },
310
+
311
+ async executeTeardown(options) {
312
+ const { executeTeardown } = await import("./teardown");
313
+ return executeTeardown(options);
314
+ },
315
+
302
316
  // Live export (cloud → code): read live flaps state and regenerate App/Machine
303
317
  // TypeScript. Strips server-written fields to the authored shape; honors the
304
318
  // `owned` filter (machine marker; app-boundary inference for the marker-less
@@ -11,7 +11,7 @@ function stack(...entries: Array<[string, unknown]>): Map<string, Declarable> {
11
11
 
12
12
  describe("fly serializer", () => {
13
13
  it("serializes an empty map to valid JSON", () => {
14
- const result = flySerializer.serialize(new Map());
14
+ const result = flySerializer.serialize(new Map()) as string;
15
15
  expect(typeof result).toBe("string");
16
16
  expect(JSON.parse(result)).toEqual({});
17
17
  });
@@ -27,7 +27,7 @@ describe("fly serializer", () => {
27
27
  new App({ name: "my-app", org_slug: "acme" }),
28
28
  ]);
29
29
 
30
- const out = JSON.parse(flySerializer.serialize(entities));
30
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
31
31
  expect(out.web).toEqual({
32
32
  endpoint: "/v1/apps",
33
33
  method: "POST",
@@ -36,7 +36,7 @@ describe("fly serializer", () => {
36
36
  });
37
37
 
38
38
  it("falls back to the entity name for app_name when no explicit name", () => {
39
- const out = JSON.parse(flySerializer.serialize(stack(["billing", new App({ org_slug: "acme" })])));
39
+ const out = JSON.parse(flySerializer.serialize(stack(["billing", new App({ org_slug: "acme" })])) as string);
40
40
  expect(out.billing.body.app_name).toBe("billing");
41
41
  });
42
42
 
@@ -57,7 +57,7 @@ describe("fly serializer", () => {
57
57
  ],
58
58
  );
59
59
 
60
- const out = JSON.parse(flySerializer.serialize(entities));
60
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
61
61
  expect(out.api.endpoint).toBe("/v1/apps/my-app/machines");
62
62
  expect(out.api.method).toBe("POST");
63
63
  expect(out.api.body.name).toBe("api-1");
@@ -79,7 +79,7 @@ describe("fly serializer", () => {
79
79
  ],
80
80
  );
81
81
 
82
- const out = JSON.parse(flySerializer.serialize(entities));
82
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
83
83
  // No user metadata → marker still present.
84
84
  expect(out.bare.body.config.metadata["managed-by"]).toBe("chant");
85
85
  // User metadata is preserved and the marker is merged in.
@@ -94,7 +94,7 @@ describe("fly serializer", () => {
94
94
  );
95
95
 
96
96
  const out = JSON.parse(
97
- flySerializer.serialize(entities, undefined, { ownership: { stack: "billing", env: "prod" } }),
97
+ flySerializer.serialize(entities, undefined, { ownership: { stack: "billing", env: "prod" } }) as string,
98
98
  );
99
99
  const meta = out.api.body.config.metadata;
100
100
  expect(meta["managed-by"]).toBe("chant");
@@ -110,7 +110,7 @@ describe("fly serializer", () => {
110
110
  new Machine({ name: "api-1", region: "iad", config: new MachineConfig({ image: "nginx" }) }),
111
111
  ],
112
112
  );
113
- const body = JSON.parse(flySerializer.serialize(entities)).api.body;
113
+ const body = JSON.parse(flySerializer.serialize(entities) as string).api.body;
114
114
  // Only fields present on flaps CreateMachineRequest / MachineConfig.
115
115
  expect(Object.keys(body).sort()).toEqual(["config", "name", "region"]);
116
116
  expect(Object.keys(body.config.guest ?? {})).toEqual([]);
@@ -123,7 +123,7 @@ describe("fly serializer", () => {
123
123
  ["app", new App({ name: "my-app" })],
124
124
  ["data", new Volume({ name: "data", region: "iad", size_gb: 10, encrypted: true })],
125
125
  );
126
- const out = JSON.parse(flySerializer.serialize(entities));
126
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
127
127
  expect(out.data).toEqual({
128
128
  endpoint: "/v1/apps/my-app/volumes",
129
129
  method: "POST",
@@ -138,7 +138,7 @@ describe("fly serializer", () => {
138
138
  ["app", new App({ name: "my-app" })],
139
139
  ["ip", new IPAddress({ type: "shared_v4", region: "iad", org_slug: "acme", service_name: "web", network: "default" })],
140
140
  );
141
- const out = JSON.parse(flySerializer.serialize(entities));
141
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
142
142
  expect(out.ip).toEqual({
143
143
  endpoint: "/v1/apps/my-app/ip_assignments",
144
144
  method: "POST",
@@ -151,7 +151,7 @@ describe("fly serializer", () => {
151
151
  ["app", new App({ name: "my-app" })],
152
152
  ["cert", new Certificate({ hostname: "example.com" })],
153
153
  );
154
- const out = JSON.parse(flySerializer.serialize(entities));
154
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
155
155
  expect(out.cert).toEqual({
156
156
  endpoint: "/v1/apps/my-app/certificates",
157
157
  method: "POST",
@@ -164,7 +164,7 @@ describe("fly serializer", () => {
164
164
  ["app", new App({ name: "my-app" })],
165
165
  ["db-password", new Secret({ value: "s3cret" })],
166
166
  );
167
- const out = JSON.parse(flySerializer.serialize(entities));
167
+ const out = JSON.parse(flySerializer.serialize(entities) as string);
168
168
  expect(out["db-password"]).toEqual({
169
169
  endpoint: "/v1/apps/my-app/secrets/db-password",
170
170
  method: "POST",
@@ -195,7 +195,7 @@ describe("fly serializer pseudo-parameters", () => {
195
195
  ["app", new App({ name: "my-app" })],
196
196
  ["web", new Machine({ region: Fly.Region, config: new MachineConfig({ image: "nginx" }) })],
197
197
  );
198
- const body = JSON.parse(flySerializer.serialize(entities)).web.body;
198
+ const body = JSON.parse(flySerializer.serialize(entities) as string).web.body;
199
199
  expect(body.region).toBe("lhr");
200
200
  });
201
201
 
@@ -205,28 +205,28 @@ describe("fly serializer pseudo-parameters", () => {
205
205
  ["app", new App({ name: "my-app" })],
206
206
  ["web", new Machine({ region: Fly.Region, config: new MachineConfig({ image: "nginx" }) })],
207
207
  );
208
- const body = JSON.parse(flySerializer.serialize(entities)).web.body;
208
+ const body = JSON.parse(flySerializer.serialize(entities) as string).web.body;
209
209
  expect(body.region).toBe("iad");
210
210
  });
211
211
 
212
212
  it("resolves Fly.OrgSlug from FLY_ORG (preferred over FLY_ORG_SLUG)", () => {
213
213
  process.env.FLY_ORG = "acme";
214
214
  process.env.FLY_ORG_SLUG = "ignored";
215
- const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
215
+ const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])) as string);
216
216
  expect(out.app.body.org_slug).toBe("acme");
217
217
  });
218
218
 
219
219
  it("resolves Fly.OrgSlug from FLY_ORG_SLUG when FLY_ORG is unset", () => {
220
220
  delete process.env.FLY_ORG;
221
221
  process.env.FLY_ORG_SLUG = "beta-org";
222
- const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
222
+ const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])) as string);
223
223
  expect(out.app.body.org_slug).toBe("beta-org");
224
224
  });
225
225
 
226
226
  it("falls back to personal for Fly.OrgSlug when no org env var is set", () => {
227
227
  delete process.env.FLY_ORG;
228
228
  delete process.env.FLY_ORG_SLUG;
229
- const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])));
229
+ const out = JSON.parse(flySerializer.serialize(stack(["app", new App({ name: "my-app", org_slug: Fly.OrgSlug })])) as string);
230
230
  expect(out.app.body.org_slug).toBe("personal");
231
231
  });
232
232
  });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Env teardown over a scripted flaps (chant #1222) — the fly halves of the
3
+ * teardownOwned / executeTeardown capability pair, mirroring the injected-HTTP
4
+ * style of fly-apply.test.ts and describe-resources.test.ts: a stateful fake
5
+ * answers app/machine reads and records mutations, so the lease → destroy →
6
+ * wait path and the app-boundary rules run for real.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
10
+ import { teardownOwned, executeTeardown } from "./teardown";
11
+ import type { FlyHttp } from "./op/activities/fly-apply";
12
+
13
+ const ENDPOINT = "http://localhost:4280";
14
+ const MARKER = { stack: "shop", env: "dev" };
15
+ const NO_WAIT = { intervalMs: 0, deadlineMs: 5_000 };
16
+
17
+ const savedOrg = { FLY_ORG: process.env.FLY_ORG, FLY_ORG_SLUG: process.env.FLY_ORG_SLUG };
18
+ beforeEach(() => {
19
+ delete process.env.FLY_ORG;
20
+ delete process.env.FLY_ORG_SLUG;
21
+ });
22
+ afterEach(() => {
23
+ if (savedOrg.FLY_ORG === undefined) delete process.env.FLY_ORG; else process.env.FLY_ORG = savedOrg.FLY_ORG;
24
+ if (savedOrg.FLY_ORG_SLUG === undefined) delete process.env.FLY_ORG_SLUG; else process.env.FLY_ORG_SLUG = savedOrg.FLY_ORG_SLUG;
25
+ });
26
+
27
+ type FakeMachine = { id: string; name: string; state: string; instance_id: string; config?: { metadata?: Record<string, string> } };
28
+
29
+ function machine(
30
+ name: string,
31
+ metadata: Record<string, string> | undefined,
32
+ extra: Partial<FakeMachine> = {},
33
+ ): FakeMachine {
34
+ return {
35
+ id: extra.id ?? `m-${name}`,
36
+ name,
37
+ state: extra.state ?? "started",
38
+ instance_id: "INST0",
39
+ config: { ...(metadata !== undefined ? { metadata } : {}) },
40
+ };
41
+ }
42
+
43
+ const MINE = { "managed-by": "chant", "chant-stack": "shop", "chant-env": "dev" };
44
+ const OTHER_ENV = { "managed-by": "chant", "chant-stack": "shop", "chant-env": "prod" };
45
+ const OTHER_STACK = { "managed-by": "chant", "chant-stack": "blog", "chant-env": "dev" };
46
+
47
+ /**
48
+ * A stateful scripted flaps: `apps` maps app name to its machines; deletes
49
+ * mutate the state, so the app-boundary re-check after a destroy sees what a
50
+ * real flaps would. An app mapped to the string "boom" fails its machine list.
51
+ */
52
+ function scriptedFlaps(apps: Record<string, FakeMachine[] | "boom">): { http: FlyHttp; log: string[] } {
53
+ const log: string[] = [];
54
+ const http: FlyHttp = async (method, url) => {
55
+ log.push(`${method} ${url}`);
56
+ if (method === "GET" && /\/v1\/apps(\?[^/]*)?$/.test(url)) {
57
+ return { status: 200, text: JSON.stringify({ apps: Object.keys(apps).map((name) => ({ name })) }) };
58
+ }
59
+ if (/\/machines\/[^/]+\/lease$/.test(url)) {
60
+ return { status: 200, text: JSON.stringify({ status: "success", data: { nonce: "N" } }) };
61
+ }
62
+ if (method === "GET" && /\/machines\/[^/]+\/wait\?/.test(url)) {
63
+ return { status: 200, text: JSON.stringify({ ok: true }) };
64
+ }
65
+ let m = url.match(/\/v1\/apps\/([^/?]+)\/machines\/([^/?]+)$/);
66
+ if (m && method === "DELETE") {
67
+ const held = apps[m[1]];
68
+ if (Array.isArray(held)) apps[m[1]] = held.filter((mm) => mm.id !== m![2]);
69
+ return { status: 200, text: "{}" };
70
+ }
71
+ m = url.match(/\/v1\/apps\/([^/?]+)\/machines$/);
72
+ if (m && method === "GET") {
73
+ const held = apps[m[1]];
74
+ if (held === "boom") return { status: 500, text: "machine list exploded" };
75
+ if (held === undefined) return { status: 404, text: "no such app" };
76
+ return { status: 200, text: JSON.stringify(held) };
77
+ }
78
+ m = url.match(/\/v1\/apps\/([^/?]+)$/);
79
+ if (m && method === "DELETE") {
80
+ if (apps[m[1]] === undefined) return { status: 404, text: "gone" };
81
+ delete apps[m[1]];
82
+ return { status: 200, text: "{}" };
83
+ }
84
+ return { status: 500, text: `unscripted: ${method} ${url}` };
85
+ };
86
+ return { http, log };
87
+ }
88
+
89
+ describe("teardownOwned — marker machines + the app boundary (#743)", () => {
90
+ test("machines carrying this stack+env are candidates; an all-matching app is one too", async () => {
91
+ const { http } = scriptedFlaps({
92
+ mine: [machine("web", MINE), machine("worker", MINE)],
93
+ mixed: [machine("ok", MINE), machine("foreign", { role: "db" })],
94
+ });
95
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, { http, endpoint: ENDPOINT });
96
+
97
+ expect(enumeration.candidates.map((c) => `${c.type === "Fly::Machines::App" ? "app" : "machine"}:${c.name}`).sort()).toEqual([
98
+ "app:mine",
99
+ "machine:mine/web",
100
+ "machine:mine/worker",
101
+ "machine:mixed/ok",
102
+ ]);
103
+ for (const candidate of enumeration.candidates) {
104
+ expect(candidate.marker).toEqual(MARKER);
105
+ }
106
+ const web = enumeration.candidates.find((c) => c.name === "mine/web")!;
107
+ expect(web.physicalId).toBe("m-web");
108
+ expect(enumeration.holes ?? []).toEqual([]);
109
+ });
110
+
111
+ test("another env's and another stack's machines are never candidates", async () => {
112
+ const { http } = scriptedFlaps({
113
+ mine: [machine("web", MINE), machine("prod-twin", OTHER_ENV), machine("their", OTHER_STACK)],
114
+ });
115
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, { http, endpoint: ENDPOINT });
116
+ // Only the matching machine — and no whole-app candidate, because the app
117
+ // hosts machines outside the requested identity.
118
+ expect(enumeration.candidates.map((c) => c.name)).toEqual(["mine/web"]);
119
+ });
120
+
121
+ test("terminal machines are ignored, and do not block the app boundary", async () => {
122
+ const { http } = scriptedFlaps({
123
+ mine: [machine("web", MINE), machine("old", { role: "db" }, { state: "destroyed" })],
124
+ });
125
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, { http, endpoint: ENDPOINT });
126
+ expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["mine", "mine/web"]);
127
+ });
128
+
129
+ test("an app whose machine list fails is a hole, and the others still enumerate (#1089)", async () => {
130
+ const { http } = scriptedFlaps({
131
+ broken: "boom",
132
+ mine: [machine("web", MINE)],
133
+ });
134
+ const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, { http, endpoint: ENDPOINT });
135
+ expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["mine", "mine/web"]);
136
+ expect(enumeration.holes).toHaveLength(1);
137
+ expect(enumeration.holes![0].name).toBe("broken");
138
+ expect(enumeration.holes![0].reason).toBe("read-failed");
139
+ });
140
+
141
+ test("FLY_ORG is passed as org_slug on the app list (real flaps requires it)", async () => {
142
+ process.env.FLY_ORG = "acme";
143
+ const { http, log } = scriptedFlaps({});
144
+ await teardownOwned({ environment: "dev", marker: MARKER }, { http, endpoint: ENDPOINT });
145
+ expect(log[0]).toBe(`GET ${ENDPOINT}/v1/apps?org_slug=acme`);
146
+ });
147
+ });
148
+
149
+ describe("executeTeardown — machines first, apps last", () => {
150
+ test("destroys the machines (lease → destroy → wait), then deletes the app", async () => {
151
+ const state: Record<string, FakeMachine[] | "boom"> = {
152
+ mine: [machine("web", MINE), machine("worker", MINE)],
153
+ };
154
+ const { http, log } = scriptedFlaps(state);
155
+ const execution = await executeTeardown(
156
+ {
157
+ environment: "dev",
158
+ marker: MARKER,
159
+ candidates: [
160
+ { name: "mine", type: "Fly::Machines::App", marker: MARKER },
161
+ { name: "mine/web", type: "Fly::Machines::Machine", physicalId: "m-web", marker: MARKER },
162
+ { name: "mine/worker", type: "Fly::Machines::Machine", physicalId: "m-worker", marker: MARKER },
163
+ ],
164
+ },
165
+ { http, endpoint: ENDPOINT, wait: NO_WAIT },
166
+ );
167
+
168
+ expect(execution.outcomes.map((o) => ({ name: o.name, outcome: o.outcome }))).toEqual([
169
+ { name: "mine/web", outcome: "deleted" },
170
+ { name: "mine/worker", outcome: "deleted" },
171
+ { name: "mine", outcome: "deleted" },
172
+ ]);
173
+ const deletes = log.filter((l) => l.startsWith("DELETE") && !l.endsWith("/lease"));
174
+ expect(deletes).toEqual([
175
+ `DELETE ${ENDPOINT}/v1/apps/mine/machines/m-web`,
176
+ `DELETE ${ENDPOINT}/v1/apps/mine/machines/m-worker`,
177
+ `DELETE ${ENDPOINT}/v1/apps/mine`,
178
+ ]);
179
+ expect(state.mine).toBeUndefined();
180
+ });
181
+
182
+ test("an app hosting a machine outside the requested identity is never deleted whole", async () => {
183
+ const { http, log } = scriptedFlaps({
184
+ mine: [machine("foreign", { role: "db" })],
185
+ });
186
+ const execution = await executeTeardown(
187
+ {
188
+ environment: "dev",
189
+ marker: MARKER,
190
+ candidates: [{ name: "mine", type: "Fly::Machines::App", marker: MARKER }],
191
+ },
192
+ { http, endpoint: ENDPOINT, wait: NO_WAIT },
193
+ );
194
+ expect(execution.outcomes[0].outcome).toBe("not-prunable");
195
+ expect(execution.outcomes[0].detail).toContain("outside the requested identity");
196
+ expect(log.filter((l) => l.startsWith("DELETE"))).toEqual([]);
197
+ });
198
+
199
+ test("an already-absent machine and an already-absent app are deleted (idempotent)", async () => {
200
+ const { http } = scriptedFlaps({ mine: [] });
201
+ const execution = await executeTeardown(
202
+ {
203
+ environment: "dev",
204
+ marker: MARKER,
205
+ candidates: [
206
+ { name: "mine/web", type: "Fly::Machines::Machine", physicalId: "m-web", marker: MARKER },
207
+ { name: "gone-app", type: "Fly::Machines::App", marker: MARKER },
208
+ ],
209
+ },
210
+ { http, endpoint: ENDPOINT, wait: NO_WAIT },
211
+ );
212
+ expect(execution.outcomes.map((o) => ({ name: o.name, outcome: o.outcome, detail: o.detail }))).toEqual([
213
+ { name: "mine/web", outcome: "deleted", detail: "already absent" },
214
+ { name: "gone-app", outcome: "deleted", detail: "already absent" },
215
+ ]);
216
+ });
217
+
218
+ test("a machine re-stamped since planning is not-prunable, never destroyed", async () => {
219
+ const { http, log } = scriptedFlaps({
220
+ mine: [machine("web", OTHER_STACK)],
221
+ });
222
+ const execution = await executeTeardown(
223
+ {
224
+ environment: "dev",
225
+ marker: MARKER,
226
+ candidates: [{ name: "mine/web", type: "Fly::Machines::Machine", physicalId: "m-web", marker: MARKER }],
227
+ },
228
+ { http, endpoint: ENDPOINT, wait: NO_WAIT },
229
+ );
230
+ expect(execution.outcomes[0].outcome).toBe("not-prunable");
231
+ expect(log.filter((l) => l.startsWith("DELETE"))).toEqual([]);
232
+ });
233
+
234
+ test("a refused delete is a failed outcome carrying the server's message", async () => {
235
+ const inner = scriptedFlaps({ mine: [machine("web", MINE)] });
236
+ const http: FlyHttp = async (method, url, body, headers, signal) => {
237
+ if (method === "DELETE" && /\/machines\/m-web$/.test(url)) {
238
+ return { status: 500, text: "flaps had a bad day" };
239
+ }
240
+ return inner.http(method, url, body, headers, signal);
241
+ };
242
+ const execution = await executeTeardown(
243
+ {
244
+ environment: "dev",
245
+ marker: MARKER,
246
+ candidates: [{ name: "mine/web", type: "Fly::Machines::Machine", physicalId: "m-web", marker: MARKER }],
247
+ },
248
+ { http, endpoint: ENDPOINT, wait: NO_WAIT },
249
+ );
250
+ expect(execution.outcomes[0].outcome).toBe("failed");
251
+ expect(execution.outcomes[0].detail).toContain("flaps had a bad day");
252
+ });
253
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Env teardown for the fly lexicon (chant #1222) — both halves of the
3
+ * `teardownOwned` / `executeTeardown` capability pair, over the applier's own
4
+ * flaps primitives (op/activities/fly-apply.ts).
5
+ *
6
+ * Selection follows the two-tier ownership convention (#743, D2):
7
+ *
8
+ * - Machines carry the primary marker in `config.metadata`
9
+ * (`managed-by: chant` + `chant-stack` + `chant-env`), so a machine is a
10
+ * candidate exactly when its own metadata reads back the requested
11
+ * stack + env identity.
12
+ * - The app is the boundary for everything metadata-less (volumes, IPs,
13
+ * certificates, secrets). An app becomes a candidate only when it has at
14
+ * least one machine and EVERY machine in it carries the requested
15
+ * identity — an app also hosting a foreign or other-env machine is never
16
+ * deleted whole; only its matching machines are.
17
+ *
18
+ * Enumeration lists apps via `GET /v1/apps` (mudflaps serves it as-is; real
19
+ * flaps wants `?org_slug=`, appended from FLY_ORG / FLY_ORG_SLUG when set)
20
+ * and each app's machines. An unlistable app is a hole (#1089) — unknown,
21
+ * never absent.
22
+ *
23
+ * Execution deletes machines first (lease → destroy → wait, the applier's
24
+ * `destroyMachine`) and apps last (`deleteApp`, which takes the app-scoped
25
+ * volumes/IPs/certs with it). Before every delete the live side is re-read
26
+ * and the identity re-verified; already-gone is `deleted` (teardown is
27
+ * idempotent), an identity mismatch is `not-prunable`, never a delete.
28
+ */
29
+
30
+ import type {
31
+ TeardownCandidate,
32
+ TeardownEnumeration,
33
+ TeardownExecution,
34
+ TeardownHole,
35
+ TeardownOutcome,
36
+ } from "@intentius/chant/lexicon";
37
+ import { readOwnership, type OwnershipMarker } from "@intentius/chant/ownership";
38
+ import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
39
+ import {
40
+ resolveEndpoint,
41
+ defaultFlyHttp,
42
+ listMachines,
43
+ destroyMachine,
44
+ deleteApp,
45
+ type ApplyCtx,
46
+ type FlapsMachine,
47
+ type FlyHttp,
48
+ type WaitOpts,
49
+ } from "./op/activities/fly-apply";
50
+
51
+ const APP_TYPE = "Fly::Machines::App";
52
+ const MACHINE_TYPE = "Fly::Machines::Machine";
53
+
54
+ /** Live machine states that mean the machine is gone or on its way out. */
55
+ const TERMINAL_STATES = new Set(["destroyed", "destroying"]);
56
+
57
+ export interface FlyTeardownOptions {
58
+ environment: string;
59
+ marker: OwnershipMarker;
60
+ }
61
+
62
+ /** Injection seam for tests: scripted http, explicit endpoint, shrunk waits. */
63
+ export interface FlyTeardownDeps {
64
+ http?: FlyHttp;
65
+ endpoint?: string;
66
+ wait?: WaitOpts;
67
+ }
68
+
69
+ /** True when a machine's own metadata reads back exactly the requested identity. */
70
+ function matchesMarker(machine: FlapsMachine, marker: OwnershipMarker): boolean {
71
+ const read = readOwnership(machine.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS);
72
+ return read !== undefined && read.stack === marker.stack && read.env === marker.env;
73
+ }
74
+
75
+ /** Machines that are actually live — terminal ones are already gone. */
76
+ function liveMachines(machines: FlapsMachine[]): FlapsMachine[] {
77
+ return machines.filter((m) => !TERMINAL_STATES.has(m.state));
78
+ }
79
+
80
+ /** `GET /v1/apps`, with `?org_slug=` appended when the env names an org. */
81
+ async function listApps(
82
+ ctx: ApplyCtx,
83
+ http: FlyHttp,
84
+ signal?: AbortSignal,
85
+ ): Promise<string[]> {
86
+ const org = process.env.FLY_ORG || process.env.FLY_ORG_SLUG;
87
+ const url = `${ctx.base}/v1/apps${org ? `?org_slug=${encodeURIComponent(org)}` : ""}`;
88
+ const res = await http("GET", url, undefined, undefined, signal);
89
+ if (res.status >= 300) throw new Error(`app list failed (${res.status}): ${res.text}`);
90
+ const body = JSON.parse(res.text) as { apps?: Array<{ name?: string }> };
91
+ return (body.apps ?? []).map((a) => a.name).filter((n): n is string => typeof n === "string" && n.length > 0);
92
+ }
93
+
94
+ /**
95
+ * Enumerate the env's marker-matching machines and whole-app boundaries — the
96
+ * fly half of `chant lifecycle teardown <env>` planning.
97
+ */
98
+ export async function teardownOwned(
99
+ options: FlyTeardownOptions,
100
+ deps: FlyTeardownDeps = {},
101
+ ): Promise<TeardownEnumeration> {
102
+ const http = deps.http ?? defaultFlyHttp();
103
+ const ctx: ApplyCtx = { base: resolveEndpoint({ ...(deps.endpoint ? { endpoint: deps.endpoint } : {}) }) };
104
+
105
+ const candidates: TeardownCandidate[] = [];
106
+ const holes: TeardownHole[] = [];
107
+
108
+ const apps = await listApps(ctx, http); // a failed app list throws — core turns it into a whole-lexicon hole
109
+
110
+ for (const app of apps) {
111
+ let machines: FlapsMachine[];
112
+ try {
113
+ machines = liveMachines(await listMachines(ctx, app, http));
114
+ } catch (err) {
115
+ holes.push({
116
+ name: app,
117
+ type: APP_TYPE,
118
+ reason: "read-failed",
119
+ detail: err instanceof Error ? err.message : String(err),
120
+ });
121
+ continue;
122
+ }
123
+
124
+ const matching = machines.filter((m) => matchesMarker(m, options.marker));
125
+ for (const m of matching) {
126
+ candidates.push({
127
+ name: `${app}/${m.name}`,
128
+ type: MACHINE_TYPE,
129
+ physicalId: m.id,
130
+ marker: readOwnership(m.config?.metadata, FLY_METADATA_OWNERSHIP_KEYS)!,
131
+ });
132
+ }
133
+
134
+ // The app boundary: whole-app delete only when every live machine carries
135
+ // the requested identity. Its marker is read back off its machines — the
136
+ // only channel an app has (#743).
137
+ if (machines.length > 0 && matching.length === machines.length) {
138
+ candidates.push({
139
+ name: app,
140
+ type: APP_TYPE,
141
+ marker: readOwnership(matching[0].config?.metadata, FLY_METADATA_OWNERSHIP_KEYS)!,
142
+ });
143
+ }
144
+ }
145
+
146
+ return { candidates, ...(holes.length > 0 ? { holes } : {}) };
147
+ }
148
+
149
+ /**
150
+ * Delete the handed-over candidates: machines first, apps last. One outcome
151
+ * per candidate, always.
152
+ */
153
+ export async function executeTeardown(
154
+ options: FlyTeardownOptions & { candidates: TeardownCandidate[] },
155
+ deps: FlyTeardownDeps = {},
156
+ ): Promise<TeardownExecution> {
157
+ const http = deps.http ?? defaultFlyHttp();
158
+ const ctx: ApplyCtx = { base: resolveEndpoint({ ...(deps.endpoint ? { endpoint: deps.endpoint } : {}) }) };
159
+
160
+ const outcomes: TeardownOutcome[] = [];
161
+
162
+ for (const candidate of options.candidates.filter((c) => c.type === MACHINE_TYPE)) {
163
+ outcomes.push(await destroyMachineCandidate(ctx, candidate, options.marker, http, deps.wait));
164
+ }
165
+ for (const candidate of options.candidates.filter((c) => c.type === APP_TYPE)) {
166
+ outcomes.push(await deleteAppCandidate(ctx, candidate, options.marker, http));
167
+ }
168
+ for (const candidate of options.candidates) {
169
+ if (candidate.type === MACHINE_TYPE || candidate.type === APP_TYPE) continue;
170
+ outcomes.push({
171
+ name: candidate.name,
172
+ type: candidate.type,
173
+ outcome: "not-prunable",
174
+ detail: `fly teardown deletes machines and apps; ${candidate.type} is app-scoped and goes with its app`,
175
+ });
176
+ }
177
+
178
+ return { outcomes };
179
+ }
180
+
181
+ async function destroyMachineCandidate(
182
+ ctx: ApplyCtx,
183
+ candidate: TeardownCandidate,
184
+ marker: OwnershipMarker,
185
+ http: FlyHttp,
186
+ wait?: WaitOpts,
187
+ ): Promise<TeardownOutcome> {
188
+ const base = {
189
+ name: candidate.name,
190
+ type: candidate.type,
191
+ ...(candidate.physicalId ? { physicalId: candidate.physicalId } : {}),
192
+ };
193
+ const slash = candidate.name.indexOf("/");
194
+ if (slash <= 0) {
195
+ return { ...base, outcome: "not-prunable", detail: "machine candidate name is not app/machine-shaped" };
196
+ }
197
+ const app = candidate.name.slice(0, slash);
198
+ const machineName = candidate.name.slice(slash + 1);
199
+
200
+ let machines: FlapsMachine[];
201
+ try {
202
+ machines = await listMachines(ctx, app, http);
203
+ } catch (err) {
204
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
205
+ }
206
+
207
+ const live = liveMachines(machines).find((m) =>
208
+ candidate.physicalId ? m.id === candidate.physicalId : m.name === machineName,
209
+ );
210
+ if (!live) {
211
+ return { ...base, outcome: "deleted", detail: "already absent" };
212
+ }
213
+ // Re-verify right before destroying — a delete is not undoable.
214
+ if (!matchesMarker(live, marker)) {
215
+ return {
216
+ ...base,
217
+ outcome: "not-prunable",
218
+ detail: "the live machine no longer carries the requested marker identity",
219
+ };
220
+ }
221
+
222
+ try {
223
+ await destroyMachine(ctx, app, live.id, http, undefined, wait ?? {});
224
+ } catch (err) {
225
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
226
+ }
227
+ return { ...base, outcome: "deleted" };
228
+ }
229
+
230
+ async function deleteAppCandidate(
231
+ ctx: ApplyCtx,
232
+ candidate: TeardownCandidate,
233
+ marker: OwnershipMarker,
234
+ http: FlyHttp,
235
+ ): Promise<TeardownOutcome> {
236
+ const base = { name: candidate.name, type: candidate.type };
237
+ const app = candidate.name;
238
+
239
+ // The boundary re-check: an app that (still, or again) hosts any machine
240
+ // outside the requested identity is not deleted whole. Machines this same
241
+ // execution just destroyed are gone by now — destroyMachine waits.
242
+ let machines: FlapsMachine[];
243
+ try {
244
+ machines = liveMachines(await listMachines(ctx, app, http));
245
+ } catch (err) {
246
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
247
+ }
248
+ const foreign = machines.filter((m) => !matchesMarker(m, marker));
249
+ if (foreign.length > 0) {
250
+ return {
251
+ ...base,
252
+ outcome: "not-prunable",
253
+ detail: `the app hosts ${foreign.length} machine(s) outside the requested identity`,
254
+ };
255
+ }
256
+
257
+ try {
258
+ const result = await deleteApp(ctx, app, http);
259
+ return result.deleted ? { ...base, outcome: "deleted" } : { ...base, outcome: "deleted", detail: "already absent" };
260
+ } catch (err) {
261
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
262
+ }
263
+ }