@volter/twin-fly 0.1.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.
@@ -0,0 +1,1272 @@
1
+ // Fly.io MACHINES API twin REQUEST HANDLER — the canonical api.machines.dev/v1 surface.
2
+ //
3
+ // Contract: handleFlyTwinRequest({ method, path, body, headers, root, readOnly, runtime,
4
+ // occurredAt }) -> { status, body }. State is the kernel event/action log (D1); reads are the
5
+ // projection. No real Fly is ever called (D4) — the only genuinely-external thing this pack
6
+ // touches is the LOCAL Docker daemon, and only through the injected `FlyContainerRuntime` seam
7
+ // (fly-machines.ts), which defaults to the pure-ledger VIRTUAL runtime so every verify and the
8
+ // whole gate run with no Docker anywhere.
9
+ //
10
+ // GROUNDING: surface + schemas from Fly's first-party OpenAPI (live-fetched
11
+ // docs.machines.dev/swagger/doc.json, "Machines API 1.0") cross-checked against superfly/fly-go
12
+ // (flyctl's own client). Facts taken from fly-go over the OpenAPI where they disagree:
13
+ // - lease responses are WRAPPED {status:"success", data:{...}} (fly-go MachineLease), not the
14
+ // OpenAPI's bare Lease schema;
15
+ // - the lease nonce travels in the `fly-machine-lease-nonce` header (flaps NonceHeader);
16
+ // - volumes list / snapshots list are BARE arrays (flaps_volumes.go decodes []Volume);
17
+ // - machine start answers {previous_state,...} (fly-go MachineStartResponse).
18
+ // Error envelope: {error: string} with the HTTP status (OpenAPI ErrorResponse; its optional
19
+ // `status` enum — name_taken etc. — is included where the spec grounds it, e.g. app-name 422).
20
+ // The docs' response-code table (fly.io/docs/machines/api/working-with-machines-api): 401
21
+ // missing/invalid credentials, 404 not found, 408 wait timeout, 422 validation.
22
+ //
23
+ // AUTH is faked-but-ENFORCED (D2): a missing/empty Bearer token -> 401 like the vendor; any
24
+ // non-empty token is accepted (the twin has no account database — disclosed). `readOnly` rejects
25
+ // writes with 405 (D3). An unmodeled operation FAILS like Fly (404 {error}, never a fabricated
26
+ // success) — including the vendor's own routes this twin has not modeled yet (certificates,
27
+ // tokens, Managed Postgres, orgs listings: all enumerated as todos in fly-capabilities.ts).
28
+ //
29
+ // ⚠ doc-unverified modeling choices (no live account to probe; each disclosed here + in
30
+ // spec-sources.json): exact error STRINGS for negative paths (except wait's
31
+ // "timeout reached waiting for machine's state to change" and the 422 app-name-taken family,
32
+ // which are documented); {ok:true} action-reply bodies for stop/restart/cordon/uncordon/
33
+ // suspend/signal/delete; 409 for lease conflicts; idempotent 200 for start-on-started /
34
+ // stop-on-stopped; 404 for DELETE of an unknown secret and for lease reads/releases with no live
35
+ // lease; on-failure restart max_retries defaulting to 10 when config.restart is absent.
36
+ import { containerSpecOf, getRow, imageRefOf, machineEvent, machinePrivateIp, countRows, newAppNumericId, newInstanceId, newLeaseNonce, newMachineId, newMachineName, newVolumeId, nowEpochMs, nowIso, rows, VIRTUAL_FLY_RUNTIME, write, } from "./fly-machines.js";
37
+ import { createHash } from 'node:crypto';
38
+ import { resolveSubjectId } from '@volter/world-core';
39
+ import { SERVICE } from "./fly-machines.js";
40
+ export const FLY_RESOURCE_TYPES = ['app', 'machine', 'volume', 'secret'];
41
+ /** The public + internal Machines API hosts (fly.io/docs/machines/api/working-with-machines-api). */
42
+ export const FLY_API_HOST = 'api.machines.dev';
43
+ export const FLY_INTERNAL_API_HOST = '_api.internal';
44
+ // Fly's error envelope (OpenAPI ErrorResponse): {error, status?, details?}.
45
+ function err(message, status = 404, code) {
46
+ return { status, body: { error: message, ...(code ? { status: code } : {}) } };
47
+ }
48
+ function parseBody(req) {
49
+ if (!req.body)
50
+ return {};
51
+ try {
52
+ const v = JSON.parse(req.body);
53
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ function lowerHeaders(h) {
60
+ const out = {};
61
+ for (const [k, v] of Object.entries(h ?? {}))
62
+ if (v !== undefined)
63
+ out[k.toLowerCase()] = v;
64
+ return out;
65
+ }
66
+ // ── views ─────────────────────────────────────────────────────────────────────────────────────
67
+ function appView(a, root, occurredAt) {
68
+ const machines = rows('machine', root).filter((m) => m.app_name === a.id && m._purged !== true && m.state !== 'destroyed');
69
+ const volumes = rows('volume', root).filter((v) => v.app_name === a.id && !volumeGone(v, occurredAt));
70
+ return {
71
+ id: String(a.id),
72
+ name: String(a.id),
73
+ internal_numeric_id: a.internal_numeric_id,
74
+ machine_count: machines.length,
75
+ volume_count: volumes.length,
76
+ network: a.network ?? 'default',
77
+ organization: { name: a.org_name ?? String(a.org_slug), slug: String(a.org_slug) },
78
+ status: a.status ?? 'pending',
79
+ };
80
+ }
81
+ function appListView(a, root) {
82
+ const v = appView(a, root);
83
+ return { id: v.id, name: v.name, machine_count: v.machine_count, network: v.network, status: v.status };
84
+ }
85
+ function machineView(m, opts = {}) {
86
+ const events = ([...(m.events ?? [])]).reverse(); // newest first
87
+ // The vendor gates Machine.lease behind ?include_leases=true, and an EXPIRED lease is not a
88
+ // lease — leaseIsLive is the same clock the lease endpoints use (§9 round one, finding 5).
89
+ const lease = opts.includeLeases && leaseIsLive(m, opts.occurredAt)
90
+ ? { description: m.lease_description ?? '', expires_at: m.lease_expires_at, owner: String(m.lease_owner ?? 'twin@local') }
91
+ : undefined;
92
+ return {
93
+ id: String(m.id),
94
+ name: String(m.name),
95
+ state: String(m.state),
96
+ region: String(m.region),
97
+ instance_id: String(m.instance_id),
98
+ version: String(m.instance_id),
99
+ private_ip: String(m.private_ip),
100
+ created_at: String(m.created_at),
101
+ updated_at: String(m.machine_updated_at ?? m.created_at),
102
+ config: m.config ?? {},
103
+ image_ref: m.image_ref ?? {},
104
+ events,
105
+ checks: [],
106
+ host_status: 'ok',
107
+ cordoned: m.cordoned === true,
108
+ ...(lease ? { lease } : {}),
109
+ ...(opts.nonce ? { nonce: opts.nonce } : {}),
110
+ // Twin-only ADDITIVE observability (disclosed, README ## Coverage): which execution plane ran
111
+ // this machine and where its published loopback ports landed. Never replaces a real field.
112
+ // (stored `_`-prefixed: the twin's own bookkeeping, outside the vendor's shape — protocol 2 shape parity)
113
+ ...(m._twin_runtime ? { twin_runtime: m._twin_runtime } : {}),
114
+ ...(Array.isArray(m._twin_local_ports) && m._twin_local_ports.length > 0 ? { twin_local_ports: m._twin_local_ports } : {}),
115
+ ...(typeof m._twin_last_failure === 'string' ? { twin_last_failure: m._twin_last_failure } : {}),
116
+ };
117
+ }
118
+ function volumeView(v) {
119
+ const sizeGb = Number(v.size_gb ?? 1);
120
+ const blockSize = 4096;
121
+ const blocks = Math.floor((sizeGb * 1024 * 1024 * 1024) / blockSize);
122
+ return {
123
+ id: String(v.id),
124
+ name: String(v.name),
125
+ app_name: undefined, // not in the Volume schema — kept off the wire
126
+ state: String(v.state),
127
+ region: String(v.region),
128
+ size_gb: sizeGb,
129
+ encrypted: v.encrypted !== false,
130
+ fstype: String(v.fstype ?? 'ext4'),
131
+ zone: String(v.zone),
132
+ snapshot_retention: Number(v.snapshot_retention ?? 5),
133
+ auto_backup_enabled: v.auto_backup_enabled !== false,
134
+ block_size: blockSize,
135
+ blocks,
136
+ blocks_free: blocks,
137
+ blocks_avail: blocks,
138
+ bytes_total: sizeGb * 1024 * 1024 * 1024,
139
+ bytes_used: 0,
140
+ attached_machine_id: v.attached_machine_id ?? null,
141
+ attached_alloc_id: null,
142
+ created_at: String(v.created_at),
143
+ host_status: 'ok',
144
+ };
145
+ }
146
+ function secretView(s, showSecrets) {
147
+ return {
148
+ name: String(s.secret_name),
149
+ digest: String(s.digest),
150
+ created_at: String(s.created_at),
151
+ updated_at: String(s.secret_updated_at ?? s.created_at),
152
+ ...(showSecrets ? { value: String(s.value) } : {}),
153
+ };
154
+ }
155
+ /** Rows that still belong to the CURRENT life of their app. An app DELETE purges its machines
156
+ * and volumes (`_purged`): real Fly destroys an app's volumes with it, and a re-created name is
157
+ * a brand-new app that must start EMPTY — without the flag, the first life's rows would bleed
158
+ * back into the recreated app (§9 round one, finding 1). */
159
+ function liveAppRows(type, appName, root) {
160
+ return rows(type, root).filter((r) => r.app_name === appName && r._purged !== true);
161
+ }
162
+ // ── machine event/state helpers ───────────────────────────────────────────────────────────────
163
+ function pushEvent(m, type, status, source, occurredAt) {
164
+ const events = [...(m.events ?? [])];
165
+ events.push(machineEvent(type, status, source, occurredAt, events.length));
166
+ return events;
167
+ }
168
+ function leaseIsLive(m, occurredAt) {
169
+ return typeof m.lease_nonce === 'string' && m.lease_nonce.length > 0
170
+ && typeof m.lease_expires_at === 'number' && m.lease_expires_at * 1000 > nowEpochMs(occurredAt);
171
+ }
172
+ /** A live lease guards every state-mutating machine op: the request must carry the nonce in the
173
+ * `fly-machine-lease-nonce` header (fly-go flaps NonceHeader). */
174
+ function leaseBlocks(m, headers, occurredAt) {
175
+ if (!leaseIsLive(m, occurredAt))
176
+ return undefined;
177
+ const nonce = headers['fly-machine-lease-nonce'];
178
+ if (nonce === m.lease_nonce)
179
+ return undefined;
180
+ return err(`machine ID ${m.id} lease currently held by ${m.lease_owner ?? 'twin@local'}, expires at ${new Date(m.lease_expires_at * 1000).toISOString()}`, 400);
181
+ }
182
+ /** on-failure's retry cap when config.restart is absent (⚠ doc-unverified default — the spec
183
+ * names on-failure as the default policy but publishes no default max_retries; 10 is this
184
+ * twin's disclosed choice). */
185
+ const FLY_DEFAULT_MAX_RETRIES = 10;
186
+ /** Ask the execution plane whether a started machine's container is still alive, and fold an
187
+ * exit back into the control plane HONORING the vendor's restart policy (fly.MachineRestart:
188
+ * `no` → stop; `always` → restart; `on-failure` — THE DEFAULT when no policy is set — → restart
189
+ * on a non-zero exit, up to max_retries). The exit code is ledgered into the event's
190
+ * fly-go-shaped request payload ({exit_event:{exit_code}}). Offline-provable with fake runtimes.
191
+ * READ-ONLY twins never write here (§9 round one, finding 4): the fold is skipped and the
192
+ * control-plane state is served as-is.
193
+ *
194
+ * DELIBERATE: the fold runs BEFORE any lease check (it is dispatched ahead of the per-action
195
+ * handlers) and is not itself lease-guarded — flyd is the PLATFORM, not a user, and observing
196
+ * that a container died is true regardless of who holds the machine lease or whether the
197
+ * triggering request is then refused. A lease guards user MUTATIONS, not reality. */
198
+ async function foldRuntimeExit(m, req, runtime) {
199
+ if (req.readOnly)
200
+ return m;
201
+ if (m.state !== 'started' || !runtime.inspect || typeof m._container_ref !== 'string')
202
+ return m;
203
+ let live;
204
+ try {
205
+ live = await runtime.inspect(m._container_ref);
206
+ }
207
+ catch {
208
+ return m;
209
+ } // an un-inspectable container is not evidence of an exit
210
+ if (live.running)
211
+ return m;
212
+ const exitCode = typeof live.exitCode === 'number' ? live.exitCode : 0;
213
+ const exitRequest = { exit_event: { exit_code: exitCode, oom_killed: live.oomKilled === true, requested_stop: false } };
214
+ const config = (m.config ?? {});
215
+ const policy = typeof config.restart?.policy === 'string' ? config.restart.policy : 'on-failure';
216
+ const maxRetries = typeof config.restart?.max_retries === 'number' ? config.restart.max_retries : FLY_DEFAULT_MAX_RETRIES;
217
+ const attempts = typeof m._flyd_restart_attempts === 'number' ? m._flyd_restart_attempts : 0;
218
+ const shouldRestart = policy === 'always' || (policy === 'on-failure' && exitCode !== 0 && attempts < maxRetries);
219
+ if (shouldRestart) {
220
+ const priorEvents = m.events ?? [];
221
+ let events = [...priorEvents, machineEvent('exit', 'restarting', 'flyd', req.occurredAt, priorEvents.length, exitRequest)];
222
+ try {
223
+ // Reap the exited container FIRST (§9 round two, finding 3): the docker plane names
224
+ // containers deterministically (fly-twin-<machineId>), so a relaunch without a reap dies
225
+ // on a name conflict — silently, since the catch below folds to stopped.
226
+ try {
227
+ await runtime.remove(m._container_ref);
228
+ }
229
+ catch { /* already gone */ }
230
+ const launched = await runtime.run(containerSpecOf(m));
231
+ events = [...events, machineEvent('start', 'started', 'flyd', req.occurredAt, events.length)];
232
+ return write('machine', String(m.id), {
233
+ ...m, state: 'started', events,
234
+ _container_ref: launched.containerRef,
235
+ _twin_local_ports: launched.publishedPorts ?? m._twin_local_ports ?? [],
236
+ _flyd_restart_attempts: attempts + 1,
237
+ _twin_last_failure: null,
238
+ machine_updated_at: nowIso(req.occurredAt),
239
+ }, 'machine.flyd_restart', req.root, req.occurredAt);
240
+ }
241
+ catch (error) {
242
+ // Retain and forward the causal restart refusal. Discarding this catch used to turn a
243
+ // capacity failure into an unexplained stopped machine with only the original exit code.
244
+ const restartError = error instanceof Error ? error.message : String(error);
245
+ process.stderr.write(`[machine ${String(m.id)}] automatic restart failed: ${restartError}\n`);
246
+ const stoppedEvents = [...events, machineEvent('exit', 'stopped', 'flyd', req.occurredAt, events.length, {
247
+ ...exitRequest,
248
+ restart_error: restartError,
249
+ })];
250
+ return write('machine', String(m.id), {
251
+ ...m,
252
+ state: 'stopped',
253
+ events: stoppedEvents,
254
+ _twin_last_failure: restartError,
255
+ machine_updated_at: nowIso(req.occurredAt),
256
+ }, 'machine.exit', req.root, req.occurredAt);
257
+ }
258
+ }
259
+ const priorEvents = m.events ?? [];
260
+ const events = [...priorEvents, machineEvent('exit', 'stopped', 'flyd', req.occurredAt, priorEvents.length, exitRequest)];
261
+ return write('machine', String(m.id), { ...m, state: 'stopped', events, machine_updated_at: nowIso(req.occurredAt) }, 'machine.exit', req.root, req.occurredAt);
262
+ }
263
+ // ── the handler ───────────────────────────────────────────────────────────────────────────────
264
+ export async function handleFlyTwinRequest(req) {
265
+ const runtime = req.runtime ?? VIRTUAL_FLY_RUNTIME;
266
+ const headers = lowerHeaders(req.headers);
267
+ const [pathOnly, queryString = ''] = req.path.split('?', 2);
268
+ const params = new URLSearchParams(queryString);
269
+ const seg = pathOnly.replace(/^\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
270
+ const method = req.method.toUpperCase();
271
+ // Auth: faked-but-enforced. Missing/empty Bearer -> the vendor's 401 (docs response table).
272
+ const auth = headers['authorization'] ?? '';
273
+ const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : '';
274
+ if (token.length === 0)
275
+ return err('unauthorized', 401);
276
+ if (req.readOnly && method !== 'GET' && method !== 'HEAD') {
277
+ return err('read-only twin: writes are disabled', 405);
278
+ }
279
+ if (seg[0] !== 'v1')
280
+ return err('not found', 404);
281
+ if (seg[1] === 'apps') {
282
+ // /v1/apps
283
+ if (seg.length === 2) {
284
+ if (method === 'GET') {
285
+ const orgSlug = params.get('org_slug');
286
+ if (!orgSlug)
287
+ return err('org_slug is required', 400);
288
+ const apps = rows('app', req.root).filter((a) => a.org_slug === orgSlug && a.deleted !== true);
289
+ return { status: 200, body: { total_apps: apps.length, apps: apps.map((a) => appListView(a, req.root)) } };
290
+ }
291
+ if (method === 'POST')
292
+ return createApp(req);
293
+ return err('not found', 404);
294
+ }
295
+ // protocol 2: an app's name is Fly's id, so a landing adopts it as it stands — but the alias map is the one
296
+ // truth about what an id became, and the boundary asks it once, for the app as for its machines and volumes
297
+ const appName = resolveSubjectId(SERVICE, 'app', decodeURIComponent(seg[2]), req.root);
298
+ const app = getRow('app', appName, req.root);
299
+ const appLive = app && app.deleted !== true ? app : undefined;
300
+ // /v1/apps/{app_name}
301
+ if (seg.length === 3) {
302
+ if (method === 'GET')
303
+ return appLive ? { status: 200, body: appView(appLive, req.root, req.occurredAt) } : err('app not found', 404);
304
+ if (method === 'DELETE') {
305
+ if (!appLive)
306
+ return err('app not found', 404);
307
+ // Destroy the app's machines (force) so the execution plane is reaped with it, destroy
308
+ // its volumes (real Fly destroys an app's volumes with the app), and PURGE both: a
309
+ // re-created app of the same name is a brand-new app and must start empty (§9 finding 1).
310
+ for (const m of liveAppRows('machine', appName, req.root)) {
311
+ if (m.state !== 'destroyed')
312
+ await destroyMachineRow(m, req, runtime);
313
+ const fresh = getRow('machine', String(m.id), req.root) ?? m;
314
+ await write('machine', String(m.id), { ...fresh, _purged: true }, 'machine.purge', req.root, req.occurredAt);
315
+ }
316
+ for (const v of liveAppRows('volume', appName, req.root)) {
317
+ if (runtime.removeVolume) {
318
+ try {
319
+ await runtime.removeVolume(String(v.id));
320
+ }
321
+ catch { /* never materialized */ }
322
+ }
323
+ await write('volume', String(v.id), { ...v, state: 'destroyed', attached_machine_id: null, _purged: true }, 'volume.purge', req.root, req.occurredAt);
324
+ }
325
+ // Secrets die with the app too (§9 round two, finding 1): a recreated name must not
326
+ // inherit the first life's secret VALUES — that leak is a credential disclosure, and it
327
+ // would also silently regress the recreated app's fresh _secrets_version counter.
328
+ for (const sec of rows('secret', req.root).filter((x) => x.app_name === appName && x.deleted !== true)) {
329
+ await write('secret', String(sec.id), { ...sec, deleted: true }, 'secret.purge', req.root, req.occurredAt);
330
+ }
331
+ await write('app', appName, { ...appLive, deleted: true }, 'app.delete', req.root, req.occurredAt);
332
+ return { status: 202, body: {} };
333
+ }
334
+ return err('not found', 404);
335
+ }
336
+ if (!appLive)
337
+ return err('app not found', 404);
338
+ // protocol 2: after a landing adopted Fly's id for a machine or a volume, a caller on a branch may still
339
+ // address it by the local id — resolved through the alias map once, here at the boundary
340
+ if (seg[4] && (seg[3] === 'machines' || seg[3] === 'volumes'))
341
+ seg[4] = resolveSubjectId(SERVICE, seg[3] === 'machines' ? 'machine' : 'volume', decodeURIComponent(seg[4]), req.root);
342
+ if (seg[3] === 'machines')
343
+ return handleMachines(req, runtime, appName, seg, params, headers, method);
344
+ if (seg[3] === 'volumes')
345
+ return handleVolumes(req, runtime, appName, seg, method);
346
+ if (seg[3] === 'secrets')
347
+ return handleSecrets(req, appName, seg, params, method);
348
+ // Real-but-unmodeled app sub-surfaces (certificates, ip_assignments, deploy_token,
349
+ // secretkeys): enumerated as todos in the manifest; until modeled they fail like the vendor.
350
+ return err('not found', 404);
351
+ }
352
+ // /v1/orgs/*, /v1/platform/*, /v1/tokens/*, /v1/postgres/*: real vendor surface, not modeled
353
+ // yet (todos in fly-capabilities.ts) — fail like the vendor, never fabricate.
354
+ return err('not found', 404);
355
+ }
356
+ // ── apps ──────────────────────────────────────────────────────────────────────────────────────
357
+ async function createApp(req) {
358
+ const b = parseBody(req);
359
+ const name = typeof b.app_name === 'string' && b.app_name ? b.app_name : (typeof b.name === 'string' ? b.name : '');
360
+ if (!name)
361
+ return err('app name required', 422);
362
+ if (!/^[a-z0-9-]+$/.test(name))
363
+ return err(`Validation failed: Name '${name}' must contain only lowercase letters, numbers, and dashes`, 422);
364
+ const orgSlug = typeof b.org_slug === 'string' && b.org_slug ? b.org_slug : 'personal';
365
+ // the name is the id: a name a landing adopted (the alias map's one truth) is taken exactly as one it never touched
366
+ const existing = getRow('app', resolveSubjectId(SERVICE, 'app', name, req.root), req.root);
367
+ if (existing && existing.deleted !== true) {
368
+ // 422 name-taken is DOCUMENTED (docs response-code table: "Validation failed (for example,
369
+ // an app name that is already taken)"; ErrorResponse.status enum carries name_taken).
370
+ return err('Validation failed: Name has already been taken', 422, 'name_taken');
371
+ }
372
+ // Not a name hash: real Fly mints a fresh internal id, so a deleted+recreated name must not
373
+ // resurrect the old one (§9 round one, finding 13). The seed carries the ORDINAL this app row
374
+ // takes over every app the tree ever held (a deleted app is a tombstone the tree keeps), so the count
375
+ // only grows — a recreated name mints at a higher ordinal and gets a different id, while two
376
+ // identical worlds still agree byte for byte (R9).
377
+ const numericId = newAppNumericId(`app:${name}:${nowIso(req.occurredAt)}:${countRows('app', req.root)}`);
378
+ await write('app', name, {
379
+ org_slug: orgSlug,
380
+ org_name: orgSlug,
381
+ network: typeof b.network === 'string' && b.network ? b.network : 'default',
382
+ internal_numeric_id: numericId,
383
+ status: 'pending',
384
+ _created_at: nowIso(req.occurredAt), // the App schema carries no created_at: the twin's own note, off the wire
385
+ }, 'app.create', req.root, req.occurredAt);
386
+ return { status: 201, body: {} }; // OpenAPI CreateAppResponse: {token?} — no token minted locally
387
+ }
388
+ // ── machines ──────────────────────────────────────────────────────────────────────────────────
389
+ async function handleMachines(req, runtime, appName, seg, params, headers, method) {
390
+ // /v1/apps/{app}/machines
391
+ if (seg.length === 4) {
392
+ if (method === 'GET') {
393
+ const includeDeleted = params.get('include_deleted') === 'true';
394
+ const includeLeases = params.get('include_leases') === 'true';
395
+ const state = params.get('state');
396
+ const region = params.get('region');
397
+ let ms = liveAppRows('machine', appName, req.root);
398
+ const refreshed = [];
399
+ for (const m of ms)
400
+ refreshed.push(await foldRuntimeExit(m, req, runtime));
401
+ ms = refreshed;
402
+ if (!includeDeleted)
403
+ ms = ms.filter((m) => m.state !== 'destroyed');
404
+ if (state) {
405
+ // The vendor documents ?state= as a COMMA-SEPARATED list (§9 round one, finding 6).
406
+ const wanted = new Set(state.split(',').map((x) => x.trim()).filter(Boolean));
407
+ ms = ms.filter((m) => wanted.has(String(m.state)));
408
+ }
409
+ if (region)
410
+ ms = ms.filter((m) => m.region === region);
411
+ return { status: 200, body: ms.map((m) => machineView(m, { includeLeases, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) })) };
412
+ }
413
+ if (method === 'POST')
414
+ return createMachine(req, runtime, appName);
415
+ return err('not found', 404);
416
+ }
417
+ const machineId = seg[4];
418
+ let m = getRow('machine', machineId, req.root);
419
+ if (!m || m.app_name !== appName || m._purged === true)
420
+ return err('machine not found', 404);
421
+ m = await foldRuntimeExit(m, req, runtime);
422
+ // /v1/apps/{app}/machines/{id}
423
+ if (seg.length === 5) {
424
+ if (method === 'GET') {
425
+ const includeLeases = params.get('include_leases') === 'true';
426
+ return { status: 200, body: machineView(m, { includeLeases, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) }) };
427
+ }
428
+ if (method === 'POST')
429
+ return updateMachine(req, runtime, m, headers, params);
430
+ if (method === 'DELETE') {
431
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
432
+ if (blocked)
433
+ return blocked;
434
+ if (m.state === 'destroyed')
435
+ return { status: 200, body: { ok: true } };
436
+ const force = params.get('force') === 'true';
437
+ if (m.state === 'started' && !force) {
438
+ return err(`machine ${m.id} is active: stop it first or pass force=true`, 400);
439
+ }
440
+ await destroyMachineRow(m, req, runtime);
441
+ return { status: 200, body: { ok: true } };
442
+ }
443
+ return err('not found', 404);
444
+ }
445
+ const action = seg[5];
446
+ if (seg.length === 6) {
447
+ // Lifecycle verbs are POST-only in the vendor's OpenAPI; any other method must 404, not
448
+ // act — a method-blind dispatch would also let a readOnly twin mutate state via GET, since
449
+ // the readOnly guard above only intercepts non-GET methods.
450
+ const lifecycle = action === 'start' || action === 'stop' || action === 'restart'
451
+ || action === 'signal' || action === 'suspend' || action === 'cordon' || action === 'uncordon';
452
+ if (lifecycle && method !== 'POST')
453
+ return err('not found', 404);
454
+ switch (action) {
455
+ case 'start': return startMachine(req, runtime, m, headers);
456
+ case 'stop': return stopMachine(req, runtime, m, headers);
457
+ case 'restart': return restartMachine(req, runtime, m, headers);
458
+ case 'signal': return signalMachine(req, runtime, m, headers);
459
+ case 'suspend': return suspendMachine(req, runtime, m, headers);
460
+ case 'cordon': return cordonMachine(req, m, headers, true);
461
+ case 'uncordon': return cordonMachine(req, m, headers, false);
462
+ case 'wait': return method === 'GET' ? waitMachine(req, m, params) : err('not found', 404);
463
+ case 'events': {
464
+ if (method !== 'GET')
465
+ return err('not found', 404);
466
+ const limitRaw = params.get('limit');
467
+ const limit = limitRaw ? Number(limitRaw) : undefined;
468
+ const events = ([...(m.events ?? [])]).reverse();
469
+ return { status: 200, body: limit && Number.isFinite(limit) && limit > 0 ? events.slice(0, limit) : events };
470
+ }
471
+ case 'versions': {
472
+ if (method !== 'GET')
473
+ return err('not found', 404);
474
+ return { status: 200, body: (m._config_history ?? []) };
475
+ }
476
+ case 'exec': {
477
+ if (method !== 'POST')
478
+ return err('not found', 404);
479
+ const execBlocked = leaseBlocks(m, headers, req.occurredAt);
480
+ if (execBlocked)
481
+ return execBlocked;
482
+ return execMachine(req, runtime, m);
483
+ }
484
+ case 'lease': return leaseMachine(req, m, params, headers, method);
485
+ case 'metadata': {
486
+ if (method === 'GET') {
487
+ const config = (m.config ?? {});
488
+ return { status: 200, body: (config.metadata ?? {}) };
489
+ }
490
+ // PUT/PATCH full metadata replace (412 version semantics): real surface, not modeled yet.
491
+ return err('not found', 404);
492
+ }
493
+ default: return err('not found', 404); // incl. ps / memory / memory-reclaim: real, unmodeled todos
494
+ }
495
+ }
496
+ if (seg.length === 7 && action === 'metadata') {
497
+ const key = decodeURIComponent(seg[6]);
498
+ const config = { ...(m.config ?? {}) };
499
+ const metadata = { ...(config.metadata ?? {}) };
500
+ if (method === 'GET') {
501
+ // OpenAPI: GET metadata/{key} -> 200 metadataValueResponse | 404
502
+ if (!(key in metadata))
503
+ return err('metadata key not found', 404);
504
+ return { status: 200, body: { value: metadata[key] } };
505
+ }
506
+ if (method === 'POST') {
507
+ const metaBlocked = leaseBlocks(m, headers, req.occurredAt);
508
+ if (metaBlocked)
509
+ return metaBlocked;
510
+ const b = parseBody(req);
511
+ if (typeof b.value !== 'string')
512
+ return err('value is required', 400);
513
+ metadata[key] = b.value;
514
+ config.metadata = metadata;
515
+ await write('machine', String(m.id), { ...m, config, machine_updated_at: nowIso(req.occurredAt) }, 'machine.metadata_set', req.root, req.occurredAt);
516
+ return { status: 204, body: null };
517
+ }
518
+ if (method === 'DELETE') {
519
+ const metaBlocked = leaseBlocks(m, headers, req.occurredAt);
520
+ if (metaBlocked)
521
+ return metaBlocked;
522
+ delete metadata[key];
523
+ config.metadata = metadata;
524
+ await write('machine', String(m.id), { ...m, config, machine_updated_at: nowIso(req.occurredAt) }, 'machine.metadata_delete', req.root, req.occurredAt);
525
+ return { status: 204, body: null };
526
+ }
527
+ return err('not found', 404);
528
+ }
529
+ return err('not found', 404);
530
+ }
531
+ /** Live-verified 2026-09-03 against api.machines.dev: a config.guest.memory_mb that is not a
532
+ * 256 MiB increment (a 128 MiB request) is refused with exactly this 400 body. */
533
+ function guestConfigError(config) {
534
+ const guest = config.guest;
535
+ if (!guest || typeof guest !== 'object')
536
+ return undefined;
537
+ const memory = guest.memory_mb;
538
+ if (memory === undefined)
539
+ return undefined;
540
+ if (typeof memory !== 'number' || !Number.isInteger(memory) || memory <= 0 || memory % 256 !== 0) {
541
+ return err('invalid config.guest.memory_mb, must be in 256 MiB increment', 400);
542
+ }
543
+ return undefined;
544
+ }
545
+ /** A deleted volume is not gone at once: DELETE leaves it listed as pending_destroy (live-verified
546
+ * 2026-09-03: the row stayed in the list and a mount of it was refused) until the platform purges
547
+ * it. The purge delay is ⚠ doc-unverified; 60 s of WORLD time is the modeled window. */
548
+ const VOLUME_PURGE_MS = 60_000;
549
+ function volumeGone(v, occurredAt) {
550
+ if (v._purged === true || v.state === 'destroyed')
551
+ return true;
552
+ if (v.state !== 'pending_destroy')
553
+ return false;
554
+ const requested = Date.parse(String(v._destroy_requested_at ?? ''));
555
+ return Number.isFinite(requested) && Date.parse(nowIso(occurredAt)) - requested >= VOLUME_PURGE_MS;
556
+ }
557
+ /** Resolve a config's mounts against the app's volumes — the ONE mount rulebook, shared by
558
+ * create AND update (§9 round one, finding 2: the first draft enforced it only on create, so an
559
+ * update could steal an attached volume or mount a nonexistent one). `selfId` is the machine
560
+ * whose own attachments are legitimate on an update. */
561
+ function resolveMounts(config, appName, selfId, req) {
562
+ const mounts = Array.isArray(config.mounts) ? config.mounts : [];
563
+ const volumes = [];
564
+ for (const mt of mounts) {
565
+ // a mount names its volume by id; an adopted volume answers to its local id too (alias-aware), and the
566
+ // config is stored with the id the tree holds so the reference follows the adoption
567
+ if (typeof mt.volume === 'string' && mt.volume)
568
+ mt.volume = resolveSubjectId(SERVICE, 'volume', mt.volume, req.root);
569
+ const vol = getRow('volume', String(mt.volume ?? ''), req.root);
570
+ if (!vol || vol.app_name !== appName || volumeGone(vol, req.occurredAt)) {
571
+ return { error: err(`volume ${mt.volume} not found`, 404), volumes: [] };
572
+ }
573
+ // Live-verified 2026-09-03: mounting a volume that is pending_destroy -> exactly this 400.
574
+ if (vol.state === 'pending_destroy')
575
+ return { error: err('volume not found', 400), volumes: [] };
576
+ const attachedTo = typeof vol.attached_machine_id === 'string' ? vol.attached_machine_id : '';
577
+ if (attachedTo && attachedTo !== selfId) {
578
+ return { error: err(`volume ${vol.id} already attached to machine ${attachedTo}`, 422), volumes: [] };
579
+ }
580
+ volumes.push(vol);
581
+ }
582
+ return { volumes };
583
+ }
584
+ async function createMachine(req, runtime, appName) {
585
+ const b = parseBody(req);
586
+ const config = b.config;
587
+ if (!config || typeof config !== 'object')
588
+ return err('machine config is required', 400);
589
+ const image = typeof config.image === 'string' ? config.image.trim() : '';
590
+ if (!image)
591
+ return err('you must provide an image for the machine', 422);
592
+ const guestError = guestConfigError(config);
593
+ if (guestError)
594
+ return guestError;
595
+ // ONE seed per created machine, off the state this write lands on (R9 — see fly-machines.ts's
596
+ // `entropyHex`): the app, the world instant and the ordinal this machine row takes. Each id
597
+ // draws from its own sub-seed so the name, the machine id, the instance id and the lease nonce
598
+ // are independent of one another.
599
+ const seed = `machine:${appName}:${nowIso(req.occurredAt)}:${rows('machine', req.root).length}`;
600
+ const name = typeof b.name === 'string' && b.name ? b.name : newMachineName(`${seed}:name`);
601
+ const clash = liveAppRows('machine', appName, req.root).find((x) => x.name === name && x.state !== 'destroyed');
602
+ if (clash)
603
+ return err(`already_exists: unique machine name violation, machine name "${name}" already taken`, 422);
604
+ // Mounts must reference an existing, unattached volume of this app (attach semantics).
605
+ const resolved = resolveMounts(config, appName, undefined, req);
606
+ if (resolved.error)
607
+ return resolved.error;
608
+ const mountVolumes = resolved.volumes;
609
+ const id = newMachineId(`${seed}:id`);
610
+ const instanceId = newInstanceId(req.occurredAt, `${seed}:instance`);
611
+ const region = typeof b.region === 'string' && b.region ? b.region : 'iad';
612
+ const createdAt = nowIso(req.occurredAt);
613
+ const base = {
614
+ app_name: appName,
615
+ name,
616
+ region,
617
+ instance_id: instanceId,
618
+ private_ip: machinePrivateIp(id),
619
+ created_at: createdAt,
620
+ machine_updated_at: createdAt,
621
+ config,
622
+ image_ref: imageRefOf(image),
623
+ cordoned: false,
624
+ _config_history: [{ version: instanceId, user_config: config }],
625
+ _container_ref: null,
626
+ _twin_runtime: runtime.kind,
627
+ _twin_local_ports: [],
628
+ };
629
+ let events = [machineEvent('launch', 'created', 'user', req.occurredAt, 0)];
630
+ let state = 'created';
631
+ let leaseFields = {};
632
+ let nonce;
633
+ if (typeof b.lease_ttl === 'number' && b.lease_ttl > 0) {
634
+ nonce = newLeaseNonce(`${seed}:lease`);
635
+ leaseFields = {
636
+ lease_nonce: nonce,
637
+ lease_owner: 'twin@local',
638
+ lease_description: '',
639
+ lease_expires_at: Math.floor(nowEpochMs(req.occurredAt) / 1000) + Math.floor(b.lease_ttl),
640
+ };
641
+ }
642
+ if (b.skip_launch === true) {
643
+ const m = await write('machine', id, { ...base, ...leaseFields, state, events }, 'machine.create', req.root, req.occurredAt);
644
+ await attachVolumes(mountVolumes, id, req);
645
+ return { status: 200, body: machineView(m, nonce ? { nonce } : {}) };
646
+ }
647
+ // Attach BEFORE launching (the updateMachine order — §9 round two, finding 2): a launch that
648
+ // fails must leave attachments consistent with the persisted config, or the failed machine's
649
+ // mounts are claimable by a second machine while the first still holds them in config.
650
+ await attachVolumes(mountVolumes, id, req);
651
+ // The execution plane actually runs the image (real Docker when serving with the docker
652
+ // runtime; the virtual ledger otherwise).
653
+ let launched;
654
+ try {
655
+ launched = await runtime.run(containerSpecOf({ ...base, id, state, events }));
656
+ }
657
+ catch (e) {
658
+ // Launch failure: the machine is persisted in state `failed` (start event status failed) and
659
+ // the request fails with a 422 — a disclosed modeling of Fly's launch-failure surface.
660
+ events = [...events, machineEvent('start', 'failed', 'flyd', req.occurredAt, events.length)];
661
+ const failure = e instanceof Error ? e.message : String(e);
662
+ await write('machine', id, { ...base, ...leaseFields, state: 'failed', events, _twin_last_failure: failure }, 'machine.create', req.root, req.occurredAt);
663
+ return err(`failed to launch VM: ${failure}`, 422);
664
+ }
665
+ events = [...events, machineEvent('start', 'started', 'flyd', req.occurredAt, events.length)];
666
+ state = 'started';
667
+ const m = await write('machine', id, {
668
+ ...base, ...leaseFields, state, events,
669
+ _container_ref: launched.containerRef,
670
+ _twin_local_ports: launched.publishedPorts ?? [],
671
+ }, 'machine.create', req.root, req.occurredAt);
672
+ return { status: 200, body: machineView(m, nonce ? { nonce } : {}) };
673
+ }
674
+ async function attachVolumes(vols, machineId, req) {
675
+ for (const vol of vols) {
676
+ await write('volume', String(vol.id), { ...vol, attached_machine_id: machineId }, 'volume.attach', req.root, req.occurredAt);
677
+ }
678
+ }
679
+ async function detachVolumesOf(machineId, req) {
680
+ for (const vol of rows('volume', req.root).filter((v) => v.attached_machine_id === machineId)) {
681
+ await write('volume', String(vol.id), { ...vol, attached_machine_id: null }, 'volume.detach', req.root, req.occurredAt);
682
+ }
683
+ }
684
+ async function updateMachine(req, runtime, m, headers, params) {
685
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
686
+ if (blocked)
687
+ return blocked;
688
+ if (m.state === 'destroyed')
689
+ return err('machine not found', 404);
690
+ const b = parseBody(req);
691
+ const config = b.config;
692
+ if (!config || typeof config !== 'object')
693
+ return err('machine config is required', 400);
694
+ const image = typeof config.image === 'string' ? config.image.trim() : '';
695
+ if (!image)
696
+ return err('you must provide an image for the machine', 422);
697
+ const guestError = guestConfigError(config);
698
+ if (guestError)
699
+ return guestError;
700
+ // The SAME mount rulebook as create (§9 finding 2): unknown volume → 404, a volume attached to
701
+ // ANOTHER machine → 422; this machine's own attachments are legitimate. Attachments are then
702
+ // RECONCILED — volumes dropped from the config detach, added ones attach.
703
+ const appName = String(m.app_name);
704
+ const resolved = resolveMounts(config, appName, String(m.id), req);
705
+ if (resolved.error)
706
+ return resolved.error;
707
+ const nextVolumeIds = new Set(resolved.volumes.map((v) => String(v.id)));
708
+ for (const vol of rows('volume', req.root).filter((v) => v.attached_machine_id === String(m.id) && !nextVolumeIds.has(String(v.id)))) {
709
+ await write('volume', String(vol.id), { ...vol, attached_machine_id: null }, 'volume.detach', req.root, req.occurredAt);
710
+ }
711
+ await attachVolumes(resolved.volumes.filter((v) => !v.attached_machine_id), String(m.id), req);
712
+ const wasStarted = m.state === 'started';
713
+ // A new instance/version per update, seeded off the machine and how many versions it already
714
+ // has — so re-applying the same update to two identical worlds yields the same version (R9).
715
+ const historyLength = (m._config_history ?? []).length;
716
+ const instanceId = newInstanceId(req.occurredAt, `instance:${String(m.id)}:${nowIso(req.occurredAt)}:${historyLength}`);
717
+ const history = [...(m._config_history ?? []), { version: instanceId, user_config: config }];
718
+ let events = pushEvent(m, 'update', wasStarted ? 'started' : String(m.state), 'user', req.occurredAt);
719
+ let containerRef = m._container_ref;
720
+ let localPorts = m._twin_local_ports ?? [];
721
+ if (wasStarted) {
722
+ // An update REPLACES the running machine (new instance/version): reap the old container and
723
+ // launch the new config.
724
+ if (typeof containerRef === 'string' && containerRef) {
725
+ try {
726
+ await runtime.remove(containerRef);
727
+ }
728
+ catch { /* already gone */ }
729
+ }
730
+ const next = { ...m, config, instance_id: instanceId, image_ref: imageRefOf(image) };
731
+ try {
732
+ const launched = await runtime.run(containerSpecOf(next));
733
+ containerRef = launched.containerRef;
734
+ localPorts = launched.publishedPorts ?? [];
735
+ }
736
+ catch (e) {
737
+ events = [...events, machineEvent('start', 'failed', 'flyd', req.occurredAt, events.length)];
738
+ const failure = e instanceof Error ? e.message : String(e);
739
+ await write('machine', String(m.id), { ...m, config, instance_id: instanceId, image_ref: imageRefOf(image), _config_history: history, state: 'failed', events, _container_ref: null, _twin_local_ports: [], _flyd_restart_attempts: 0, _twin_last_failure: failure, machine_updated_at: nowIso(req.occurredAt) }, 'machine.update', req.root, req.occurredAt);
740
+ return err(`failed to launch VM: ${failure}`, 422);
741
+ }
742
+ }
743
+ const updated = await write('machine', String(m.id), {
744
+ ...m,
745
+ config,
746
+ instance_id: instanceId,
747
+ image_ref: imageRefOf(image),
748
+ _config_history: history,
749
+ events,
750
+ _container_ref: containerRef,
751
+ _twin_local_ports: localPorts,
752
+ _flyd_restart_attempts: 0,
753
+ _twin_last_failure: null,
754
+ machine_updated_at: nowIso(req.occurredAt),
755
+ }, 'machine.update', req.root, req.occurredAt);
756
+ return { status: 200, body: machineView(updated, { includeLeases: params?.get('include_leases') === 'true', ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) }) };
757
+ }
758
+ async function destroyMachineRow(m, req, runtime) {
759
+ if (typeof m._container_ref === 'string' && m._container_ref) {
760
+ try {
761
+ await runtime.remove(m._container_ref);
762
+ }
763
+ catch { /* container already gone */ }
764
+ }
765
+ const events = pushEvent(m, 'destroy', 'destroyed', 'user', req.occurredAt);
766
+ await write('machine', String(m.id), { ...m, state: 'destroyed', events, _container_ref: null, machine_updated_at: nowIso(req.occurredAt) }, 'machine.destroy', req.root, req.occurredAt);
767
+ await detachVolumesOf(String(m.id), req);
768
+ }
769
+ async function startMachine(req, runtime, m, headers) {
770
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
771
+ if (blocked)
772
+ return blocked;
773
+ const prev = String(m.state);
774
+ if (prev === 'destroyed' || prev === 'destroying')
775
+ return err('machine not found', 404);
776
+ if (prev === 'started')
777
+ return { status: 200, body: { previous_state: 'started' } };
778
+ if (prev !== 'created' && prev !== 'stopped' && prev !== 'suspended' && prev !== 'failed') {
779
+ return err(`machine cannot be started from state ${prev}`, 400);
780
+ }
781
+ let containerRef = m._container_ref;
782
+ let localPorts = m._twin_local_ports ?? [];
783
+ try {
784
+ if (prev === 'suspended' && runtime.unpause && typeof containerRef === 'string' && containerRef) {
785
+ await runtime.unpause(containerRef);
786
+ }
787
+ else if (prev === 'stopped' && runtime.start && typeof containerRef === 'string' && containerRef) {
788
+ // A stopped Machine that starts again is RESET to its original state on real Fly (only
789
+ // mounted volumes survive), so this is a relaunch, not a resume: re-consult the mount
790
+ // rulebook exactly like a fresh launch, and take the plane's NEW handle and ports.
791
+ const check = resolveMounts((m.config ?? {}), String(m.app_name), String(m.id), req);
792
+ if (check.error)
793
+ return check.error;
794
+ const restarted = await runtime.start(containerRef, containerSpecOf(m));
795
+ containerRef = restarted.containerRef;
796
+ localPorts = restarted.publishedPorts ?? [];
797
+ }
798
+ else {
799
+ // Fresh launch (created/failed, or a runtime without start): the machine's mounts may have
800
+ // been destroyed or claimed since the config was written — re-consult the rulebook, and
801
+ // reap any stale container (deterministic names) before running.
802
+ const check = resolveMounts((m.config ?? {}), String(m.app_name), String(m.id), req);
803
+ if (check.error)
804
+ return check.error;
805
+ if (typeof containerRef === 'string' && containerRef) {
806
+ try {
807
+ await runtime.remove(containerRef);
808
+ }
809
+ catch { /* already gone */ }
810
+ }
811
+ const launched = await runtime.run(containerSpecOf(m));
812
+ containerRef = launched.containerRef;
813
+ localPorts = launched.publishedPorts ?? [];
814
+ }
815
+ }
816
+ catch (e) {
817
+ const failure = e instanceof Error ? e.message : String(e);
818
+ const events = [...(m.events ?? []), machineEvent('start', 'failed', 'flyd', req.occurredAt, (m.events ?? []).length, { failure })];
819
+ await write('machine', String(m.id), { ...m, events, _twin_last_failure: failure, machine_updated_at: nowIso(req.occurredAt) }, 'machine.start_failure', req.root, req.occurredAt);
820
+ return err(`failed to start VM: ${failure}`, 422);
821
+ }
822
+ const events = pushEvent(m, 'start', 'started', 'user', req.occurredAt);
823
+ await write('machine', String(m.id), { ...m, state: 'started', events, _container_ref: containerRef, _twin_local_ports: localPorts, _flyd_restart_attempts: 0, _twin_last_failure: null, machine_updated_at: nowIso(req.occurredAt) }, 'machine.start', req.root, req.occurredAt);
824
+ return { status: 200, body: { previous_state: prev } };
825
+ }
826
+ async function stopMachine(req, runtime, m, headers) {
827
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
828
+ if (blocked)
829
+ return blocked;
830
+ const prev = String(m.state);
831
+ if (prev === 'destroyed' || prev === 'destroying')
832
+ return err('machine not found', 404);
833
+ if (prev === 'stopped' || prev === 'created')
834
+ return { status: 200, body: { ok: true } };
835
+ const b = parseBody(req);
836
+ if (typeof m._container_ref === 'string' && m._container_ref) {
837
+ const timeout = typeof b.timeout === 'string' ? parseInt(b.timeout, 10) : (typeof b.timeout === 'number' ? b.timeout : undefined);
838
+ try {
839
+ await runtime.stop(m._container_ref, {
840
+ ...(typeof b.signal === 'string' ? { signal: b.signal } : {}),
841
+ ...(timeout !== undefined && Number.isFinite(timeout) ? { timeoutSeconds: timeout } : {}),
842
+ });
843
+ }
844
+ catch { /* container already stopped */ }
845
+ }
846
+ const events = pushEvent(m, 'stop', 'stopped', 'user', req.occurredAt);
847
+ await write('machine', String(m.id), { ...m, state: 'stopped', events, machine_updated_at: nowIso(req.occurredAt) }, 'machine.stop', req.root, req.occurredAt);
848
+ return { status: 200, body: { ok: true } };
849
+ }
850
+ async function restartMachine(req, runtime, m, headers) {
851
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
852
+ if (blocked)
853
+ return blocked;
854
+ const prev = String(m.state);
855
+ if (prev === 'destroyed' || prev === 'destroying')
856
+ return err('machine not found', 404);
857
+ let containerRef = m._container_ref;
858
+ let localPorts = m._twin_local_ports ?? [];
859
+ try {
860
+ if (prev === 'started' && typeof containerRef === 'string' && containerRef) {
861
+ await runtime.stop(containerRef);
862
+ // A restart is a stop→start: same vendor rootfs reset, so the plane relaunches rather
863
+ // than resuming, and the machine picks up the new handle/ports.
864
+ if (runtime.start) {
865
+ const restarted = await runtime.start(containerRef, containerSpecOf(m));
866
+ containerRef = restarted.containerRef;
867
+ localPorts = restarted.publishedPorts ?? [];
868
+ }
869
+ else {
870
+ await runtime.remove(containerRef);
871
+ const launched = await runtime.run(containerSpecOf(m));
872
+ containerRef = launched.containerRef;
873
+ localPorts = launched.publishedPorts ?? [];
874
+ }
875
+ }
876
+ else {
877
+ // Relaunch of a non-started machine: re-consult the mount rulebook (its volumes may have
878
+ // been destroyed/claimed since) and reap any stale container first (deterministic names).
879
+ const check = resolveMounts((m.config ?? {}), String(m.app_name), String(m.id), req);
880
+ if (check.error)
881
+ return check.error;
882
+ if (typeof containerRef === 'string' && containerRef) {
883
+ try {
884
+ await runtime.remove(containerRef);
885
+ }
886
+ catch { /* already gone */ }
887
+ }
888
+ const launched = await runtime.run(containerSpecOf(m));
889
+ containerRef = launched.containerRef;
890
+ localPorts = launched.publishedPorts ?? [];
891
+ }
892
+ }
893
+ catch (e) {
894
+ const failure = e instanceof Error ? e.message : String(e);
895
+ const events = [...(m.events ?? []), machineEvent('restart', 'failed', 'flyd', req.occurredAt, (m.events ?? []).length, { failure })];
896
+ await write('machine', String(m.id), { ...m, events, _twin_last_failure: failure, machine_updated_at: nowIso(req.occurredAt) }, 'machine.restart_failure', req.root, req.occurredAt);
897
+ return err(`failed to restart VM: ${failure}`, 422);
898
+ }
899
+ let events = pushEvent(m, 'restart', 'stopping', 'user', req.occurredAt);
900
+ events = [...events, machineEvent('start', 'started', 'flyd', req.occurredAt, events.length)];
901
+ // _flyd_restart_attempts resets: an explicit operator restart restores the supervision budget.
902
+ await write('machine', String(m.id), { ...m, state: 'started', events, _container_ref: containerRef, _twin_local_ports: localPorts, _flyd_restart_attempts: 0, _twin_last_failure: null, machine_updated_at: nowIso(req.occurredAt) }, 'machine.restart', req.root, req.occurredAt);
903
+ return { status: 200, body: { ok: true } };
904
+ }
905
+ const FLY_SIGNALS = new Set(['SIGABRT', 'SIGALRM', 'SIGFPE', 'SIGHUP', 'SIGILL', 'SIGINT', 'SIGKILL', 'SIGPIPE', 'SIGQUIT', 'SIGSEGV', 'SIGTERM', 'SIGTRAP', 'SIGUSR1', 'SIGUSR2']);
906
+ async function signalMachine(req, runtime, m, headers) {
907
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
908
+ if (blocked)
909
+ return blocked;
910
+ if (m.state !== 'started')
911
+ return err('machine is not started', 400);
912
+ const b = parseBody(req);
913
+ // fly-go's Kill() sends {signal: 9} (a number); the OpenAPI SignalRequest is a string enum.
914
+ const signal = typeof b.signal === 'number'
915
+ ? { 1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 9: 'SIGKILL', 15: 'SIGTERM' }[b.signal]
916
+ : (typeof b.signal === 'string' ? b.signal.toUpperCase() : undefined);
917
+ if (!signal || !FLY_SIGNALS.has(signal))
918
+ return err('invalid signal', 400);
919
+ if (runtime.signal && typeof m._container_ref === 'string' && m._container_ref) {
920
+ try {
921
+ await runtime.signal(m._container_ref, signal);
922
+ }
923
+ catch { /* container gone; fold on next read */ }
924
+ }
925
+ return { status: 200, body: { ok: true } };
926
+ }
927
+ async function suspendMachine(req, runtime, m, headers) {
928
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
929
+ if (blocked)
930
+ return blocked;
931
+ if (m.state !== 'started')
932
+ return err('machine must be started to suspend', 400);
933
+ if (typeof m._container_ref === 'string' && m._container_ref) {
934
+ try {
935
+ if (runtime.pause)
936
+ await runtime.pause(m._container_ref);
937
+ else
938
+ await runtime.stop(m._container_ref); // documented fallback: suspend falls back to stop
939
+ }
940
+ catch { /* container gone; fold on next read */ }
941
+ }
942
+ const events = pushEvent(m, 'suspend', 'suspended', 'user', req.occurredAt);
943
+ await write('machine', String(m.id), { ...m, state: 'suspended', events, machine_updated_at: nowIso(req.occurredAt) }, 'machine.suspend', req.root, req.occurredAt);
944
+ return { status: 200, body: { ok: true } };
945
+ }
946
+ async function cordonMachine(req, m, headers, cordoned) {
947
+ const blocked = leaseBlocks(m, headers, req.occurredAt);
948
+ if (blocked)
949
+ return blocked;
950
+ if (m.state === 'destroyed')
951
+ return err('machine not found', 404);
952
+ await write('machine', String(m.id), { ...m, cordoned, machine_updated_at: nowIso(req.occurredAt) }, cordoned ? 'machine.cordon' : 'machine.uncordon', req.root, req.occurredAt);
953
+ return { status: 200, body: { ok: true } };
954
+ }
955
+ async function waitMachine(req, m, params) {
956
+ const desired = params.get('state') ?? 'started';
957
+ // Only the states a client can genuinely wait FOR (the spec's filterable set + destroyed);
958
+ // transient/internal states (starting, replacing, failed, …) are a 400, not an eternal 408.
959
+ const WAITABLE = ['created', 'started', 'stopped', 'suspended', 'destroyed'];
960
+ if (!WAITABLE.includes(desired)) {
961
+ return err(`invalid desired state ${desired}`, 400);
962
+ }
963
+ if (String(m.state) === desired) {
964
+ return { status: 200, body: { ok: true } };
965
+ }
966
+ // Local transitions complete synchronously, so a mismatched state cannot change while a wait
967
+ // is pending — answer Fly's own timeout immediately (docs response table: 408; the message is
968
+ // flaps' real one).
969
+ return err("timeout reached waiting for machine's state to change", 408);
970
+ }
971
+ async function execMachine(req, runtime, m) {
972
+ if (m.state !== 'started')
973
+ return err('machine is not started', 400);
974
+ const b = parseBody(req);
975
+ const command = Array.isArray(b.command) && b.command.length > 0
976
+ ? b.command.map(String)
977
+ : (typeof b.cmd === 'string' && b.cmd.trim() ? b.cmd.trim().split(/\s+/) : []);
978
+ if (command.length === 0)
979
+ return err('command is required', 400);
980
+ if (!runtime.exec || typeof m._container_ref !== 'string' || !m._container_ref) {
981
+ // The virtual plane cannot execute anything, and inventing output would be a fabricated
982
+ // success — fail loudly inside the vendor envelope (serve with --local-execution for real exec).
983
+ return err('exec unavailable: the twin execution plane is virtual (serve with a docker runtime for real exec)', 400);
984
+ }
985
+ try {
986
+ const out = await runtime.exec(m._container_ref, command, typeof b.timeout === 'number' ? { timeoutSeconds: b.timeout } : {});
987
+ return { status: 200, body: { exit_code: out.exitCode, stdout: out.stdout, stderr: out.stderr } };
988
+ }
989
+ catch (e) {
990
+ return err(`exec failed: ${e instanceof Error ? e.message : String(e)}`, 400);
991
+ }
992
+ }
993
+ // Lease wire shape: fly-go MachineLease — {status, data:{nonce,expires_at,owner,description?,version}}.
994
+ function leaseData(m) {
995
+ return {
996
+ nonce: String(m.lease_nonce),
997
+ expires_at: Number(m.lease_expires_at),
998
+ owner: String(m.lease_owner ?? 'twin@local'),
999
+ description: String(m.lease_description ?? ''),
1000
+ version: String(m.instance_id),
1001
+ };
1002
+ }
1003
+ async function leaseMachine(req, m, params, headers, method) {
1004
+ if (method === 'GET') {
1005
+ if (!leaseIsLive(m, req.occurredAt))
1006
+ return err('lease not found', 404);
1007
+ return { status: 200, body: { status: 'success', data: leaseData(m) } };
1008
+ }
1009
+ if (method === 'POST') {
1010
+ const b = parseBody(req);
1011
+ // fly-go AcquireLease sends ?ttl=; the OpenAPI CreateLeaseRequest carries {ttl, description}.
1012
+ const ttlRaw = params.get('ttl') ?? (typeof b.ttl === 'number' ? String(b.ttl) : undefined);
1013
+ const ttl = ttlRaw !== undefined ? parseInt(String(ttlRaw), 10) : 60;
1014
+ if (!Number.isFinite(ttl) || ttl <= 0)
1015
+ return err('invalid lease ttl', 400);
1016
+ const nonce = headers['fly-machine-lease-nonce'];
1017
+ if (leaseIsLive(m, req.occurredAt) && nonce !== m.lease_nonce) {
1018
+ return err(`machine ID ${m.id} lease currently held by ${m.lease_owner ?? 'twin@local'}`, 409);
1019
+ }
1020
+ const isRefresh = leaseIsLive(m, req.occurredAt) && nonce === m.lease_nonce;
1021
+ const updated = await write('machine', String(m.id), {
1022
+ ...m,
1023
+ lease_nonce: isRefresh ? m.lease_nonce : newLeaseNonce(`lease:${String(m.id)}:${nowIso(req.occurredAt)}:${String(m._rev ?? 0)}`),
1024
+ lease_owner: 'twin@local',
1025
+ lease_description: typeof b.description === 'string' ? b.description : String(m.lease_description ?? ''),
1026
+ lease_expires_at: Math.floor(nowEpochMs(req.occurredAt) / 1000) + ttl,
1027
+ }, 'machine.lease_acquire', req.root, req.occurredAt);
1028
+ return { status: 201, body: { status: 'success', data: leaseData(updated) } };
1029
+ }
1030
+ if (method === 'DELETE') {
1031
+ // ⚠ doc-unverified status: with no live lease there is nothing to release — served as the
1032
+ // same 404 the GET gives, never a fabricated success.
1033
+ if (!leaseIsLive(m, req.occurredAt))
1034
+ return err('lease not found', 404);
1035
+ const nonce = headers['fly-machine-lease-nonce'];
1036
+ if (nonce !== m.lease_nonce)
1037
+ return err('invalid lease nonce', 400);
1038
+ await write('machine', String(m.id), { ...m, lease_nonce: null, lease_expires_at: null, lease_description: null }, 'machine.lease_release', req.root, req.occurredAt);
1039
+ return { status: 200, body: { status: 'success' } };
1040
+ }
1041
+ return err('not found', 404);
1042
+ }
1043
+ // ── volumes ───────────────────────────────────────────────────────────────────────────────────
1044
+ async function handleVolumes(req, runtime, appName, seg, method) {
1045
+ if (seg.length === 4) {
1046
+ if (method === 'GET') {
1047
+ const vols = liveAppRows('volume', appName, req.root).filter((v) => !volumeGone(v, req.occurredAt));
1048
+ return { status: 200, body: vols.map(volumeView) }; // BARE array (flaps_volumes.go)
1049
+ }
1050
+ if (method === 'POST') {
1051
+ const b = parseBody(req);
1052
+ const name = typeof b.name === 'string' ? b.name.trim() : '';
1053
+ if (!name)
1054
+ return err('volume name is required', 422);
1055
+ if (!/^[a-z0-9_]+$/.test(name))
1056
+ return err(`Validation failed: Name '${name}' must contain only lowercase letters, numbers, and underscores`, 422);
1057
+ const sizeGb = typeof b.size_gb === 'number' && b.size_gb > 0 ? Math.floor(b.size_gb) : 1;
1058
+ const id = newVolumeId(`volume:${appName}:${name}:${nowIso(req.occurredAt)}:${rows('volume', req.root).length}`);
1059
+ const zone = createHash('sha256').update(id).digest('hex').slice(0, 4);
1060
+ if (runtime.createVolume) {
1061
+ try {
1062
+ await runtime.createVolume(id);
1063
+ }
1064
+ catch { /* volume materializes lazily at machine run */ }
1065
+ }
1066
+ const v = await write('volume', id, {
1067
+ app_name: appName,
1068
+ name,
1069
+ region: typeof b.region === 'string' && b.region ? b.region : 'iad',
1070
+ size_gb: sizeGb,
1071
+ encrypted: b.encrypted !== false,
1072
+ fstype: typeof b.fstype === 'string' && b.fstype ? b.fstype : 'ext4',
1073
+ snapshot_retention: typeof b.snapshot_retention === 'number' ? b.snapshot_retention : 5,
1074
+ auto_backup_enabled: b.auto_backup_enabled !== false,
1075
+ zone,
1076
+ state: 'created',
1077
+ attached_machine_id: null,
1078
+ created_at: nowIso(req.occurredAt),
1079
+ snapshots: [],
1080
+ }, 'volume.create', req.root, req.occurredAt);
1081
+ return { status: 200, body: volumeView(v) };
1082
+ }
1083
+ return err('not found', 404);
1084
+ }
1085
+ const volumeId = seg[4];
1086
+ const v = getRow('volume', volumeId, req.root);
1087
+ if (!v || v.app_name !== appName || volumeGone(v, req.occurredAt))
1088
+ return err('volume not found', 404);
1089
+ if (seg.length === 5) {
1090
+ if (method === 'GET')
1091
+ return { status: 200, body: volumeView(v) };
1092
+ if (method === 'PUT') {
1093
+ const b = parseBody(req);
1094
+ const updated = await write('volume', volumeId, {
1095
+ ...v,
1096
+ ...(typeof b.snapshot_retention === 'number' ? { snapshot_retention: b.snapshot_retention } : {}),
1097
+ ...(typeof b.auto_backup_enabled === 'boolean' ? { auto_backup_enabled: b.auto_backup_enabled } : {}),
1098
+ }, 'volume.update', req.root, req.occurredAt);
1099
+ return { status: 200, body: volumeView(updated) };
1100
+ }
1101
+ if (method === 'DELETE') {
1102
+ if (typeof v.attached_machine_id === 'string' && v.attached_machine_id) {
1103
+ return err(`volume ${v.id} is attached to machine ${v.attached_machine_id}`, 400);
1104
+ }
1105
+ if (runtime.removeVolume) {
1106
+ try {
1107
+ await runtime.removeVolume(volumeId);
1108
+ }
1109
+ catch { /* never materialized */ }
1110
+ }
1111
+ const pending = await write('volume', volumeId, { ...v, state: 'pending_destroy', _destroy_requested_at: nowIso(req.occurredAt) }, 'volume.delete', req.root, req.occurredAt);
1112
+ return { status: 200, body: volumeView(pending) }; // returns the Volume (flaps DeleteVolume)
1113
+ }
1114
+ return err('not found', 404);
1115
+ }
1116
+ if (seg.length === 6 && seg[5] === 'extend' && method === 'PUT') {
1117
+ const b = parseBody(req);
1118
+ const sizeGb = typeof b.size_gb === 'number' ? Math.floor(b.size_gb) : NaN;
1119
+ if (!Number.isFinite(sizeGb) || sizeGb <= Number(v.size_gb ?? 1)) {
1120
+ return err('volumes can only be extended: size_gb must be larger than the current size', 400);
1121
+ }
1122
+ const updated = await write('volume', volumeId, { ...v, size_gb: sizeGb }, 'volume.extend', req.root, req.occurredAt);
1123
+ const attached = typeof v.attached_machine_id === 'string' && v.attached_machine_id.length > 0;
1124
+ return { status: 200, body: { needs_restart: attached, volume: volumeView(updated) } };
1125
+ }
1126
+ if (seg.length === 6 && seg[5] === 'snapshots') {
1127
+ if (method === 'GET') {
1128
+ return { status: 200, body: (v.snapshots ?? []) }; // BARE array
1129
+ }
1130
+ if (method === 'POST') {
1131
+ const snapshots = [...(v.snapshots ?? [])];
1132
+ snapshots.push({
1133
+ id: `vs_${createHash('sha256').update(`${volumeId}:${snapshots.length}`).digest('hex').slice(0, 20)}`,
1134
+ created_at: nowIso(req.occurredAt),
1135
+ digest: '',
1136
+ retention_days: Number(v.snapshot_retention ?? 5),
1137
+ size: 0,
1138
+ status: 'created',
1139
+ volume_size: Number(v.size_gb ?? 1) * 1024 * 1024 * 1024,
1140
+ });
1141
+ await write('volume', volumeId, { ...v, snapshots }, 'volume.snapshot', req.root, req.occurredAt);
1142
+ return { status: 200, body: {} };
1143
+ }
1144
+ }
1145
+ return err('not found', 404);
1146
+ }
1147
+ // ── app secrets ───────────────────────────────────────────────────────────────────────────────
1148
+ function secretKid(appName, name) {
1149
+ return `${appName}/${name}`;
1150
+ }
1151
+ /** The app-wide secrets version is a MONOTONIC counter kept on the app row and bumped by every
1152
+ * set/bulk/delete (§9 round one, finding 7: the first draft derived it from max(per-secret
1153
+ * version), which went BACKWARDS after deletes — unbuildable ground for ?min_version= and
1154
+ * CreateMachineRequest.min_secrets_version). */
1155
+ async function bumpSecretsVersion(req, appName) {
1156
+ const app = getRow('app', appName, req.root);
1157
+ const version = (typeof app?._secrets_version === 'number' ? app._secrets_version : 0) + 1;
1158
+ if (app)
1159
+ await write('app', appName, { ...app, _secrets_version: version }, 'app._secrets_version', req.root, req.occurredAt);
1160
+ return version;
1161
+ }
1162
+ async function handleSecrets(req, appName, seg, params, method) {
1163
+ const showSecrets = params.get('show_secrets') === 'true';
1164
+ const appSecrets = () => rows('secret', req.root).filter((s) => s.app_name === appName && s.deleted !== true);
1165
+ if (seg.length === 4) {
1166
+ if (method === 'GET') {
1167
+ return { status: 200, body: { secrets: appSecrets().map((s) => secretView(s, showSecrets)) } };
1168
+ }
1169
+ if (method === 'POST') {
1170
+ // Secrets_update: bulk {values} upsert -> {secrets, version} (ONE version bump per update
1171
+ // operation, like the vendor's release-versioned secrets).
1172
+ const b = parseBody(req);
1173
+ const values = b.values;
1174
+ if (!values || typeof values !== 'object' || Array.isArray(values))
1175
+ return err('values object is required', 400);
1176
+ for (const [name, value] of Object.entries(values)) {
1177
+ await writeSecret(req, appName, name, String(value));
1178
+ }
1179
+ const version = await bumpSecretsVersion(req, appName);
1180
+ return { status: 200, body: { secrets: appSecrets().map((s) => secretView(s, false)), version } };
1181
+ }
1182
+ return err('not found', 404);
1183
+ }
1184
+ const name = decodeURIComponent(seg[4]);
1185
+ const existing = getRow('secret', secretKid(appName, name), req.root);
1186
+ const live = existing && existing.deleted !== true ? existing : undefined;
1187
+ if (seg.length === 5) {
1188
+ if (method === 'GET') {
1189
+ if (!live)
1190
+ return err('secret not found', 404);
1191
+ return { status: 200, body: secretView(live, showSecrets) };
1192
+ }
1193
+ if (method === 'POST') {
1194
+ const b = parseBody(req);
1195
+ if (typeof b.value !== 'string' || b.value.length === 0)
1196
+ return err('value is required', 400);
1197
+ const s = await writeSecret(req, appName, name, b.value);
1198
+ const version = await bumpSecretsVersion(req, appName);
1199
+ return { status: 201, body: { ...secretView(s, false), version } };
1200
+ }
1201
+ if (method === 'DELETE') {
1202
+ // ⚠ doc-unverified status: the spec documents only 200 for Secret_delete; an unknown name
1203
+ // is served the twin's 404 rather than a fabricated success.
1204
+ if (!live)
1205
+ return err('secret not found', 404);
1206
+ await write('secret', secretKid(appName, name), { ...live, deleted: true }, 'secret.delete', req.root, req.occurredAt);
1207
+ const version = await bumpSecretsVersion(req, appName);
1208
+ return { status: 200, body: { version } };
1209
+ }
1210
+ return err('not found', 404);
1211
+ }
1212
+ return err('not found', 404); // encrypt/decrypt/sign/verify (secretkeys) — real, unmodeled todos
1213
+ }
1214
+ async function writeSecret(req, appName, name, value) {
1215
+ const existing = getRow('secret', secretKid(appName, name), req.root);
1216
+ return write('secret', secretKid(appName, name), {
1217
+ app_name: appName,
1218
+ secret_name: name,
1219
+ value,
1220
+ digest: createHash('sha256').update(value).digest('hex').slice(0, 32),
1221
+ created_at: existing && existing.deleted !== true ? existing.created_at : nowIso(req.occurredAt),
1222
+ secret_updated_at: nowIso(req.occurredAt),
1223
+ }, 'secret.set', req.root, req.occurredAt);
1224
+ }
1225
+ // ── conformance snapshot (dev tooling reads this; fly-conformance.ts probes every entry) ──────
1226
+ export function flyTwinSnapshot() {
1227
+ return {
1228
+ implementedEndpoints: [
1229
+ 'GET /v1/apps',
1230
+ 'POST /v1/apps',
1231
+ 'GET /v1/apps/{app_name}',
1232
+ 'DELETE /v1/apps/{app_name}',
1233
+ 'GET /v1/apps/{app_name}/machines',
1234
+ 'POST /v1/apps/{app_name}/machines',
1235
+ 'GET /v1/apps/{app_name}/machines/{machine_id}',
1236
+ 'POST /v1/apps/{app_name}/machines/{machine_id}',
1237
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}',
1238
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/start',
1239
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/stop',
1240
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/restart',
1241
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/signal',
1242
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/suspend',
1243
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/cordon',
1244
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/uncordon',
1245
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/wait',
1246
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/events',
1247
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/versions',
1248
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/exec',
1249
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/lease',
1250
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/lease',
1251
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}/lease',
1252
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/metadata',
1253
+ 'GET /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}',
1254
+ 'POST /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}',
1255
+ 'DELETE /v1/apps/{app_name}/machines/{machine_id}/metadata/{key}',
1256
+ 'GET /v1/apps/{app_name}/volumes',
1257
+ 'POST /v1/apps/{app_name}/volumes',
1258
+ 'GET /v1/apps/{app_name}/volumes/{volume_id}',
1259
+ 'PUT /v1/apps/{app_name}/volumes/{volume_id}',
1260
+ 'DELETE /v1/apps/{app_name}/volumes/{volume_id}',
1261
+ 'PUT /v1/apps/{app_name}/volumes/{volume_id}/extend',
1262
+ 'GET /v1/apps/{app_name}/volumes/{volume_id}/snapshots',
1263
+ 'POST /v1/apps/{app_name}/volumes/{volume_id}/snapshots',
1264
+ 'GET /v1/apps/{app_name}/secrets',
1265
+ 'POST /v1/apps/{app_name}/secrets',
1266
+ 'GET /v1/apps/{app_name}/secrets/{secret_name}',
1267
+ 'POST /v1/apps/{app_name}/secrets/{secret_name}',
1268
+ 'DELETE /v1/apps/{app_name}/secrets/{secret_name}',
1269
+ ],
1270
+ resourceTypes: [...FLY_RESOURCE_TYPES],
1271
+ };
1272
+ }