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