@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.
- package/dist/codegen/docs.d.ts +0 -6
- package/dist/codegen/docs.d.ts.map +1 -1
- 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/codegen/docs.ts +0 -86
- package/src/describe-resources.test.ts +26 -0
- package/src/describe-resources.ts +7 -0
- package/src/import/roundtrip.test.ts +2 -2
- package/src/plugin.ts +14 -0
- package/src/serializer.test.ts +16 -16
- package/src/teardown.test.ts +253 -0
- package/src/teardown.ts +263 -0
package/dist/codegen/docs.d.ts
CHANGED
|
@@ -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;
|
|
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;
|
|
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": "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": "
|
|
18
|
+
"composite": "6e0ad16fc43da2edad2bc8bfa9a6a42ced592d6b8871914af192c11099fdb8ee"
|
|
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.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.
|
|
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",
|
package/src/codegen/docs.ts
CHANGED
|
@@ -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
|
package/src/serializer.test.ts
CHANGED
|
@@ -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
|
+
});
|
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
|
+
}
|