@volter/world-machine 2.0.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,367 @@
1
+ // The machine: one Firecracker microVM in a slot on the machine host, run in place. A checkpoint
2
+ // pauses it, writes its memory (a diff against what it was loaded from) and clones its disk, resumes
3
+ // it, then materializes a full memory file for the entry and appends the entry to the ledger. A
4
+ // restore replaces the slot's process with this branch's head (or the warm base), steps the guest's
5
+ // wall clock to the World's, and points its one route out at this World's twins.
6
+ import { createHash, randomBytes } from 'node:crypto';
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import { GUEST_AGENT, GUEST_INJECT, GUEST_INJECT_DIR, GUEST_PROFILE, HostSession, hostNowMs, q } from "./host.js";
10
+ import { appendSnapshot, headAt, headSnapshot, worldInstant } from "./ledger.js";
11
+ /** Rewritten with a new id at each restore, before stale connections are reset (see guestFiles). */
12
+ export const RESTORE_ID_FILE = '/run/world-machine/restore-id';
13
+ const IDLE = 'nothing is running yet: the first GET /machine, POST /machine/checkpoint or POST /machine/restore brings the machine up';
14
+ /** A machine-host path as one shell word: `~/x` expands on that host. */
15
+ export const hp = (path) => (path.startsWith('~/') ? `"$HOME"/${q(path.slice(2))}` : q(path));
16
+ /** Every `<VENDOR>_TWIN_URL` the World handed this service. */
17
+ export function twinUrls(env) {
18
+ const out = {};
19
+ for (const [k, v] of Object.entries(env)) {
20
+ const m = /^([A-Z0-9_]+)_TWIN_URL$/.exec(k);
21
+ if (m && v) {
22
+ try {
23
+ out[m[1]] = new URL(v);
24
+ }
25
+ catch { /* not a URL: not a route */ }
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ /** A stable guest-visible port per vendor: derived from the name (FNV-1a into 20000-29999), a
31
+ * collision moving the later name in sorted order up by one; a configured port wins. */
32
+ export function egressPorts(vendors, fixed) {
33
+ const taken = new Set(Object.values(fixed));
34
+ const out = {};
35
+ for (const v of [...vendors].sort()) {
36
+ if (fixed[v] !== undefined) {
37
+ out[v] = fixed[v];
38
+ continue;
39
+ }
40
+ let h = 0x811c9dc5;
41
+ for (const c of v) {
42
+ h ^= c.charCodeAt(0);
43
+ h = Math.imul(h, 0x01000193) >>> 0;
44
+ }
45
+ let port = 20000 + (h % 10000);
46
+ while (taken.has(port))
47
+ port = port === 29999 ? 20000 : port + 1;
48
+ taken.add(port);
49
+ out[v] = port;
50
+ }
51
+ return out;
52
+ }
53
+ /** The guest kernel's command line. The guest's address is on-link with the tap's host end and it
54
+ * has no gateway: no route leads anywhere but the machine host. */
55
+ export const bootArgs = (config) => `console=ttyS0 reboot=k panic=1 ip=${config.guestIp}:::255.255.255.252::eth0:off`;
56
+ /** The egress port name of the World's proxy (never a vendor's: vendors come from *_TWIN_URL names). */
57
+ const WORLD_PROXY = 'VOLTER_WORLD_PROXY';
58
+ export async function baseHash(config, session) {
59
+ const facts = await session.call(`printf '@{"kernel":"%s","rootfs":"%s","fc":"%s"}\\n' "$(stat -c %s-%Y ${hp(config.kernel)})" "$(stat -c %s-%Y ${hp(config.rootfs)})" "$(${hp(config.firecracker)} --version 2>&1 | head -1)"`);
60
+ const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
61
+ const inputs = { ...facts, bootArgs: bootArgs(config), provision, agent: GUEST_AGENT, profile: GUEST_PROFILE, vcpus: config.vcpus, memMib: config.memMib, slot: config.slot, tap: config.tap, hostIp: config.hostIp, guestIp: config.guestIp, store: config.store };
62
+ return createHash('sha256').update(JSON.stringify(inputs)).digest('hex').slice(0, 16);
63
+ }
64
+ export class Machine {
65
+ config;
66
+ root;
67
+ env;
68
+ port;
69
+ state = 'idle';
70
+ message = IDLE;
71
+ loaded = null;
72
+ base;
73
+ lastUsed = Promise.resolve(0);
74
+ token = randomBytes(8).toString('hex');
75
+ /** This load's restore id (RESTORE_ID_FILE); a backend that can write it before the machine runs sets it first. */
76
+ restoreId = '';
77
+ session;
78
+ ports;
79
+ twins;
80
+ constructor(config, root, env, port) {
81
+ this.config = config;
82
+ this.root = root;
83
+ this.env = env;
84
+ this.port = port;
85
+ this.session = new HostSession(config);
86
+ this.twins = twinUrls(env);
87
+ // the World's egress proxy (its CONNECT door to the twins and, as its network policy allows, beyond) has a
88
+ // stable guest-visible port like a twin's
89
+ this.ports = egressPorts([...Object.keys(this.twins), WORLD_PROXY], config.egressPorts);
90
+ }
91
+ /** What the guest sees: vendor → its stable URL on the tap's host address. */
92
+ egress() {
93
+ return Object.fromEntries(Object.entries(this.twins).map(([v, u]) => [v, `http://${this.config.hostIp}:${this.ports[v]}${u.pathname === '/' ? '' : u.pathname}`]));
94
+ }
95
+ /** The World's live env (VOLTER_WORLD_ENV_FILE), `export K='v'` lines. */
96
+ worldEnv() {
97
+ const file = this.env.VOLTER_WORLD_ENV_FILE;
98
+ const out = {};
99
+ if (!file || !existsSync(file))
100
+ return out;
101
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
102
+ const m = /^export ([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
103
+ if (m)
104
+ out[m[1]] = m[2].startsWith("'") ? m[2].slice(1, -1).replaceAll("'\\''", "'") : m[2];
105
+ }
106
+ return out;
107
+ }
108
+ /** The World's proxy as the machine reaches it, read from its live env at each load: its guests' listener
109
+ * (`VOLTER_WORLD_GUEST_PROXY`, where every connection is kept off this host's own network, whatever NAT makes its
110
+ * source look like; world-runtime redirect-proxy.ts), else, from an older World, its one listener. */
111
+ proxy() {
112
+ const env = this.worldEnv();
113
+ try {
114
+ return new URL(env.VOLTER_WORLD_GUEST_PROXY || env.VOLTER_WORLD_PROXY || '');
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ /** What the guest is given at each start and restore: /etc/world.env, the World's live env as the guest sees it,
121
+ * and the files it names. Twin URLs become the stable egress ones, the World's proxy its forwarder (the twins'
122
+ * own addresses bypass it), NODE_OPTIONS the guest's copy of the redirect, and a value naming a certificate file on the
123
+ * World's host (its CA and trust bundle, PEM) a copy of it under /etc; what only that host can use (a
124
+ * directory there, a loopback URL of its other listeners) is left out and named, and an empty value is left unset. */
125
+ guestFiles() {
126
+ const out = Object.entries(this.egress()).map(([v, u]) => `${v}_TWIN_URL=${u}`);
127
+ // a new id at every load, written before the connections the load left stale are reset: a program in the machine
128
+ // that loses a connection can tell a restore (the id changed since it connected) from its peer going away
129
+ const files = { [RESTORE_ID_FILE]: `${this.restoreId || randomBytes(8).toString('hex')}\n` };
130
+ const env = this.worldEnv();
131
+ const proxy = this.proxy();
132
+ const guestProxy = proxy ? `http://${this.config.hostIp}:${this.ports[WORLD_PROXY]}` : null;
133
+ // a value naming either of the proxy's listeners (HTTPS_PROXY names the World's own) is the machine's forwarder
134
+ const proxyPorts = new Set([proxy?.port, ...['VOLTER_WORLD_PROXY', 'VOLTER_WORLD_GUEST_PROXY'].map((k) => { try {
135
+ return new URL(env[k] ?? '').port;
136
+ }
137
+ catch {
138
+ return '';
139
+ } })].filter(Boolean));
140
+ const hostPorts = new Set([...Object.values(this.twins).map((u) => u.port), String(this.port), ...proxyPorts, ...['MACHINE_URL'].map((k) => { try {
141
+ return new URL(env[k] ?? '').port;
142
+ }
143
+ catch {
144
+ return '';
145
+ } })].filter(Boolean));
146
+ const left = [];
147
+ const unset = [];
148
+ for (const [k, v] of Object.entries(env)) {
149
+ if (k.endsWith('_TWIN_URL') || v.includes('\n'))
150
+ continue;
151
+ // an empty value (a repository's .env.example keys, detected with no value) is left unset: set, even to nothing,
152
+ // it would win over the app's own env files, which Next.js and dotenv never let override a set variable (measured:
153
+ // the app's plan ids in .env.local were hidden, and the agent had to unset them on every start)
154
+ if (v === '') {
155
+ unset.push(k);
156
+ continue;
157
+ }
158
+ if (k === 'NODE_OPTIONS') {
159
+ out.push(`NODE_OPTIONS=--require ${GUEST_INJECT_DIR}/inject.cjs`);
160
+ continue;
161
+ }
162
+ if (/^(no_proxy|NO_PROXY)$/.test(k)) {
163
+ out.push(`${k}=${[v, this.config.hostIp].filter(Boolean).join(',')}`);
164
+ continue;
165
+ }
166
+ const loopback = /^[a-z]+:\/\/(?:[^@/]*@)?(?:127\.0\.0\.1|localhost):(\d+)/.exec(v);
167
+ if (loopback && proxy && proxyPorts.has(loopback[1]) && guestProxy) {
168
+ out.push(`${k}=${guestProxy}`);
169
+ continue;
170
+ }
171
+ if (v.startsWith('/')) {
172
+ let size = -1;
173
+ try {
174
+ const s = statSync(v);
175
+ if (s.isFile())
176
+ size = s.size;
177
+ }
178
+ catch { /* absent */ }
179
+ const pem = size >= 0 && size <= 1 << 20 ? readFileSync(v, 'utf8') : '';
180
+ if (pem.startsWith('-----BEGIN ')) {
181
+ const guest = `/etc/volter-world-${basename(v)}`;
182
+ files[guest] = pem;
183
+ out.push(`${k}=${guest}`);
184
+ }
185
+ else
186
+ left.push(k);
187
+ continue;
188
+ }
189
+ if (loopback && hostPorts.has(loopback[1])) {
190
+ left.push(k);
191
+ continue;
192
+ }
193
+ out.push(`${k}=${v}`);
194
+ }
195
+ // Node's own fetch (corepack, npm's newer paths) uses HTTP(S)_PROXY only when asked: without it corepack could not
196
+ // reach the registry from the machine (measured), where the proxy is the only way out
197
+ if (guestProxy && !('NODE_USE_ENV_PROXY' in env))
198
+ out.push('NODE_USE_ENV_PROXY=1');
199
+ if (left.length)
200
+ out.unshift(`# not carried (the World's host only): ${left.join(' ')}`);
201
+ if (unset.length)
202
+ out.unshift(`# empty in the World, so left unset (the app's own env files can set them): ${unset.join(' ')}`);
203
+ // world-core's redirect with the files it loads: written at every load rather than kept in the warm base, whose
204
+ // hash would otherwise move with every change to the pack facts (and rebuild the base each time twin moves)
205
+ for (const [f, body] of Object.entries(GUEST_INJECT))
206
+ files[`${GUEST_INJECT_DIR}/${f}`] = body;
207
+ return { ...files, '/etc/world.env': `${out.join('\n')}\n` };
208
+ }
209
+ forwarders() {
210
+ const proxy = this.proxy();
211
+ return Object.entries(this.twins).map(([v, u]) => {
212
+ const host = u.hostname === '127.0.0.1' || u.hostname === 'localhost' ? this.config.twinHost : u.hostname;
213
+ return q(`${this.ports[v]}=${host}:${u.port || (u.protocol === 'https:' ? '443' : '80')}`);
214
+ }).concat(proxy ? [q(`${this.ports[WORLD_PROXY]}=${proxy.hostname === '127.0.0.1' || proxy.hostname === 'localhost' ? this.config.twinHost : proxy.hostname}:${proxy.port || '80'}`)] : []).join(' ');
215
+ }
216
+ owner() {
217
+ return JSON.stringify({ token: this.token, world: this.env.VOLTER_WORLD_NAME ?? null, root: this.root, pid: process.pid, port: this.port });
218
+ }
219
+ baseDir() { return `$WM_STORE/base-${this.base}`; }
220
+ /** The machine, brought up when first asked for: nothing runs until a door needs it, so a new branch's
221
+ * machine is loaded once, by the restore that retakes to its mark, instead of booting its head at start and
222
+ * being replaced a moment later. `GET /machine` and `checkpoint` bring up the head if the ledger has one,
223
+ * else the warm base; `restore` loads the head itself. Returns the restore's facts when it brought it up. */
224
+ async ensure() {
225
+ if (this.state === 'running' || this.state === 'displaced')
226
+ return null;
227
+ try {
228
+ this.base = await baseHash(this.config, this.session);
229
+ const { ready } = await this.session.call(`wm_reflink_check >/dev/null || return 1\n[ -f "${this.baseDir()}/vmstate" ] && echo '@{"ready":true}' || echo '@{"ready":false}'`);
230
+ if (!headSnapshot(this.root) && !ready) {
231
+ this.state = 'no-base';
232
+ this.message = `no warm base base-${this.base} in the store; build it with \`world-machine base --config <file>\` (rerun until it prints ready)`;
233
+ return null;
234
+ }
235
+ return await this.restore();
236
+ }
237
+ catch (error) {
238
+ this.state = 'error';
239
+ this.message = error.message;
240
+ return null;
241
+ }
242
+ }
243
+ /** This branch's head, loaded in place; with no head, the warm base. */
244
+ async restore() {
245
+ if (!this.base) {
246
+ this.base = await baseHash(this.config, this.session);
247
+ }
248
+ const head = headSnapshot(this.root);
249
+ const dir = head ? `$WM_STORE/${head.dir}` : this.baseDir();
250
+ const { offsetMs, rttMs } = await this.session.offset();
251
+ const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
252
+ const deltaS = clockFile && existsSync(clockFile) ? (Date.parse(worldInstant()) - hostNowMs()) / 1000 : 0;
253
+ const t0 = hostNowMs();
254
+ const r = await this.session.call([
255
+ 'wm_net >/dev/null || return 1',
256
+ `wm_timed egress wm_egress ${this.forwarders()} || return 1`,
257
+ `wm_restore "${dir}" || return 1`,
258
+ `wm_own ${q(this.owner())}`,
259
+ `printf '%s' ${q(JSON.stringify(this.guestFiles()))} | wm_timed agent wm_agent clock ${this.session.offsetS} ${deltaS} - || return 1`,
260
+ ].join('\n'));
261
+ // the store's baseline for the next checkpoint's growth, read after the answer (a syncfs is slow)
262
+ this.lastUsed = this.session.call('wm_used').then((u) => u.used, () => Number.NaN);
263
+ this.loaded = head ? { id: head.id, label: head.label, dir: head.dir } : null;
264
+ this.state = 'running';
265
+ this.message = undefined;
266
+ return {
267
+ id: head?.id ?? null, label: head?.label ?? null, from: head ? 'head' : `base-${this.base}`,
268
+ loadMs: r.loadMs, restoreMs: r.restoreMs, clockMs: r.clockMs, clockErrorMs: r.errorMs, clockSteps: r.steps, agentConnectMs: r.connectMs, agentFirstReplyMs: r.firstReplyMs, agentFirstHandleMs: r.firstHandleMs, agentFirstWaitMs: r.firstWaitMs, agentFirstSetupMs: r.firstSetupMs, staleReset: r.staleReset, resetMs: r.resetMs, agentMs: r.agentMs, egressMs: r.egressMs,
269
+ hostOffsetMs: offsetMs, hostRttMs: rttMs, totalMs: Math.round(hostNowMs() - t0),
270
+ };
271
+ }
272
+ /** Is the slot still this service's? A later restore by another World's machine takes it. */
273
+ async refresh() {
274
+ if (this.state !== 'running' && this.state !== 'displaced')
275
+ return;
276
+ const r = await this.session.call('wm_running');
277
+ if (r.owner?.token !== this.token) {
278
+ this.state = 'displaced';
279
+ this.message = `the slot now runs ${r.owner?.world ?? 'nothing'}'s machine; POST /machine/restore takes it back`;
280
+ }
281
+ else if (r.fc !== 'Running') {
282
+ this.state = 'error';
283
+ this.message = `firecracker is ${r.fc}`;
284
+ }
285
+ }
286
+ async checkpoint(label, keep = false) {
287
+ await this.refresh();
288
+ if (this.state !== 'running')
289
+ throw Object.assign(new Error(`cannot checkpoint: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
290
+ await this.room();
291
+ const id = `snap-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
292
+ const dir = `snapshots/${id}`;
293
+ const parentMem = this.loaded ? `$WM_STORE/${this.loaded.dir}/mem` : `${this.baseDir()}/mem`;
294
+ // `.pending` until the ledger names it: a collection running meanwhile (another World's serve) keeps it
295
+ const r = await this.session.call([
296
+ `mkdir -p "$WM_STORE/${dir}" && : > "$WM_STORE/${dir}/.pending" || return 1`,
297
+ `wm_snapshot "$WM_STORE/${dir}" Diff || return 1`,
298
+ `wm_materialize "${parentMem}" "$WM_STORE/${dir}" || return 1`,
299
+ // the machine now runs on from this entry: its memory is the next checkpoint's parent
300
+ `printf '%s' "$WM_STORE/${dir}" > "$WM_SLOTDIR/loaded"`,
301
+ 'wm_used',
302
+ ].join('\n'));
303
+ const measured = await this.lastUsed;
304
+ const before = Number.isNaN(measured) ? (r.used ?? 0) : measured;
305
+ const storeBytes = Math.max(0, (r.used ?? 0) - before);
306
+ this.lastUsed = Promise.resolve(r.used ?? before);
307
+ const record = {
308
+ id, label, type: 'Diff', parent: this.loaded?.id ?? `base-${this.base}`, base: `base-${this.base}`, dir,
309
+ memBytes: r.memBytes ?? 0, diskBytes: Math.max(0, storeBytes - (r.memBytes ?? 0)), storeBytes,
310
+ pauseMs: r.pauseMs ?? 0, created: worldInstant(), ...(keep ? { keep: true } : {}),
311
+ };
312
+ await appendSnapshot(this.root, record);
313
+ await this.session.call(`rm -f "$WM_STORE/${dir}/.pending"; echo '@{"recorded":true}'`);
314
+ this.loaded = { id, label, dir };
315
+ return { id, pauseMs: r.pauseMs, memBytes: record.memBytes, diskBytes: record.diskBytes, storeBytes, memWriteMs: r.memWriteMs, diskCloneMs: r.diskCloneMs, materializeMs: r.materializeMs };
316
+ }
317
+ /** Refused (507) unless every filesystem beneath the store has room for a mark: the last mark's store growth, at
318
+ * least `firstMarkBytes` when there is none, plus the configured reserve. Checked before anything is written. */
319
+ async room(firstMarkBytes = 2 ** 30) {
320
+ const last = headSnapshot(this.root)?.storeBytes ?? 0;
321
+ const need = Math.max(last, firstMarkBytes) + this.config.room.reserveBytes;
322
+ try {
323
+ await this.session.call(`wm_room ${need} ${this.config.room.paths.map(hp).join(' ')}`);
324
+ }
325
+ catch (error) {
326
+ // 507 only for the refusal itself; a host that did not answer is the error it was
327
+ throw /^no room for a mark/.test(error.message) ? Object.assign(error, { status: 507 }) : error;
328
+ }
329
+ }
330
+ /** For each position of this machine's ledger (a mark's `machine@N`), the snapshot a branch forked there would restore
331
+ * and whether the store still holds it: gc with `retain` lets old ones go. */
332
+ async present(positions) {
333
+ const at = positions.map((position) => ({ position, snap: headAt(this.root, position) }));
334
+ const dirs = [...new Set(at.flatMap(({ snap }) => (snap ? [snap.dir] : [])))];
335
+ const r = dirs.length ? await this.session.call(`printf '@{"present":['; s=''; for d in ${dirs.map(q).join(' ')}; do [ -d "$WM_STORE/$d" ] && [ ! -e "$WM_STORE/$d/.pending" ] && { printf '%s"%s"' "$s" "$d"; s=,; }; done; printf ']}\n'`) : { present: [] };
336
+ const held = new Set(r.present);
337
+ return at.map(({ position, snap }) => ({ position, id: snap?.id ?? null, present: snap ? held.has(snap.dir) : false }));
338
+ }
339
+ /** The guest's wall clock against the World's, now. */
340
+ async clock() {
341
+ const { offsetMs, rttMs } = await this.session.offset();
342
+ const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
343
+ const deltaS = clockFile && existsSync(clockFile) ? (Date.parse(worldInstant()) - hostNowMs()) / 1000 : 0;
344
+ const r = await this.session.call(`wm_agent now ${this.session.offsetS} ${deltaS}`);
345
+ return { errorMs: r.errorMs, guestRttMs: r.probeRttMs, hostOffsetMs: offsetMs, hostRttMs: rttMs };
346
+ }
347
+ status() {
348
+ const head = headSnapshot(this.root);
349
+ return {
350
+ state: this.state,
351
+ head: head ? { id: head.id, label: head.label } : null,
352
+ loaded: this.state === 'running' ? (this.loaded ? { id: this.loaded.id, label: this.loaded.label } : { base: `base-${this.base}` }) : null,
353
+ guest: { ip: this.config.guestIp, sshPort: 22, sshKey: this.config.sshKey, user: 'root' },
354
+ egress: this.egress(),
355
+ ...(this.message ? { message: this.message } : {}),
356
+ };
357
+ }
358
+ /** On service stop: the slot is released only if it is still this service's. */
359
+ async stop() {
360
+ try {
361
+ await this.session.call(`if [ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ]; then wm_fc_stop; wm_egress; wm_release; fi; echo '@{"stopped":true}'`);
362
+ }
363
+ finally {
364
+ this.session.close();
365
+ }
366
+ }
367
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/python3
2
+ """publish.py <slot dir> <netns> <container> <ports JSON> <sum>: the container machine's listening TCP ports on this host's
3
+ loopback, as the machine's own `localhost`. The machine is where the app runs, and what the app is told is its address
4
+ (`http://localhost:3000`) is what it registers with a vendor: a twin delivering a webhook there posts from this host,
5
+ and reached nothing (a Stripe twin's deliveries were dropped). Published here, the twin's delivery reaches the app, and
6
+ a Lima VM forwards this host's loopback ports to the Mac, so the user's own browser opens the same URL.
7
+
8
+ Only the ports the config allows (<ports JSON>: {"ranges": [[from, to], ...], "except": [port, ...]}) are published:
9
+ the machine runs code the user did not write, and a port it held first would be taken from a service of this host that
10
+ binds it later (the retake stack's own doors, a twin restarting). Every half second the machine's listeners are read
11
+ from its network namespace (/proc/<init>/net/tcp and tcp6); an allowed port is published on 127.0.0.1 when
12
+ - the machine listens on it on a wildcard or loopback address, and not also on an address of its own (a port the
13
+ machine's doors bind, the job server and a harness's executor, is never published, however else it is bound), and
14
+ - nothing on this host listens on it.
15
+ Each connection enters the machine's namespace and reaches its loopback, 127.0.0.1 for an IPv4 listener and ::1 for an
16
+ IPv6 one, so it arrives at exactly the listener that was classified. A port the machine stops listening on stops being
17
+ published. Runs as root (entering the namespace needs it), one per slot (a lock in the slot), its pid in
18
+ <slot>/publish.pid; its socat children die with it."""
19
+ import ctypes, fcntl, json, os, signal, subprocess, sys, time
20
+
21
+ SLOT, NETNS, CONTAINER, PORTS, SUM = sys.argv[1], sys.argv[2], sys.argv[3], json.loads(sys.argv[4]), sys.argv[5]
22
+ RANGES = [tuple(r) for r in PORTS.get('ranges', [])]
23
+ EXCEPT = set(PORTS.get('except', []))
24
+ children = {} # port -> [socat processes]: one on 127.0.0.1, and one on ::1 where that is free
25
+ PR_SET_PDEATHSIG = 1
26
+ libc = ctypes.CDLL(None, use_errno=True)
27
+ LOOPBACK = ('0100007F', '00000000000000000000000001000000', '0000000000000000FFFF00000100007F')
28
+
29
+
30
+ def allowed(port):
31
+ return port not in EXCEPT and any(lo <= port <= hi for lo, hi in RANGES)
32
+
33
+
34
+ def listeners(path, family):
35
+ """[(port, kind, family)] for the LISTEN sockets in a /proc/net/tcp{,6} file; kind is any | loopback | addr."""
36
+ try:
37
+ rows = open(path).read().splitlines()[1:]
38
+ except OSError:
39
+ return []
40
+ out = []
41
+ for row in rows:
42
+ f = row.split()
43
+ if len(f) < 4 or f[3] != '0A':
44
+ continue
45
+ addr, port = f[1].rsplit(':', 1)
46
+ kind = 'any' if set(addr) == {'0'} else 'loopback' if addr in LOOPBACK else 'addr'
47
+ out.append((int(port, 16), kind, family))
48
+ return out
49
+
50
+
51
+ def machine(previous):
52
+ """{port: family} the machine publishes now. A failed read of the container keeps the previous set (a busy
53
+ `crun state` during a mark must not drop live connections); it is empty only when the container is gone."""
54
+ r = subprocess.run(['crun', 'state', CONTAINER], capture_output=True, timeout=5)
55
+ if r.returncode != 0:
56
+ gone = b'does not exist' in r.stderr or b'no such' in r.stderr.lower()
57
+ return {} if gone else previous
58
+ try:
59
+ pid = json.loads(r.stdout)['pid']
60
+ except (ValueError, KeyError):
61
+ return previous
62
+ kinds = {}
63
+ for name, family in (('tcp', 4), ('tcp6', 6)):
64
+ for port, kind, fam in listeners(f'/proc/{pid}/net/{name}', family):
65
+ kinds.setdefault(port, []).append((kind, fam))
66
+ out = {}
67
+ for port, found in kinds.items():
68
+ if not allowed(port) or any(kind == 'addr' for kind, _ in found):
69
+ continue
70
+ out[port] = 4 if any(fam == 4 for _, fam in found) else 6
71
+ return out
72
+
73
+
74
+ def host_busy():
75
+ return {port for name in ('tcp', 'tcp6') for port, _, _ in listeners(f'/proc/net/{name}', 0)}
76
+
77
+
78
+ def die_with_parent():
79
+ libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
80
+
81
+
82
+ def stop(port):
83
+ """The port's listeners and every connection they forked (their own process groups: a fork does not keep the
84
+ parent-death signal, so an open connection would otherwise outlive the publisher)."""
85
+ for p in children.pop(port, []):
86
+ try:
87
+ os.killpg(p.pid, signal.SIGTERM)
88
+ except ProcessLookupError:
89
+ pass
90
+ try:
91
+ p.wait(2)
92
+ except subprocess.TimeoutExpired:
93
+ try:
94
+ os.killpg(p.pid, signal.SIGKILL)
95
+ except ProcessLookupError:
96
+ pass
97
+
98
+
99
+ def shutdown(*_):
100
+ for port in list(children):
101
+ stop(port)
102
+ sys.exit(0)
103
+
104
+
105
+ lock = open(os.path.join(SLOT, 'publish.lock'), 'w')
106
+ try:
107
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
108
+ except OSError:
109
+ sys.exit(0) # this slot's publisher is running
110
+ for name, value in (('publish.pid', str(os.getpid())), ('publish.sum', SUM)):
111
+ with open(os.path.join(SLOT, name), 'w') as f:
112
+ f.write(value)
113
+ signal.signal(signal.SIGTERM, shutdown)
114
+ signal.signal(signal.SIGINT, shutdown)
115
+ wanted = {}
116
+ while True:
117
+ try:
118
+ wanted = machine(wanted)
119
+ for port in list(children):
120
+ if port not in wanted or children[port][0].poll() is not None:
121
+ stop(port)
122
+ busy = host_busy()
123
+ for port, family in wanted.items():
124
+ if port in children or port in busy:
125
+ continue
126
+ inside = f'TCP4\\:127.0.0.1\\:{port}' if family == 4 else f'TCP6\\:[\\:\\:1]\\:{port}'
127
+ target = f'EXEC:ip netns exec {NETNS} socat STDIO {inside}'
128
+ # 127.0.0.1, and ::1 as well (a client resolving localhost to ::1 first); errors to the log
129
+ children[port] = [subprocess.Popen(['socat', listen, target], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
130
+ stderr=sys.stdout, preexec_fn=die_with_parent, start_new_session=True)
131
+ for listen in (f'TCP4-LISTEN:{port},bind=127.0.0.1,reuseaddr,fork', f'TCP6-LISTEN:{port},bind=[::1],ipv6only=1,reuseaddr,fork')]
132
+ except Exception as error: # one bad pass is not the publisher's end
133
+ print(f'publish: {error}', flush=True)
134
+ time.sleep(0.5)
@@ -0,0 +1,20 @@
1
+ export interface Thinnable {
2
+ /** The snapshot's directory name (its id). */
3
+ dir: string;
4
+ /** When the host took it (ms since the epoch). */
5
+ takenMs: number;
6
+ /** Taken to be kept: never thinned. */
7
+ keep?: boolean;
8
+ }
9
+ export interface ThinRule {
10
+ /** How many of the newest stay whatever their age. */
11
+ retain: number;
12
+ /** Hours for which one of each hour stays (after the newest `retain`). */
13
+ hours?: number;
14
+ /** Days for which one of each day stays (after the hours). */
15
+ days?: number;
16
+ /** How many `keep` snapshots are never thinned (the newest); older ones thin like any other. */
17
+ keepMax?: number;
18
+ }
19
+ /** The dirs of `snapshots` that stay at `nowMs`. */
20
+ export declare function thinned(snapshots: Thinnable[], nowMs: number, rule: ThinRule): Set<string>;
@@ -0,0 +1,30 @@
1
+ // Retention thinned with age (config `thin`), as a backup's history is: every snapshot of the last `retain`, then one
2
+ // of each calendar hour for a day, then one of each UTC day for a month; older ones are let go. The one kept in a
3
+ // bucket is its OLDEST: nothing older can join a bucket later, so a keeper is never displaced by a newer mark leaving
4
+ // `retain` or crossing into the day tier (the day's oldest crosses first), and what a listing showed kept stays kept
5
+ // until its tier ends. A snapshot taken to be kept (`keep`: a mark the agent pinned) is never thinned, the newest
6
+ // `keepMax` of them (a pin in a loop must not fill the store). Pure: gc.ts ranks the calling World's own snapshots
7
+ // and asks which of them stay.
8
+ const HOUR = 3_600_000;
9
+ const DAY = 24 * HOUR;
10
+ /** The dirs of `snapshots` that stay at `nowMs`. */
11
+ export function thinned(snapshots, nowMs, rule) {
12
+ const hours = rule.hours ?? 24;
13
+ const days = rule.days ?? 30;
14
+ const newestFirst = [...snapshots].sort((a, b) => b.takenMs - a.takenMs || b.dir.localeCompare(a.dir));
15
+ const kept = new Set();
16
+ const pinned = new Set(newestFirst.filter((s) => s.keep).slice(0, rule.keepMax ?? 20).map((s) => s.dir));
17
+ newestFirst.forEach((s, i) => { if (pinned.has(s.dir) || i < rule.retain)
18
+ kept.add(s.dir); });
19
+ // each bucket's oldest (walked oldest first): by the calendar hour or UTC day it was taken in
20
+ const buckets = new Set();
21
+ for (const s of [...newestFirst].reverse()) {
22
+ const age = nowMs - s.takenMs;
23
+ const bucket = age < hours * HOUR ? `h${Math.floor(s.takenMs / HOUR)}` : age < hours * HOUR + days * DAY ? `d${Math.floor(s.takenMs / DAY)}` : null;
24
+ if (bucket && !buckets.has(bucket)) {
25
+ buckets.add(bucket);
26
+ kept.add(s.dir);
27
+ }
28
+ }
29
+ return kept;
30
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@volter/world-machine",
3
+ "version": "2.0.0",
4
+ "description": "The machine as a World service: a Firecracker microVM whose memory and disk snapshots are entries in the World's ledger, so `mark` and `branch --at` take the machine back exactly as they take a twin back.",
5
+ "keywords": [
6
+ "twin",
7
+ "world",
8
+ "firecracker",
9
+ "microvm",
10
+ "snapshot"
11
+ ],
12
+ "author": "Volter (https://github.com/volter-ai)",
13
+ "license": "Apache-2.0",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "type": "module",
18
+ "bin": {
19
+ "world-machine": "dist/src/cli.js"
20
+ },
21
+ "files": [
22
+ "src",
23
+ "dist"
24
+ ],
25
+ "dependencies": {
26
+ "@volter/world-core": "2.0.0"
27
+ },
28
+ "scripts": {
29
+ "build": "node ../../scripts/publish/build.mjs",
30
+ "prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
31
+ "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
32
+ }
33
+ }