@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.
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/teardown.d.ts +54 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/describe-resources.test.ts +26 -0
- package/src/describe-resources.ts +7 -0
- package/src/plugin.ts +14 -0
- package/src/teardown.test.ts +253 -0
- package/src/teardown.ts +263 -0
|
@@ -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;
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
18
|
+
"composite": "85f11e33db1d17e0fb429183c61ffc405fa34f66d88b71bda49476a7446fe289"
|
|
19
19
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/teardown.ts
ADDED
|
@@ -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
|
+
}
|