@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.
- package/LICENSE +202 -0
- package/README.md +125 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +78 -0
- package/dist/src/fly-budget.d.ts +41 -0
- package/dist/src/fly-budget.js +94 -0
- package/dist/src/fly-capabilities.d.ts +4 -0
- package/dist/src/fly-capabilities.js +1212 -0
- package/dist/src/fly-conformance.d.ts +8 -0
- package/dist/src/fly-conformance.js +142 -0
- package/dist/src/fly-connector.d.ts +75 -0
- package/dist/src/fly-connector.js +277 -0
- package/dist/src/fly-docker.d.ts +121 -0
- package/dist/src/fly-docker.js +509 -0
- package/dist/src/fly-machines.d.ts +152 -0
- package/dist/src/fly-machines.js +293 -0
- package/dist/src/fly-runtime-choice.d.ts +12 -0
- package/dist/src/fly-runtime-choice.js +21 -0
- package/dist/src/fly-server.d.ts +31 -0
- package/dist/src/fly-server.js +116 -0
- package/dist/src/fly-twin.d.ts +25 -0
- package/dist/src/fly-twin.js +1272 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +117 -0
- package/package.json +51 -0
- package/src/cli.ts +77 -0
- package/src/fly-budget.ts +121 -0
- package/src/fly-capabilities.ts +1261 -0
- package/src/fly-conformance.ts +168 -0
- package/src/fly-connector.ts +278 -0
- package/src/fly-docker.ts +539 -0
- package/src/fly-machines.ts +383 -0
- package/src/fly-runtime-choice.ts +38 -0
- package/src/fly-server.ts +116 -0
- package/src/fly-twin.ts +1218 -0
- package/src/index.ts +171 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// fly MACHINE LIFECYCLE + the CONTAINER-RUNTIME SEAM — the novel core of this pack.
|
|
2
|
+
//
|
|
3
|
+
// Fly.io's defining product idea is "a REST call gives you a running VM in seconds". This twin
|
|
4
|
+
// splits that into two planes, per the repo's real-plane doctrine (supabase: twin the control
|
|
5
|
+
// plane, run the real engine):
|
|
6
|
+
//
|
|
7
|
+
// CONTROL PLANE (this file + fly-twin.ts): apps/machines/volumes/secrets as kernel-backed
|
|
8
|
+
// rows; the machine lifecycle state machine (created → starting → started → stopping → stopped
|
|
9
|
+
// → destroying → destroyed, plus suspended) with a ledgered MachineEvent stream — all fully
|
|
10
|
+
// verifiable OFFLINE and deterministically.
|
|
11
|
+
//
|
|
12
|
+
// EXECUTION PLANE (the `FlyContainerRuntime` seam): when a machine is created/started the twin
|
|
13
|
+
// ACTUALLY runs `config.image` as a real local Docker container (fly-docker.ts implements the
|
|
14
|
+
// seam over the `docker` CLI): config.env → container env (plus Fly's documented FLY_* runtime
|
|
15
|
+
// environment), services/internal_port → published loopback ports, config.mounts → named
|
|
16
|
+
// docker volumes, stop → docker stop, start → RECREATE the container from the image (Fly
|
|
17
|
+
// resets a stopped Machine's rootfs), destroy → docker rm. The seam is INJECTED: verifies and
|
|
18
|
+
// unit tests inject a fake (or use the VIRTUAL runtime below), so the whole gate stays green on
|
|
19
|
+
// a machine with no Docker daemon; the real-Docker proof lives in
|
|
20
|
+
// fly-docker.integration.test.ts, which self-skips LOUDLY when `docker info` fails and is
|
|
21
|
+
// never counted as a capability proof.
|
|
22
|
+
//
|
|
23
|
+
// STATE-TRANSITION HONESTY (disclosed, README ## Coverage): a local `docker run`/`stop` completes
|
|
24
|
+
// synchronously, so the transient `starting`/`stopping`/`destroying` states are modeled but never
|
|
25
|
+
// observable — each API call lands on its terminal state (started/stopped/destroyed) before it
|
|
26
|
+
// returns, with the transition recorded in the machine's `events` array. The `wait` endpoint is
|
|
27
|
+
// therefore an immediate state check: already-there → 200 {ok:true}, otherwise Fly's own 408
|
|
28
|
+
// timeout — nothing in this twin changes state between two requests except the runtime-exit fold
|
|
29
|
+
// below.
|
|
30
|
+
//
|
|
31
|
+
// RUNTIME-EXIT FOLD: on every machine read the handler asks the runtime (when it can,
|
|
32
|
+
// `inspect()`) whether the container is still running; a container that exited folds the machine
|
|
33
|
+
// to `stopped` with an `exit` event — the genuine execution plane reflecting back into the
|
|
34
|
+
// twinned control plane. Proven offline with a fake runtime whose inspect() reports not-running.
|
|
35
|
+
//
|
|
36
|
+
// GROUNDING: the Machines API surface, request/response schemas, states and event shape are from
|
|
37
|
+
// Fly's first-party OpenAPI (live-fetched from docs.machines.dev/swagger/doc.json, OpenAPI 3.0.1
|
|
38
|
+
// "Machines API 1.0") cross-checked against superfly/fly-go (flyctl's own client — machine_types.go,
|
|
39
|
+
// flaps/flaps_machines.go). Where the two disagree the Go client wins (it is what flyctl actually
|
|
40
|
+
// speaks): e.g. lease responses are WRAPPED `{status:"success", data:{...}}` (fly-go MachineLease),
|
|
41
|
+
// not the OpenAPI's bare Lease schema. See spec-sources.json for the full citation + every
|
|
42
|
+
// ⚠ doc-unverified modeling choice.
|
|
43
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
export const SERVICE = 'fly';
|
|
46
|
+
/**
|
|
47
|
+
* The offline default: a pure-ledger execution plane. Nothing runs, nothing listens; every
|
|
48
|
+
* lifecycle call succeeds and `inspect` reports the container as still running (so control-plane
|
|
49
|
+
* state is authoritative). This is what makes the ENTIRE control plane — states, events, wait,
|
|
50
|
+
* leases — verifiable with no Docker daemon anywhere near the gate.
|
|
51
|
+
*/
|
|
52
|
+
export const VIRTUAL_FLY_RUNTIME = {
|
|
53
|
+
kind: 'virtual',
|
|
54
|
+
async run(spec) {
|
|
55
|
+
return { containerRef: `virtual:${spec.machineId}` };
|
|
56
|
+
},
|
|
57
|
+
async stop() { },
|
|
58
|
+
// Nothing runs, so nothing has a filesystem to reset — the virtual plane models no state that
|
|
59
|
+
// could survive a stop→start, and hands back the same stable ledger handle.
|
|
60
|
+
async start(_containerRef, spec) {
|
|
61
|
+
return { containerRef: `virtual:${spec.machineId}` };
|
|
62
|
+
},
|
|
63
|
+
async remove() { },
|
|
64
|
+
async pause() { },
|
|
65
|
+
async unpause() { },
|
|
66
|
+
async signal() { },
|
|
67
|
+
async inspect() {
|
|
68
|
+
return { running: true };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
// ── Fly's documented machine runtime environment (fly.io/docs/machines/runtime-environment):
|
|
72
|
+
// the env vars every real Fly machine boots with. The execution plane injects these UNDER the
|
|
73
|
+
// user's config.env (user env wins on collision, matching a user's ability to override). ────────
|
|
74
|
+
export function flyRuntimeEnv(m) {
|
|
75
|
+
return {
|
|
76
|
+
FLY_APP_NAME: m.app_name,
|
|
77
|
+
FLY_MACHINE_ID: m.id,
|
|
78
|
+
FLY_ALLOC_ID: m.id,
|
|
79
|
+
FLY_REGION: m.region,
|
|
80
|
+
FLY_IMAGE_REF: m.image,
|
|
81
|
+
FLY_MACHINE_VERSION: m.instance_id,
|
|
82
|
+
FLY_PRIVATE_IP: m.private_ip,
|
|
83
|
+
FLY_PROCESS_GROUP: 'app',
|
|
84
|
+
FLY_VM_MEMORY_MB: String(m.memory_mb),
|
|
85
|
+
PRIMARY_REGION: m.region,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ── machine states (fly-go machine_types.go MachineState* constants; the transient trio +
|
|
89
|
+
// `suspending`/`replacing`/`failed` appear in Fly's states documentation) ─────────────────────
|
|
90
|
+
export const FLY_MACHINE_STATES = [
|
|
91
|
+
'created', 'starting', 'started', 'stopping', 'stopped',
|
|
92
|
+
'suspending', 'suspended', 'destroying', 'destroyed', 'replacing', 'failed',
|
|
93
|
+
];
|
|
94
|
+
// ── kernel-backed rows (kernel-backed projection rows) ─────────────────────────────
|
|
95
|
+
// Kernel META reserves `type`/`id`/`updatedAt` inside `fields` — every field below is snake_case
|
|
96
|
+
// (created_at, instance_id, image_ref, …) so nothing collides; the bare vendor id is re-attached
|
|
97
|
+
// AFTER projection, never written inside `fields`.
|
|
98
|
+
export function nowIso(occurredAt) {
|
|
99
|
+
return occurredAt ?? new Date().toISOString();
|
|
100
|
+
}
|
|
101
|
+
export function nowEpochMs(occurredAt) {
|
|
102
|
+
return Date.parse(nowIso(occurredAt));
|
|
103
|
+
}
|
|
104
|
+
/** The live rows of a type (protocol 2: a subject's id is the vendor's — an app's name, a machine's id, a volume's id —
|
|
105
|
+
* with no type prefix; a delete is the kernel's tombstone `deleted: true`, cleared by the subject's next write). */
|
|
106
|
+
export function rows(type, root) {
|
|
107
|
+
return projectResources(SERVICE, root)
|
|
108
|
+
.filter((r) => r.type === type && r.deleted !== true)
|
|
109
|
+
.map((r) => ({ ...r }));
|
|
110
|
+
}
|
|
111
|
+
/** Every row of a type the tree has ever held, tombstoned ones included — the ordinal an id seed takes. */
|
|
112
|
+
export function countRows(type, root) {
|
|
113
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type).length;
|
|
114
|
+
}
|
|
115
|
+
export function getRow(type, id, root) {
|
|
116
|
+
return rows(type, root).find((r) => r.id === id);
|
|
117
|
+
}
|
|
118
|
+
function view(r) {
|
|
119
|
+
// the row as the tree holds it, its own bookkeeping (`_`-prefixed) included — the wire views pick their fields
|
|
120
|
+
const { type: _t, updatedAt: _u, ...rest } = r;
|
|
121
|
+
return rest;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The one kernel write choke point. Every write folds a per-subject `_rev` ordinal into `fields`
|
|
125
|
+
* (read-modify-write off the current projection): the kernel dedupes actions by content +
|
|
126
|
+
* millisecond timestamp, so without `rev` a machine returning to a PREVIOUS value under a pinned
|
|
127
|
+
* clock (start→stop→start in one verify) would silently land as `replayed` — reply and stored
|
|
128
|
+
* state disagreeing (the upstash lesson, ADDING_A_TWIN §5).
|
|
129
|
+
*/
|
|
130
|
+
export async function write(type, id, fields, op, root, occurredAt) {
|
|
131
|
+
const current = getRow(type, id, root);
|
|
132
|
+
const rev = typeof current?._rev === 'number' ? current._rev + 1 : 1;
|
|
133
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: id, fields: { ...fields, _rev: rev }, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
|
|
134
|
+
return view({ ...resource, id });
|
|
135
|
+
}
|
|
136
|
+
// ── id minting — entropy-based, NEVER a row count (the count-mint collision class three §9
|
|
137
|
+
// reviews found independently; ADDING_A_TWIN §5). Shapes are vendor-faithful:
|
|
138
|
+
// machine id: 14 lowercase hex chars (e.g. `d8d1e2ea044148` — flyctl's own shape)
|
|
139
|
+
// instance id: 26-char Crockford-base32 ULID-ish (`01JGXW…` — "unique for each version")
|
|
140
|
+
// volume id: `vol_` + 20 lowercase base32 (fly-go VolumeIDPrefix-compatible)
|
|
141
|
+
// lease nonce: 24 hex chars
|
|
142
|
+
// ────────────────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
/**
|
|
144
|
+
* The pack's id byte source — SEEDED, never random (R9).
|
|
145
|
+
*
|
|
146
|
+
* Real Fly mints machine ids, instance ids, volume ids and lease nonces from entropy, and this
|
|
147
|
+
* file used to do the same (`randomUUID()`), which made every one of them differ between two
|
|
148
|
+
* identical worlds the moment a caller listed machines or volumes. They are now a digest of the
|
|
149
|
+
* SEED the caller derives from the state the write is about to land on: the world instant plus
|
|
150
|
+
* the ordinal the new row takes among the rows of its type.
|
|
151
|
+
*
|
|
152
|
+
* That ordinal is the reason this does not resurrect the §9 round-one finding it replaced (a
|
|
153
|
+
* NAME hash, which made a deleted-and-recreated app inherit its predecessor's internal id).
|
|
154
|
+
* A destroyed machine or volume stays a row (`_purged`, state `destroyed`) and a deleted app is a tombstone the tree
|
|
155
|
+
* keeps, so `countRows(type)` only ever grows: recreating a destroyed name mints at a higher ordinal, hence a fresh id.
|
|
156
|
+
*/
|
|
157
|
+
function entropyHex(len, seed) {
|
|
158
|
+
let out = '';
|
|
159
|
+
for (let round = 0; out.length < len; round += 1)
|
|
160
|
+
out += createHash('sha256').update(`${round}:${seed}`).digest('hex');
|
|
161
|
+
return out.slice(0, len);
|
|
162
|
+
}
|
|
163
|
+
const B32 = '0123456789abcdefghjkmnpqrstvwxyz';
|
|
164
|
+
function entropyB32(len, seed) {
|
|
165
|
+
const hex = entropyHex(len * 2, seed);
|
|
166
|
+
let out = '';
|
|
167
|
+
for (let i = 0; i < len; i++)
|
|
168
|
+
out += B32[parseInt(hex.slice(i * 2, i * 2 + 2), 16) % 32];
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
export function newMachineId(seed) {
|
|
172
|
+
return entropyHex(14, seed);
|
|
173
|
+
}
|
|
174
|
+
export function newInstanceId(occurredAt, seed) {
|
|
175
|
+
// ULID-like: time prefix (10 chars) + seeded tail (16 chars), uppercase Crockford base32.
|
|
176
|
+
const t = nowEpochMs(occurredAt);
|
|
177
|
+
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
178
|
+
let time = '';
|
|
179
|
+
let ms = t;
|
|
180
|
+
for (let i = 0; i < 10; i++) {
|
|
181
|
+
time = A[ms % 32] + time;
|
|
182
|
+
ms = Math.floor(ms / 32);
|
|
183
|
+
}
|
|
184
|
+
return time + entropyB32(16, seed).toUpperCase();
|
|
185
|
+
}
|
|
186
|
+
export function newVolumeId(seed) {
|
|
187
|
+
return `vol_${entropyB32(20, seed)}`;
|
|
188
|
+
}
|
|
189
|
+
export function newLeaseNonce(seed) {
|
|
190
|
+
return entropyHex(24, seed);
|
|
191
|
+
}
|
|
192
|
+
/** An app's internal numeric id — the same seeded source, read as a 24-bit integer. */
|
|
193
|
+
export function newAppNumericId(seed) {
|
|
194
|
+
return parseInt(entropyHex(6, seed), 16);
|
|
195
|
+
}
|
|
196
|
+
/** Fly machine names default to adjective-noun-number; the twin's default is the same shape. */
|
|
197
|
+
const NAME_ADJ = ['ancient', 'bold', 'calm', 'divine', 'empty', 'frosty', 'green', 'hidden', 'icy', 'jolly', 'late', 'misty', 'nameless', 'odd', 'purple', 'quiet', 'rough', 'shy', 'twilight', 'young'];
|
|
198
|
+
const NAME_NOUN = ['breeze', 'cherry', 'dawn', 'feather', 'glitter', 'haze', 'leaf', 'meadow', 'night', 'paper', 'rain', 'shadow', 'silence', 'smoke', 'star', 'sun', 'thunder', 'violet', 'water', 'wind'];
|
|
199
|
+
export function newMachineName(seed) {
|
|
200
|
+
const h = entropyHex(8, seed);
|
|
201
|
+
const a = NAME_ADJ[parseInt(h.slice(0, 2), 16) % NAME_ADJ.length];
|
|
202
|
+
const n = NAME_NOUN[parseInt(h.slice(2, 4), 16) % NAME_NOUN.length];
|
|
203
|
+
return `${a}-${n}-${(parseInt(h.slice(4, 8), 16) % 9000) + 1000}`;
|
|
204
|
+
}
|
|
205
|
+
/** 6PN address: deterministic per machine id (fdaa:… shape, fly-go's "internal 6PN address").
|
|
206
|
+
* Ledgered only — nothing routes it locally; published loopback ports are the local
|
|
207
|
+
* reachability story. */
|
|
208
|
+
export function machinePrivateIp(machineId) {
|
|
209
|
+
const h = createHash('sha256').update(machineId).digest('hex');
|
|
210
|
+
return `fdaa:0:${h.slice(0, 4)}:a7b:${h.slice(4, 8)}:${h.slice(8, 12)}:${h.slice(12, 16)}:2`;
|
|
211
|
+
}
|
|
212
|
+
export function machineEvent(type, status, source, occurredAt, seq, request) {
|
|
213
|
+
// Event id: entropy-free WITHIN a machine write (derived from type+status+seq+time) so a
|
|
214
|
+
// pinned-clock verify sees stable events, but unique across the stream via seq.
|
|
215
|
+
const t = nowEpochMs(occurredAt);
|
|
216
|
+
const id = createHash('sha256').update(`${type}:${status}:${seq}:${t}`).digest('hex').slice(0, 26);
|
|
217
|
+
return { id, type, status, source, timestamp: t, ...(request ? { request } : {}) };
|
|
218
|
+
}
|
|
219
|
+
// ── image_ref (fly-go ImageRef {registry, repository, tag, digest, labels}) ───────────────────
|
|
220
|
+
export function imageRefOf(image) {
|
|
221
|
+
// "registry/repository:tag" | "repository:tag" | bare repository. Digest is the local twin's
|
|
222
|
+
// deterministic stand-in (sha256 of the image string) — a real registry digest needs a real
|
|
223
|
+
// pull, which only the docker execution plane does; the ledgered digest is disclosed as
|
|
224
|
+
// twin-derived (spec-sources.json).
|
|
225
|
+
let rest = image;
|
|
226
|
+
let tag = 'latest';
|
|
227
|
+
const at = rest.indexOf('@');
|
|
228
|
+
let digest = '';
|
|
229
|
+
if (at !== -1) {
|
|
230
|
+
digest = rest.slice(at + 1);
|
|
231
|
+
rest = rest.slice(0, at);
|
|
232
|
+
}
|
|
233
|
+
const colon = rest.lastIndexOf(':');
|
|
234
|
+
if (colon !== -1 && !rest.slice(colon + 1).includes('/')) {
|
|
235
|
+
tag = rest.slice(colon + 1);
|
|
236
|
+
rest = rest.slice(0, colon);
|
|
237
|
+
}
|
|
238
|
+
const firstSeg = rest.split('/')[0] ?? '';
|
|
239
|
+
const hasRegistry = firstSeg.includes('.') || firstSeg.includes(':') || firstSeg === 'localhost';
|
|
240
|
+
const registry = hasRegistry ? firstSeg : 'registry-1.docker.io';
|
|
241
|
+
const repository = hasRegistry ? rest.split('/').slice(1).join('/') : (rest.includes('/') ? rest : `library/${rest}`);
|
|
242
|
+
return {
|
|
243
|
+
registry,
|
|
244
|
+
repository,
|
|
245
|
+
tag,
|
|
246
|
+
digest: digest || `sha256:${createHash('sha256').update(image).digest('hex')}`,
|
|
247
|
+
labels: {},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// ── container-spec derivation from a machine row ──────────────────────────────────────────────
|
|
251
|
+
export function containerSpecOf(m) {
|
|
252
|
+
const config = (m.config ?? {});
|
|
253
|
+
const image = String(config.image ?? '');
|
|
254
|
+
const memoryMb = Number(config.guest?.memory_mb ?? 256);
|
|
255
|
+
const cpus = Number(config.guest?.cpus ?? 1);
|
|
256
|
+
const base = flyRuntimeEnv({
|
|
257
|
+
id: String(m.id),
|
|
258
|
+
app_name: String(m.app_name),
|
|
259
|
+
region: String(m.region),
|
|
260
|
+
instance_id: String(m.instance_id),
|
|
261
|
+
image,
|
|
262
|
+
memory_mb: memoryMb,
|
|
263
|
+
private_ip: String(m.private_ip),
|
|
264
|
+
});
|
|
265
|
+
const env = { ...base };
|
|
266
|
+
for (const [k, v] of Object.entries((config.env ?? {})))
|
|
267
|
+
env[k] = String(v);
|
|
268
|
+
const ports = [];
|
|
269
|
+
for (const svc of (config.services ?? [])) {
|
|
270
|
+
const p = Number(svc.internal_port ?? 0);
|
|
271
|
+
if (p > 0 && !ports.some((x) => x.internal === p))
|
|
272
|
+
ports.push({ internal: p });
|
|
273
|
+
}
|
|
274
|
+
const mounts = [];
|
|
275
|
+
for (const mt of (config.mounts ?? [])) {
|
|
276
|
+
if (mt.volume && mt.path)
|
|
277
|
+
mounts.push({ volumeId: String(mt.volume), path: String(mt.path) });
|
|
278
|
+
}
|
|
279
|
+
const init = (config.init ?? {});
|
|
280
|
+
return {
|
|
281
|
+
machineId: String(m.id),
|
|
282
|
+
appName: String(m.app_name),
|
|
283
|
+
machineName: String(m.name),
|
|
284
|
+
image,
|
|
285
|
+
memoryMiB: Number.isFinite(memoryMb) && memoryMb > 0 ? Math.floor(memoryMb) : 256,
|
|
286
|
+
cpus: Number.isFinite(cpus) && cpus > 0 ? cpus : 1,
|
|
287
|
+
env,
|
|
288
|
+
...(Array.isArray(init.cmd) && init.cmd.length > 0 ? { cmd: init.cmd.map(String) } : {}),
|
|
289
|
+
...(Array.isArray(init.entrypoint) && init.entrypoint.length > 0 ? { entrypoint: init.entrypoint.map(String) } : {}),
|
|
290
|
+
ports,
|
|
291
|
+
mounts,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type FlyContainerRuntime } from './fly-machines.js';
|
|
2
|
+
export type FlyServerRuntimeChoice = 'virtual' | 'docker' | FlyContainerRuntime;
|
|
3
|
+
/** Configuration of the LOCAL EXECUTION plane, declared here at the host boundary so a caller can
|
|
4
|
+
* name it without importing the docker module (structurally the docker runtime's own options).
|
|
5
|
+
* Ignored by the virtual plane, which runs nothing and therefore admits nothing. */
|
|
6
|
+
export interface FlyLocalExecutionOptions {
|
|
7
|
+
/** MiB of writable storage admission keeps FREE beyond a machine's own need. Mechanism, not
|
|
8
|
+
* policy: a World on a small disk configures the reserve down rather than the runtime quietly
|
|
9
|
+
* lowering its floor. Omitted => the runtime's 2048 MiB default. */
|
|
10
|
+
storageReserveMiB?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveFlyRuntime(choice: FlyServerRuntimeChoice | undefined, root?: string, options?: FlyLocalExecutionOptions): Promise<FlyContainerRuntime>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The execution-plane CHOICE, resolved at the host boundary (cli.ts, tests) — dev plane, never
|
|
2
|
+
// imported by the serve path. 'virtual' is the pure ledger (offline default, nothing runs);
|
|
3
|
+
// 'docker' is fly-docker.ts (machines genuinely run as local containers), a lazy import so the
|
|
4
|
+
// docker module enters no graph unless asked for; a FlyContainerRuntime object injects a custom
|
|
5
|
+
// plane. Absent capability FAILS with the fix, never a silent downgrade to virtual.
|
|
6
|
+
import { VIRTUAL_FLY_RUNTIME } from "./fly-machines.js";
|
|
7
|
+
export async function resolveFlyRuntime(choice, root = process.cwd(), options = {}) {
|
|
8
|
+
if (choice === undefined || choice === 'virtual')
|
|
9
|
+
return VIRTUAL_FLY_RUNTIME;
|
|
10
|
+
if (choice === 'docker') {
|
|
11
|
+
// Lazy import so the docker module never enters the graph unless asked for.
|
|
12
|
+
const { FlyDockerRuntime, dockerAvailable } = await import("./fly-docker.js");
|
|
13
|
+
if (!dockerAvailable()) {
|
|
14
|
+
// Absent capability => FAIL with the fix, never a silent downgrade to virtual (a caller who
|
|
15
|
+
// asked for real machines must not think they got them).
|
|
16
|
+
throw new Error('machine provider: the configured local execution runtime is unavailable');
|
|
17
|
+
}
|
|
18
|
+
return new FlyDockerRuntime(root, options);
|
|
19
|
+
}
|
|
20
|
+
return choice;
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type FlyContainerRuntime } from './fly-machines.js';
|
|
2
|
+
/** Options every Fly-twin HTTP surface needs, independent of who owns the socket. The
|
|
3
|
+
* execution plane arrives PRE-RESOLVED: `resolveFlyRuntime` is async (the docker plane is a
|
|
4
|
+
* lazy import), and a fetch factory must be synchronous to mount in-process. Omitting it
|
|
5
|
+
* selects the offline default — the pure ledger, nothing runs. */
|
|
6
|
+
export interface FlyTwinFetchOptions {
|
|
7
|
+
root?: string;
|
|
8
|
+
readOnly?: boolean;
|
|
9
|
+
runtime?: FlyContainerRuntime;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The pack's whole HTTP surface as a plain fetch (runtime contract R12b): `createFlyTwinServer`
|
|
13
|
+
* is `Bun.serve` around this closure, and a Worker/DO entry mounts the same closure in-process,
|
|
14
|
+
* so the standalone and hosted lanes execute identical serving bytes.
|
|
15
|
+
*
|
|
16
|
+
* Hand-rolled rather than the kernel adapter (`createTwinFetchFromHandler`): the handler takes
|
|
17
|
+
* this pack's `runtime` (the execution plane) as a per-instance argument and lower-cases its
|
|
18
|
+
* header map, which the adapter's fixed request shape does not thread.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createFlyTwinFetch(options?: FlyTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
21
|
+
export declare function createFlyTwinServer(options?: {
|
|
22
|
+
cleanupOwnedOnStart?: boolean;
|
|
23
|
+
root?: string;
|
|
24
|
+
port?: number;
|
|
25
|
+
readOnly?: boolean;
|
|
26
|
+
runtime?: FlyContainerRuntime;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
port: number;
|
|
29
|
+
runtimeKind: string;
|
|
30
|
+
stop: () => Promise<void>;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// fly twin HTTP server — serve the Machines API twin handler over plain HTTP so any unmodified
|
|
2
|
+
// Machines API client (flyctl's flaps transport, fly-admin, plain fetch — Fly ships no canonical
|
|
3
|
+
// npm SDK) works against it by pointing FLY_API_HOSTNAME at http://127.0.0.1:<port>, exactly the
|
|
4
|
+
// env var Fly's own docs use for the base URL. Writable by default; pass readOnly to reject
|
|
5
|
+
// writes with 405. State is the kernel projection (no side-store) — see fly-twin.ts.
|
|
6
|
+
//
|
|
7
|
+
// The EXECUTION PLANE arrives INJECTED and pre-resolved (a FlyContainerRuntime): the pure-ledger
|
|
8
|
+
// virtual runtime is the default — nothing runs — and the real docker plane is a dev-plane
|
|
9
|
+
// adapter the HOST BOUNDARY resolves (cli.ts via fly-runtime-choice.ts). This module never names
|
|
10
|
+
// fly-docker.ts, so the serve closure carries no process, cwd or child_process (R4, R12b): the
|
|
11
|
+
// engine is a provider behind a door, never the serve path's business.
|
|
12
|
+
import { serveHttp } from '@volter/world-core';
|
|
13
|
+
import { rows, VIRTUAL_FLY_RUNTIME } from "./fly-machines.js";
|
|
14
|
+
import { worldNow, statefulTwinManifest } from '@volter/world-core';
|
|
15
|
+
import { handleFlyTwinRequest } from "./fly-twin.js";
|
|
16
|
+
/**
|
|
17
|
+
* The pack's whole HTTP surface as a plain fetch (runtime contract R12b): `createFlyTwinServer`
|
|
18
|
+
* is `Bun.serve` around this closure, and a Worker/DO entry mounts the same closure in-process,
|
|
19
|
+
* so the standalone and hosted lanes execute identical serving bytes.
|
|
20
|
+
*
|
|
21
|
+
* Hand-rolled rather than the kernel adapter (`createTwinFetchFromHandler`): the handler takes
|
|
22
|
+
* this pack's `runtime` (the execution plane) as a per-instance argument and lower-cases its
|
|
23
|
+
* header map, which the adapter's fixed request shape does not thread.
|
|
24
|
+
*/
|
|
25
|
+
export function createFlyTwinFetch(options = {}) {
|
|
26
|
+
const readOnly = options.readOnly ?? false;
|
|
27
|
+
const root = options.root; // undefined resolves through the kernel's projectRoot — the serve path never reads the process (R4)
|
|
28
|
+
const runtime = options.runtime ?? VIRTUAL_FLY_RUNTIME;
|
|
29
|
+
return async function flyTwinFetch(request) {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
// GET /twin — the discovery manifest (education inside the twin). Constants only: no
|
|
32
|
+
// clock, no state, so every replay of this door is byte-identical (R9).
|
|
33
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
34
|
+
return Response.json(statefulTwinManifest({ vendor: 'fly', twinOf: "Fly.io's Machines API (api.machines.dev/v1)", stores: 'apps, machines and volumes — with --docker each Machine runs as a real local container' }));
|
|
35
|
+
}
|
|
36
|
+
const body = request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : '';
|
|
37
|
+
const headers = {};
|
|
38
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
39
|
+
const { status, body: out } = await handleFlyTwinRequest({
|
|
40
|
+
method: request.method,
|
|
41
|
+
path: url.pathname + (url.search || ''),
|
|
42
|
+
body,
|
|
43
|
+
headers,
|
|
44
|
+
readOnly,
|
|
45
|
+
runtime,
|
|
46
|
+
occurredAt: worldNow(),
|
|
47
|
+
root,
|
|
48
|
+
});
|
|
49
|
+
if (status === 204 || out === null)
|
|
50
|
+
return new Response(null, { status });
|
|
51
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json' } });
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function createFlyTwinServer(options = {}) {
|
|
55
|
+
const readOnly = options.readOnly ?? false;
|
|
56
|
+
const root = options.root; // undefined resolves through the kernel's projectRoot — the serve path never reads the process (R4)
|
|
57
|
+
const runtime = options.runtime ?? VIRTUAL_FLY_RUNTIME;
|
|
58
|
+
// A freshly claimed World instance has no retained provider ledger, but an
|
|
59
|
+
// abrupt prior runner termination may have prevented the old service's stop
|
|
60
|
+
// hook. Reclaim only resources bearing this exact World-service owner before
|
|
61
|
+
// accepting new demand. Standalone/persistent providers opt out by default.
|
|
62
|
+
if (options.cleanupOwnedOnStart)
|
|
63
|
+
await runtime.cleanup?.();
|
|
64
|
+
const server = await serveHttp({
|
|
65
|
+
port: options.port ?? 0,
|
|
66
|
+
idleTimeout: 60,
|
|
67
|
+
fetch: createFlyTwinFetch({ root, readOnly, runtime }),
|
|
68
|
+
});
|
|
69
|
+
let stopPromise;
|
|
70
|
+
const stop = () => {
|
|
71
|
+
if (stopPromise)
|
|
72
|
+
return stopPromise;
|
|
73
|
+
stopPromise = (async () => {
|
|
74
|
+
server.stop(true);
|
|
75
|
+
// A World owns the full lifecycle, including resources created behind a
|
|
76
|
+
// service boundary. Give every recorded Machine a graceful shutdown
|
|
77
|
+
// before removal, then let the runtime sweep any owner-labelled residue
|
|
78
|
+
// left by an interrupted API operation.
|
|
79
|
+
const failures = [];
|
|
80
|
+
const machines = rows('machine', root)
|
|
81
|
+
.map((machine) => machine._container_ref)
|
|
82
|
+
.filter((value) => typeof value === 'string' && value.length > 0);
|
|
83
|
+
await Promise.all(machines.map(async (containerRef) => {
|
|
84
|
+
try {
|
|
85
|
+
await runtime.stop(containerRef, { timeoutSeconds: 3 });
|
|
86
|
+
}
|
|
87
|
+
catch { /* already stopped or gone */ }
|
|
88
|
+
try {
|
|
89
|
+
await runtime.remove(containerRef);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
if (runtime.removeVolume) {
|
|
96
|
+
const volumes = rows('volume', root).map((volume) => String(volume.id));
|
|
97
|
+
await Promise.all(volumes.map(async (volumeId) => {
|
|
98
|
+
try {
|
|
99
|
+
await runtime.removeVolume(volumeId);
|
|
100
|
+
}
|
|
101
|
+
catch { /* never materialized or already gone */ }
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await runtime.cleanup?.();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
109
|
+
}
|
|
110
|
+
if (failures.length > 0)
|
|
111
|
+
throw new Error(`world service cleanup failed: ${failures.join('; ')}`);
|
|
112
|
+
})();
|
|
113
|
+
return stopPromise;
|
|
114
|
+
};
|
|
115
|
+
return { port: server.port ?? options.port ?? 0, runtimeKind: runtime.kind, stop };
|
|
116
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FlyContainerRuntime } from './fly-machines.js';
|
|
2
|
+
export type FlyRequest = {
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
headers?: Record<string, string | undefined>;
|
|
7
|
+
occurredAt?: string;
|
|
8
|
+
root?: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
/** The execution plane. Defaults to the pure-ledger VIRTUAL runtime (offline). */
|
|
11
|
+
runtime?: FlyContainerRuntime;
|
|
12
|
+
};
|
|
13
|
+
export type FlyResponse = {
|
|
14
|
+
status: number;
|
|
15
|
+
body: unknown;
|
|
16
|
+
};
|
|
17
|
+
export declare const FLY_RESOURCE_TYPES: readonly ["app", "machine", "volume", "secret"];
|
|
18
|
+
/** The public + internal Machines API hosts (fly.io/docs/machines/api/working-with-machines-api). */
|
|
19
|
+
export declare const FLY_API_HOST = "api.machines.dev";
|
|
20
|
+
export declare const FLY_INTERNAL_API_HOST = "_api.internal";
|
|
21
|
+
export declare function handleFlyTwinRequest(req: FlyRequest): Promise<FlyResponse>;
|
|
22
|
+
export declare function flyTwinSnapshot(): {
|
|
23
|
+
implementedEndpoints: string[];
|
|
24
|
+
resourceTypes: string[];
|
|
25
|
+
};
|