@intentius/chant-lexicon-fly 0.45.0 → 0.49.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.
@@ -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": "aed323ed6c59f96ab9470a796a81e75ddac9d72d7b1a5f7522cc7a544d88df42",
4
+ "manifest.json": "6dcc2b17471f8b97f59b038dc09152ed5ce8c1ed3668889577f9ec066dc7783a",
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": "2831247df3815a0f1f18a99d8a7dc4baeb905952268fec353746037d2d8a86c2"
18
+ "composite": "85f11e33db1d17e0fb429183c61ffc405fa34f66d88b71bda49476a7446fe289"
19
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fly",
3
- "version": "0.45.0",
3
+ "version": "0.49.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.45.0",
3
+ "version": "0.49.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.45.0",
60
+ "@intentius/chant": "^0.49.0",
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
@@ -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,
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
@@ -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
+ }