@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.
package/src/config.ts ADDED
@@ -0,0 +1,165 @@
1
+ // The machine's declaration: a JSON file the World service names with `--config`. Paths to the
2
+ // guest image, Firecracker and the store are paths ON THE MACHINE HOST (`~/` is that host's home);
3
+ // `provision` is a script beside the config on the World's host, shipped into the guest when the warm
4
+ // base is built.
5
+ import { readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, resolve } from 'node:path';
8
+
9
+ export type MachineHost = { kind: 'linux' } | { kind: 'lima'; instance: string; limaHome: string };
10
+
11
+ /** The firecracker backend: a microVM on KVM, snapshotted by Firecracker (memory diffs, a reflinked disk). The
12
+ * container backend (ct.sh): the host's own processes in a container, dumped by CRIU with their tree in one reflinked
13
+ * image; for a host where a VM would be nested (a Mac: first-touch memory faulted through two hypervisors ran a real
14
+ * install 20–80x slower). */
15
+ export type MachineBackend = 'firecracker' | 'container';
16
+
17
+ export type MachineConfig = {
18
+ host: MachineHost;
19
+ backend: MachineBackend;
20
+ /** container: the OCI image the machine's tree is made from, and the tree's size (a sparse ext4 image). */
21
+ image: string;
22
+ imageSize: string;
23
+ /** container: host directories bound read-only into the container (binaries the machine runs that its image lacks). */
24
+ binds: Array<{ source: string; destination: string }>;
25
+ /** container: which of the machine's listening ports are published on the host's loopback at the same port, as the
26
+ * machine's own localhost (publish.py): a twin delivering to `http://localhost:<port>` reaches the app, and a Lima host
27
+ * forwards them on to the Mac. Only allowed ports: the machine runs code the user did not write, and a port it held
28
+ * first would be taken from a host service that binds it later. None unless set. */
29
+ publishPorts?: { ranges: Array<[number, number]>; except: number[] };
30
+ /** container: a path in the machine's tree served to `client` by the host's NFS server (v3), as `uid`:`gid`. */
31
+ export?: { path: string; client: string; uid: number; gid: number };
32
+ /** The reflink-capable directory (XFS or btrfs) holding warm bases, snapshots and the slot. */
33
+ store: string;
34
+ /** A mark is refused unless the store, the file backing it and each of `paths` (machine-host paths on the
35
+ * filesystems beneath it, such as a Mac's home mounted in its Lima VM) have its estimate plus `reserveBytes` free. */
36
+ room: { paths: string[]; reserveBytes: number };
37
+ /** How many of this World's snapshots the store keeps beyond every branch's head, the newest by when the host took
38
+ * them; older ones are let go by gc (and named in the store's expired.list, so a restore of one says it was let go).
39
+ * Another World on the store keeps its own. Every snapshot kept while a ledger names it when unset. */
40
+ retain?: number;
41
+ /** With `retain`: past the newest `retain`, the newest snapshot of each hour for a day and of each day for a month
42
+ * stay too (thin.ts), and a snapshot taken with `keep` is never let go. */
43
+ thin?: boolean;
44
+ /** The one machine this service runs in place; its tap, addresses and disk path are fixed. */
45
+ slot: string;
46
+ firecracker: string;
47
+ snapshotEditor: string;
48
+ kernel: string;
49
+ rootfs: string;
50
+ /** The private key whose public half the rootfs authorizes for root. */
51
+ sshKey: string;
52
+ /** Absolute path of the provisioning script on the World's host, or undefined for none. */
53
+ provision?: string;
54
+ vcpus: number;
55
+ memMib: number;
56
+ tap: string;
57
+ hostIp: string;
58
+ guestIp: string;
59
+ /** A fixed guest-visible port per vendor, overriding the derived one. */
60
+ egressPorts: Record<string, number>;
61
+ /** Where the machine host reaches a twin listening on the World host's loopback. */
62
+ twinHost: string;
63
+ };
64
+
65
+ const FIELDS = ['host', 'backend', 'image', 'imageSize', 'binds', 'export', 'publishPorts', 'store', 'room', 'retain', 'thin', 'slot', 'firecracker', 'snapshotEditor', 'kernel', 'rootfs', 'sshKey', 'provision', 'vcpus', 'memMib', 'tap', 'hostIp', 'guestIp', 'egressPorts'];
66
+
67
+ export function loadConfig(path: string): MachineConfig {
68
+ const file = resolve(path);
69
+ let raw: Record<string, unknown>;
70
+ try { raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>; } catch (error) { throw new Error(`world-machine: cannot read the machine config ${file}: ${(error as Error).message}`); }
71
+ for (const key of Object.keys(raw)) if (!FIELDS.includes(key) && !key.startsWith('//')) throw new Error(`world-machine: unknown field "${key}" in ${file} (known: ${FIELDS.join(', ')})`);
72
+ const str = (key: string, fallback?: string): string => {
73
+ const v = raw[key] ?? fallback;
74
+ if (typeof v !== 'string' || v === '') throw new Error(`world-machine: "${key}" must be a non-empty string in ${file}`);
75
+ return v;
76
+ };
77
+ const num = (key: string, fallback: number): number => {
78
+ const v = raw[key] ?? fallback;
79
+ if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) throw new Error(`world-machine: "${key}" must be a positive integer in ${file}`);
80
+ return v;
81
+ };
82
+ const hostRaw = (raw.host ?? (process.platform === 'darwin' ? undefined : { kind: 'linux' })) as Record<string, unknown> | undefined;
83
+ let host: MachineHost;
84
+ if (hostRaw?.kind === 'linux') host = { kind: 'linux' };
85
+ else if (hostRaw?.kind === 'lima' && typeof hostRaw.instance === 'string') {
86
+ const home = typeof hostRaw.limaHome === 'string' ? hostRaw.limaHome : process.env.LIMA_HOME ?? '~/.lima';
87
+ host = { kind: 'lima', instance: hostRaw.instance, limaHome: home.startsWith('~/') ? resolve(homedir(), home.slice(2)) : resolve(dirname(file), home) };
88
+ } else throw new Error(`world-machine: "host" must be {"kind":"linux"} or {"kind":"lima","instance":"<name>","limaHome":"<dir>"} in ${file} (on a Mac Firecracker runs inside a Lima >= 2 VM with nestedVirtualization: true)`);
89
+ const backend = str('backend', 'firecracker');
90
+ if (backend !== 'firecracker' && backend !== 'container') throw new Error(`world-machine: "backend" must be "firecracker" or "container" in ${file}`);
91
+ const vm = backend === 'firecracker';
92
+ const store = str('store', '~/.cache/volter-world-machine');
93
+ const egressPorts = (raw.egressPorts ?? {}) as Record<string, number>;
94
+ if (typeof egressPorts !== 'object' || Object.values(egressPorts).some((p) => !Number.isInteger(p))) throw new Error(`world-machine: "egressPorts" must map a vendor to a port in ${file}`);
95
+ const slot = str('slot', 'default');
96
+ if (!/^[a-z0-9-]+$/.test(slot)) throw new Error(`world-machine: "slot" must be lowercase letters, digits and dashes in ${file}`);
97
+ return {
98
+ host,
99
+ backend,
100
+ image: vm ? '' : str('image'),
101
+ imageSize: vm ? '' : str('imageSize', '40G'),
102
+ binds: vm ? [] : binds(raw.binds, file),
103
+ ...(raw.publishPorts === undefined ? {} : vm ? (() => { throw new Error(`world-machine: "publishPorts" is for the container backend only, in ${file}`); })() : { publishPorts: publishOf(raw.publishPorts, file) }),
104
+ ...(vm || raw.export === undefined ? {} : { export: exportOf(raw.export, file) }),
105
+ store,
106
+ room: roomOf(raw.room, file),
107
+ ...(raw.retain === undefined ? {} : { retain: num('retain', 1) }),
108
+ ...(raw.thin === undefined ? {} : typeof raw.thin !== 'boolean' ? (() => { throw new Error(`world-machine: "thin" must be true or false in ${file}`); })()
109
+ : raw.thin && raw.retain === undefined ? (() => { throw new Error(`world-machine: "thin" thins what "retain" lets go, and needs it, in ${file}`); })() : { thin: raw.thin }),
110
+ slot,
111
+ firecracker: str('firecracker', `${store}/bin/firecracker`),
112
+ snapshotEditor: str('snapshotEditor', `${store}/bin/snapshot-editor`),
113
+ kernel: vm ? str('kernel') : '',
114
+ rootfs: vm ? str('rootfs') : '',
115
+ sshKey: vm ? str('sshKey') : '',
116
+ ...(raw.provision === undefined ? {} : { provision: resolve(dirname(file), str('provision')) }),
117
+ vcpus: num('vcpus', 2),
118
+ memMib: num('memMib', 1024),
119
+ tap: str('tap', 'wm0'),
120
+ hostIp: str('hostIp', '172.16.0.1'),
121
+ guestIp: str('guestIp', '172.16.0.2'),
122
+ egressPorts,
123
+ twinHost: host.kind === 'lima' ? 'host.lima.internal' : '127.0.0.1',
124
+ };
125
+ }
126
+
127
+ function binds(raw: unknown, file: string): Array<{ source: string; destination: string }> {
128
+ if (raw === undefined) return [];
129
+ if (!Array.isArray(raw) || raw.some((b) => typeof b?.source !== 'string' || typeof b?.destination !== 'string' || !b.destination.startsWith('/'))) {
130
+ throw new Error(`world-machine: "binds" must be [{"source":"<host dir>","destination":"/<path in the machine>"}] in ${file}`);
131
+ }
132
+ return raw as Array<{ source: string; destination: string }>;
133
+ }
134
+
135
+ function exportOf(raw: unknown, file: string): { path: string; client: string; uid: number; gid: number } {
136
+ const e = raw as Record<string, unknown>;
137
+ if (typeof e?.path !== 'string' || !e.path.startsWith('/') || typeof e.client !== 'string' || !Number.isInteger(e.uid) || !Number.isInteger(e.gid)) {
138
+ throw new Error(`world-machine: "export" must be {"path":"/<path in the machine>","client":"<address>","uid":<n>,"gid":<n>} in ${file}`);
139
+ }
140
+ return { path: e.path, client: e.client, uid: e.uid as number, gid: e.gid as number };
141
+ }
142
+
143
+ function roomOf(raw: unknown, file: string): { paths: string[]; reserveBytes: number } {
144
+ const r = (raw ?? {}) as Record<string, unknown>;
145
+ const paths = r.paths ?? [];
146
+ const reserveBytes = r.reserveBytes ?? 2 * 2 ** 30;
147
+ if (typeof r !== 'object' || !Array.isArray(paths) || paths.some((p) => typeof p !== 'string' || p === '') || !Number.isInteger(reserveBytes) || (reserveBytes as number) < 0) {
148
+ throw new Error(`world-machine: "room" must be {"paths":["<machine-host path>", ...],"reserveBytes":<n>} in ${file}`);
149
+ }
150
+ return { paths: paths as string[], reserveBytes: reserveBytes as number };
151
+ }
152
+
153
+ function publishOf(raw: unknown, file: string): { ranges: Array<[number, number]>; except: number[] } {
154
+ const r = raw as Record<string, unknown>;
155
+ const port = (n: unknown): boolean => Number.isInteger(n) && (n as number) >= 1024 && (n as number) <= 65535;
156
+ const ranges = r?.ranges; const except = r?.except ?? [];
157
+ if (!Array.isArray(ranges) || ranges.some((x) => !Array.isArray(x) || x.length !== 2 || !port(x[0]) || !port(x[1]) || x[0] > x[1]) || !Array.isArray(except) || except.some((n) => !port(n))) {
158
+ throw new Error(`world-machine: "publishPorts" must be {"ranges":[[from,to],...],"except":[port,...]} with ports 1024-65535 in ${file}`);
159
+ }
160
+ // the kernel hands out ephemeral ports from 32768 (Linux's default range): the World's own services take theirs there
161
+ if ((ranges as Array<[number, number]>).some(([lo, hi]) => hi >= 32768 && lo <= 60999)) {
162
+ throw new Error(`world-machine: "publishPorts" ranges must stay below 32768 (the ephemeral ports the World's services use) in ${file}`);
163
+ }
164
+ return { ranges: ranges as Array<[number, number]>, except: except as number[] };
165
+ }
@@ -0,0 +1,411 @@
1
+ // The container backend (config `backend: "container"`, host functions in ct.sh): the machine is the host's own
2
+ // processes in a container run by crun, marked by CRIU with their tree in one reflinked ext4 image. It is for a host
3
+ // where a microVM would be nested (a Mac): there first-touch memory faults through two hypervisors, and a real
4
+ // project's install ran 20–80x slower than one level up (Volter Harness container/retake/README.md). The doors are the
5
+ // Firecracker backend's: the same guest address on the same bridge name, the same egress forwarders, the same
6
+ // ledger entries. What differs:
7
+ // the clock the container reads the host's kernel clock, which is now; a World whose clock is frozen is not
8
+ // followed (reported in the restore's facts)
9
+ // a mark a full CRIU dump of the processes and a reflink of the tree (no memory diff: arm64 has no
10
+ // soft-dirty tracking), recorded as a Full entry; its pages composed against the parent's
11
+ // afterwards (compose.py), so a compressing store keeps only the new ones
12
+ // stale sockets reset from the host in the container's network namespace (ss -K), as the guest agent resets them
13
+ import { createHash, randomBytes } from 'node:crypto';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { posix } from 'node:path';
16
+ import type { MachineConfig } from './config.ts';
17
+ import { GUEST_PROFILE, type HostSession, hostNowMs, q } from './host.ts';
18
+ import { appendSnapshot, headAt, headSnapshot, type SnapshotRecord, worldInstant } from './ledger.ts';
19
+ import { Machine } from './machine.ts';
20
+
21
+ /** The host functions a base is made by: the tree's fill (with the init it installs), then the provisioning run, the
22
+ * init's start and the bundle every process in it runs under. The rest (marks, restores, the export, publishing) act on
23
+ * a base without shaping it. Hashing all of ct.sh rebuilt the base at every change to the host functions (49 s
24
+ * measured). */
25
+ const FILL_FUNCTIONS = ['wm_ct_base_fill'];
26
+ // the base's dump too: its CRIU options (wm_ct_mark) and the namespace its sockets live in (wm_ct_net), which a restore
27
+ // must match
28
+ const BASE_FUNCTIONS = ['wm_ct_provision', 'wm_ct_start', 'wm_ct_bundle', 'wm_ct_net', 'wm_ct_mark'];
29
+
30
+ const sha = (inputs: unknown): string => createHash('sha256').update(JSON.stringify(inputs)).digest('hex').slice(0, 16);
31
+
32
+ /** A container base's identity, and its filled tree's (`tree-<hash>`, kept in the store: a base rebuilt for anything
33
+ * but its image or fill starts from a reflink of it instead of filling again, the 8-25 s of a rebuild). The tree is
34
+ * the image (by its id on this host: a tag rebuilt or pulled anew is a new image), its size, the fill and the init it
35
+ * installs, and the guest's address (its /etc/hosts); the base adds the provisioning, the host functions that make it,
36
+ * the directories bound into it and the slot. Functions as bash prints them, so a comment is no input. Refused when the
37
+ * image has no id here (podman failing, or the image removed): a hash without it names no base that exists, and a
38
+ * collection working from it would delete the one that does. */
39
+ export async function containerHashes(config: MachineConfig, session: HostSession): Promise<{ base: string; tree: string }> {
40
+ const json = (names: string[], extra = ''): string => `"$({ declare -f ${names.join(' ')}; ${extra} } | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`;
41
+ const r = await session.call<{ imageId: string; fill: string; functions: string }>(
42
+ `printf '@{"imageId":"%s","fill":%s,"functions":%s}\n' "$(sudo -n podman image inspect --format '{{.Id}}' ${q(config.image)} 2>/dev/null)" ${json(FILL_FUNCTIONS, `printf '%s' "$WM_CT_INIT";`)} ${json(BASE_FUNCTIONS)}`);
43
+ if (!/^[0-9a-f]{64}$/.test(r.imageId)) throw new Error(`world-machine: the image ${config.image} has no id on the machine host (podman image inspect gave ${JSON.stringify(r.imageId)}): build or pull it`);
44
+ const tree = sha({ image: config.image, imageId: r.imageId, imageSize: config.imageSize, fill: r.fill, hostIp: config.hostIp, guestIp: config.guestIp });
45
+ const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
46
+ const base = sha({ backend: 'container', tree, provision, functions: r.functions, binds: config.binds, slot: config.slot, tap: config.tap, hostIp: config.hostIp, guestIp: config.guestIp });
47
+ return { base, tree };
48
+ }
49
+ export const containerBaseHash = async (config: MachineConfig, session: HostSession): Promise<string> => (await containerHashes(config, session)).base;
50
+
51
+ export type ContainerBaseStep = { base: string; stage: string; ready: boolean; detail?: string };
52
+
53
+ /** One step of building the container base, advancing as far as it can without waiting (the Firecracker base's
54
+ * contract: run it again until ready). The base is a mark like any other: the tree and the freshly started init with
55
+ * its services. */
56
+ export async function stepContainerBase(config: MachineConfig, session: HostSession): Promise<ContainerBaseStep> {
57
+ await session.call('wm_reflink_check');
58
+ // an image from a registry is pulled first (in the background): the base is named by the image's id on this host
59
+ if (!config.image.startsWith('localhost/') && !(await session.call<{ ok: boolean }>(`if sudo -n podman image exists ${q(config.image)}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
60
+ const P = `"$WM_STORE/.pull-$WM_SLOT"`;
61
+ if (!(await session.call<{ ok: boolean }>(`if kill -0 "$(cat ${P}.pid 2>/dev/null)" 2>/dev/null; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
62
+ if ((await session.call<{ ok: boolean }>(`if [ -f ${P}.log ]; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok) {
63
+ const log = await session.call<{ log: string }>(`printf '@{"log":%s}\n' "$(tail -c 400 ${P}.log | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"; rm -f ${P}.log`);
64
+ throw new Error(`world-machine base: pulling ${config.image} failed:\n${log.log}`);
65
+ }
66
+ await session.call(`{ wm_closefds; sudo -n podman pull -q ${q(config.image)} && rm -f ${P}.log; } </dev/null >${P}.log 2>&1 9>&- &\necho $! > ${P}.pid; echo '@{"ok":true}'`);
67
+ }
68
+ return { base: '', stage: `pulling ${config.image}`, ready: false, detail: 'run again' };
69
+ }
70
+ const hashes = await containerHashes(config, session);
71
+ const base = `base-${hashes.base}`;
72
+ const T = `"$WM_STORE/tree-${hashes.tree}"`;
73
+ const B = `"$WM_STORE/${base}"`;
74
+ const W = `"$WM_STORE/${base}.building"`;
75
+ const has = async (test: string): Promise<boolean> => (await session.call<{ ok: boolean }>(`if ${test}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok;
76
+ if (await has(`[ -f ${B}/criu/inventory.img ]`)) return { base, stage: 'ready', ready: true };
77
+
78
+ // 1. the tree: a reflink of the one filled from the same image before, else made from the image in the background
79
+ // (an export takes 8-25 s)
80
+ if (!(await has(`[ -f ${W}/filled ]`)) && (await has(`[ -f ${T}/root.img ] && [ ! -f ${W}/filling ]`))) {
81
+ await session.call(`for d in "$WM_STORE"/base-*.building; do [ -d "$d" ] && [ "$d" != ${W} ] || continue; s=$(cat "$d/slot" 2>/dev/null || echo "$WM_SLOT"); [ "$s" = "$WM_SLOT" ] && sudo -n rm -rf "$d"; done
82
+ # a tree another store's collection deleted meanwhile is filled again below
83
+ mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && printf '%s' tree-${hashes.tree} > ${W}/tree && { cp --reflink=always ${T}/root.img ${W}/root.img && : > ${W}/filled || rm -f ${W}/root.img; }; echo '@{"ok":true}'`);
84
+ }
85
+ if (!(await has(`[ -f ${W}/filled ]`))) {
86
+ if (await has(`[ -f ${W}/filling ] && kill -0 "$(cat ${W}/fill.pid 2>/dev/null)" 2>/dev/null`)) return { base, stage: `filling the tree from ${config.image}`, ready: false, detail: 'run again' };
87
+ if (await has(`[ -f ${W}/filling ]`)) {
88
+ const log = await session.call<{ log: string }>(`printf '@{"log":%s}\\n' "$(tail -c 400 ${W}/fill.log 2>/dev/null | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`);
89
+ throw new Error(`world-machine base: filling the tree from ${config.image} failed:\n${log.log}\n(remove ${config.store}/${base}.building to start over)`);
90
+ }
91
+ await session.call(`for d in "$WM_STORE"/base-*.building; do [ -d "$d" ] && [ "$d" != ${W} ] || continue; s=$(cat "$d/slot" 2>/dev/null || echo "$WM_SLOT"); [ "$s" = "$WM_SLOT" ] && sudo -n rm -rf "$d"; done
92
+ mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && printf '%s' tree-${hashes.tree} > ${W}/tree && : > ${W}/filling && { wm_closefds; wm_ct_base_fill ${q(config.image)} ${q(config.imageSize)} ${W} > ${W}/fill.log 2>&1 && : > ${W}/filled; } </dev/null >/dev/null 2>&1 9>&- &
93
+ echo $! > ${W}/fill.pid; echo '@{"ok":true}'`);
94
+ return { base, stage: `filling the tree from ${config.image}`, ready: false };
95
+ }
96
+
97
+ // 2. the provisioning, run once in the tree placed in the slot; the tree as filled is kept first, for the next base
98
+ // made from it
99
+ if (!(await has(`[ -f ${W}/provisioned ]`))) {
100
+ const P = `${T}.part-"$WM_SLOT"`;
101
+ await session.call(`[ -f ${T}/root.img ] || { rm -rf ${P} && mkdir -p ${P} && cp --reflink=always ${W}/root.img ${P}/root.img && mv -T ${P} ${T}; } || rm -rf ${P}; echo '@{"ok":true}'`);
102
+ const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
103
+ const r = await session.call<{ code: number; log: string }>([
104
+ 'wm_ct_stop; wm_ct_umount || return 1',
105
+ // the bundle names the slot's network namespace, which the host's boot does not make
106
+ 'wm_ct_net >/dev/null || return 1',
107
+ `cp --reflink=always ${W}/root.img "$WM_ROOTIMG" || { wm_fail "cannot place the tree in the slot"; return 1; }`,
108
+ 'wm_ct_mount || return 1',
109
+ `cat > "$WM_SLOTDIR/provision.sh" <<'__WM_PROVISION__'\n${provision || 'true'}\n__WM_PROVISION__`,
110
+ 'wm_ct_provision "$WM_SLOTDIR/provision.sh"',
111
+ ].join('\n'));
112
+ if (r.code !== 0) throw new Error(`world-machine base: the provisioning script exited ${r.code}:\n${r.log}\n(remove ${config.store}/${base}.building to start over)`);
113
+ await session.call(`: > ${W}/provisioned; echo '@{"ok":true}'`);
114
+ return { base, stage: 'provisioned', ready: false, detail: 'run again' };
115
+ }
116
+
117
+ // 3. the init started with its services, marked, and the base put in place
118
+ const r = await session.call<{ pauseMs: number; memBytes: number }>([
119
+ 'wm_ct_net >/dev/null || return 1',
120
+ 'wm_ct_mount || return 1',
121
+ 'wm_ct_start >/dev/null || return 1',
122
+ `wm_ct_mark ${W} || return 1`,
123
+ // W/tree: the tree it was made from, kept by gc while this base (or its build) is
124
+ `wm_ct_stop; rm -f ${W}/filling ${W}/filled ${W}/provisioned ${W}/fill.pid ${W}/fill.log; printf '%s' tree-${hashes.tree} > ${W}/tree; mv ${W} ${B}`,
125
+ ].join('\n'));
126
+ return { base, stage: 'ready', ready: true, detail: `marked in ${r.pauseMs} ms, ${Math.round((r.memBytes ?? 0) / 1e6)} MB of processes` };
127
+ }
128
+
129
+ export class ContainerMachine extends Machine {
130
+ /** What going back to the snapshot at `position` would change in the machine, read only (ct.sh wm_ct_compare): the
131
+ * files under `path` (the config's export by default: the project a harness works in) and the processes. */
132
+ async compare(position: number, path?: string): Promise<Record<string, unknown>> {
133
+ const snap = headAt(this.root, position);
134
+ if (!snap) throw new Error(`no snapshot at machine position ${position}`);
135
+ // a relative path is the exported project's, as POST /machine/files reads it
136
+ const base = this.config.export?.path;
137
+ const where = path === undefined ? base : path.startsWith('/') ? path : base ? `${base}/${path}` : undefined;
138
+ if (!where) throw new Error(path === undefined ? 'compare needs a path (the machine exports none)' : `compare: ${path} is relative and the machine exports no project to resolve it against`);
139
+ return this.session.call<Record<string, unknown>>(`[ -d "$WM_STORE/${snap.dir}" ] || { wm_fail "the snapshot ${snap.id} is no longer kept"; return 1; }; wm_ct_compare "$WM_STORE/${snap.dir}" ${q(where)}${path === undefined ? '' : ' exact'}`);
140
+ }
141
+
142
+ /** A command run in the running machine (world-machine POST /machine/run; ct.sh wm_ct_run): as `user`, a name the
143
+ * machine's passwd resolves, or its root; bounded by `timeoutMs` (at most 300 s). `{code, stdout, stderr}`, tails. */
144
+ async run(argv: string[], user?: string, timeoutMs = 60_000): Promise<Record<string, unknown>> {
145
+ await this.refresh();
146
+ if (this.state !== 'running') throw Object.assign(new Error(`refused: cannot run a command: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
147
+ const secs = Math.max(1, Math.min(300, Math.ceil(timeoutMs / 1000)));
148
+ return this.session.call<Record<string, unknown>>(`wm_ct_run ${q(user ?? '')} ${secs} ${q(JSON.stringify(argv))}`);
149
+ }
150
+
151
+ /** Part of the machine's disk back at `position` (world-machine POST /machine/files), the machine running on: each
152
+ * path, absolute in the machine or relative to its exported project, copied from the mark's image into the live
153
+ * tree (ct.sh wm_ct_files). A partial retake's files: no restore, no branch; the next mark records the result. */
154
+ async restoreFiles(position: number, paths: string[], { noLinks = false, dryRun = false } = {}): Promise<Record<string, unknown>> {
155
+ const snap = headAt(this.root, position);
156
+ if (!snap) throw Object.assign(new Error(`no snapshot at machine position ${position}`), { status: 409 });
157
+ await this.refresh();
158
+ if (this.state !== 'running') throw Object.assign(new Error(`cannot restore files: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
159
+ const base = this.config.export?.path;
160
+ const rels = paths.map((p) => {
161
+ if (!p.startsWith('/') && !base) throw Object.assign(new Error(`refused: files: ${p} is relative and the machine exports no project to resolve it against`), { status: 400 });
162
+ const abs = posix.normalize(p.startsWith('/') ? p : `${base}/${p}`);
163
+ // a relative path stays inside the exported project
164
+ if (!p.startsWith('/') && abs !== base && !abs.startsWith(`${base}/`)) throw Object.assign(new Error(`refused: files: ${p} climbs out of ${base}`), { status: 400 });
165
+ return abs.replace(/^\/+|\/+$/g, '');
166
+ });
167
+ return this.session.call<Record<string, unknown>>(`[ -d "$WM_STORE/${snap.dir}" ] || { wm_fail "the snapshot ${snap.id} is no longer kept"; return 1; }; wm_ct_files "$WM_STORE/${snap.dir}" ${q(JSON.stringify(rels))}${noLinks ? ' nolinks' : ' -'}${dryRun ? ' dry' : ''}`);
168
+ }
169
+
170
+ protected override baseDir(): string { return `$WM_STORE/base-${this.base}`; }
171
+
172
+ /** This configuration's base. A machine with a head restores that and reads no base, so a hash that cannot be made
173
+ * (the image has no id on the host) is no failure there: the name stays what it was. */
174
+ private async baseName(): Promise<string | undefined> {
175
+ try { return await containerBaseHash(this.config, this.session); } catch (error) {
176
+ if (!headSnapshot(this.root)) throw error;
177
+ return this.base;
178
+ }
179
+ }
180
+ /** A machine with a head needs its base's name only for the records its marks write: learned after the restore
181
+ * (queued behind it on the session), never before it. */
182
+ private basePending: Promise<void> | undefined;
183
+ private learnBase(): void {
184
+ if (this.base || this.basePending) return;
185
+ this.basePending = this.baseName().then((b) => { this.base = b ?? this.base; }, () => undefined).finally(() => { this.basePending = undefined; });
186
+ }
187
+
188
+ override async ensure(): Promise<Record<string, unknown> | null> {
189
+ if (this.state === 'running' || this.state === 'displaced') return null;
190
+ try {
191
+ // the store checked (and its loop put on direct I/O: a host boot mounts it without) before any restore
192
+ if (headSnapshot(this.root)) { await this.session.call('wm_reflink_check'); return await this.restore(); }
193
+ this.base = await this.baseName();
194
+ const { ready } = await this.session.call<{ ready: boolean }>(`wm_reflink_check >/dev/null || return 1\n[ -f "${this.baseDir()}/criu/inventory.img" ] && echo '@{"ready":true}' || echo '@{"ready":false}'`);
195
+ if (!headSnapshot(this.root) && !ready) {
196
+ this.state = 'no-base';
197
+ this.message = `no container base base-${this.base} in the store; build it with \`world-machine base --config <file>\` (rerun until it prints ready)`;
198
+ return null;
199
+ }
200
+ return await this.restore();
201
+ } catch (error) {
202
+ this.state = 'error';
203
+ this.message = (error as Error).message;
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /** A retake's first half, run by the machine being left while the World branches: the mark a branch forked at
209
+ * `position` of this machine's ledger will have as its head, restored into the slot now and paused at once, its way
210
+ * out closed, the slot marked as handed over to that snapshot. The branch's machine then adopts it (restore) instead
211
+ * of restoring it again, and this one, when the World stops it, leaves the container alone (stop). While paused the
212
+ * machine runs nothing, and until adopted it reaches no twin, so it cannot write into the World being left. */
213
+ async prepare(position: number, live = false): Promise<Record<string, unknown>> {
214
+ const snap = headAt(this.root, position);
215
+ if (!snap) throw Object.assign(new Error(`no snapshot at machine@${position} of this ledger`), { status: 409 });
216
+ if (live) return this.prepareLive(snap, position);
217
+ const t0 = hostNowMs();
218
+ const restoreId = randomBytes(8).toString('hex');
219
+ // One locked op: the slot must still be this machine's; then its way out is closed and the mark swapped in. A
220
+ // failure once the swap began (wm_ct_restore stops this machine first) leaves the container stopped and its way
221
+ // out closed, never the mark running into this World's twins; the error says the slot was swapped. The slot's owner
222
+ // is then no machine's, only the handover (a status read here must not take it back, and this machine's stop leaves
223
+ // it alone).
224
+ const handover = { handover: snap.dir, restoreId, from: this.env.VOLTER_WORLD_NAME ?? null, position };
225
+ let r: Record<string, number>;
226
+ try {
227
+ r = await this.session.call<Record<string, number>>([
228
+ `[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ] || { wm_fail "cannot prepare: the slot is not this machine's"; return 1; }`,
229
+ // held against a collection from here until the handover names it (gc reads both), before its presence is checked
230
+ `printf '%s' "$WM_STORE/${snap.dir}" > "$WM_SLOTDIR/preparing"`,
231
+ `[ -d "$WM_STORE/${snap.dir}" ] || { rm -f "$WM_SLOTDIR/preparing"; if grep -qx ${q(snap.dir.replace(/^snapshots\//, ''))} "$WM_STORE/expired.list" 2>/dev/null; then wm_fail "cannot prepare: the snapshot ${snap.id} was let go (past retain)"; else wm_fail "cannot prepare: the snapshot ${snap.id} is missing from the store"; fi; return 1; }`,
232
+ 'wm_ct_net >/dev/null || { rm -f "$WM_SLOTDIR/preparing"; return 1; }',
233
+ 'wm_egress >/dev/null',
234
+ `wm_ct_restore "$WM_STORE/${snap.dir}" ${q(restoreId)} || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped): $(tail -2 "$WM_SLOTDIR/restore.out" 2>/dev/null)"; return 1; }`,
235
+ 'sudo -n crun pause "$WM_CT" || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped): cannot pause it"; return 1; }',
236
+ `wm_own ${q(JSON.stringify(handover))} || { rm -f "$WM_SLOTDIR/preparing"; wm_ct_stop; wm_fail "prepare failed after the slot was swapped (the machine is stopped)"; return 1; }`,
237
+ 'rm -f "$WM_SLOTDIR/preparing"',
238
+ ].join('\n'));
239
+ } catch (error) {
240
+ const swapped = /after the slot was swapped/.test((error as Error).message);
241
+ if (swapped) { this.state = 'error'; this.message = (error as Error).message; }
242
+ // before the swap, nothing changed but the closed way out, put back while the slot is still this machine's
243
+ else 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_egress ${this.forwarders()} >/dev/null; fi; echo '@{"egress":true}'`).catch(() => undefined);
244
+ throw Object.assign(error as Error, { status: 409, swapped });
245
+ }
246
+ this.state = 'displaced';
247
+ this.message = `handed over: the slot holds ${snap.id} for the branch that forks at machine@${position}`;
248
+ return { id: snap.id, dir: snap.dir, loadMs: r.loadMs, restoreMs: r.restoreMs, totalMs: Math.round(hostNowMs() - t0) };
249
+ }
250
+
251
+ /** A handover with no restore: the running machine itself handed to the branch that forks at `position`, which must
252
+ * be this ledger's head (a mark just taken). A partial retake uses it: the twins go back, the machine stays. It is not
253
+ * paused (the harness's connection into it would go silent): its way out is closed and its connections through the
254
+ * World's forwarders reset, so from here it reaches no twin of the World being left, while its processes and every
255
+ * other connection run on. The branch adopts it as it adopts a prepared one, the connections into it kept; a new
256
+ * restore id tells a program whose twin connection was reset that the World moved. */
257
+ private async prepareLive(snap: SnapshotRecord, position: number): Promise<Record<string, unknown>> {
258
+ const head = headSnapshot(this.root);
259
+ if (head?.dir !== snap.dir) throw Object.assign(new Error(`cannot hand over the live machine: machine@${position} (${snap.id}) is not this ledger's head (${head?.id ?? 'none'})`), { status: 409 });
260
+ await this.refresh();
261
+ if (this.state !== 'running') throw Object.assign(new Error(`cannot hand over the live machine: it is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
262
+ const t0 = hostNowMs();
263
+ const handover = { handover: snap.dir, restoreId: randomBytes(8).toString('hex'), from: this.env.VOLTER_WORLD_NAME ?? null, position, live: true };
264
+ const spec = this.forwarders();
265
+ // One locked op: the slot still this machine's and running, its way out closed and those connections reset, the
266
+ // owner the handover. Nothing is restored or paused: a failure opens the way out again and the machine runs on.
267
+ try {
268
+ await this.session.call([
269
+ `[ "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("token",""))' "$WM_SLOTDIR/owner.json" 2>/dev/null)" = ${q(this.token)} ] || { wm_fail "cannot hand over: the slot is not this machine's"; return 1; }`,
270
+ `[ "$(wm_ct_state)" = running ] || { wm_fail "cannot hand over: the container is $(wm_ct_state)"; return 1; }`,
271
+ 'wm_egress >/dev/null',
272
+ `wm_ct_reset_egress ${spec} >/dev/null`,
273
+ `wm_own ${q(JSON.stringify(handover))} || { wm_fail "cannot hand over the slot"; return 1; }`,
274
+ `printf '%s' "$WM_STORE/${snap.dir}" > "$WM_SLOTDIR/ct-loaded"`,
275
+ `echo '@{"handedOver":true}'`,
276
+ ].join('\n'));
277
+ } catch (error) {
278
+ 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_egress ${spec} >/dev/null; fi; echo '@{"egress":true}'`).catch(() => undefined);
279
+ throw Object.assign(error as Error, { status: 409, swapped: false });
280
+ }
281
+ this.state = 'displaced';
282
+ this.message = `handed over live: the slot's running machine (at ${snap.id}) is the branch's that forks at machine@${position}; POST /machine/restore takes it back`;
283
+ return { id: snap.id, dir: snap.dir, live: true, totalMs: Math.round(hostNowMs() - t0) };
284
+ }
285
+
286
+ /** This branch's head, restored in place; with no head, the base. A slot a retake's prepare left holding this head,
287
+ * paused, is adopted instead: its way out pointed at this World's twins, the World's files written, the connections
288
+ * the restore left stale reset, and the processes resumed, with no second restore. */
289
+ override async restore(): Promise<Record<string, unknown>> {
290
+ const head = headSnapshot(this.root);
291
+ if (head) this.learnBase();
292
+ else if (!this.base) this.base = await this.baseName();
293
+ if (head) {
294
+ // an adopt that fails part way leaves the slot to the whole restore below, in this same call
295
+ const adopted = await this.adopt(head).catch((error: Error) => { process.stderr.write(`world-machine: adopting the prepared slot failed (${error.message}); restoring\n`); return null; });
296
+ if (adopted) return adopted;
297
+ }
298
+ const dir = head ? `$WM_STORE/${head.dir}` : this.baseDir();
299
+ const clockFile = this.env.TWIN_WORLD_CLOCK_FILE;
300
+ const frozenClock = Boolean(clockFile && existsSync(clockFile));
301
+ const t0 = hostNowMs();
302
+ this.restoreId = randomBytes(8).toString('hex');
303
+ const r = await this.session.call<Record<string, number>>([
304
+ // a snapshot let go past retain is named before anything is stopped
305
+ `[ -d "${dir}" ] || { if grep -qx ${q((head?.dir ?? '').replace(/^snapshots\//, ''))} "$WM_STORE/expired.list" 2>/dev/null; then wm_fail "the snapshot ${head?.id ?? 'base'} was let go (past retain)"; else wm_fail "the snapshot ${head?.id ?? 'base'} is missing from the store"; fi; return 1; }`,
306
+ // what the collector keeps while this machine runs (a base another World's configuration does not name), from
307
+ // before the restore reads it
308
+ `printf '%s' "${dir}" > "$WM_SLOTDIR/ct-loaded"`,
309
+ 'wm_ct_net >/dev/null || return 1',
310
+ `wm_timed egress wm_egress ${this.forwarders()} || return 1`,
311
+ // the restore id is in the tree before the processes run: a restored connection is reset by its peer at once
312
+ `wm_ct_restore "${dir}" ${q(this.restoreId)} || return 1`,
313
+ `wm_own ${q(this.owner())}`, 'rm -f "$WM_SLOTDIR/preparing"',
314
+ // the World's env, certificates and redirect, and the login-shell profile that exports the env (the Firecracker base
315
+ // installs that once; a container's tree gets it with the rest)
316
+ `printf '%s' ${q(JSON.stringify({ ...this.guestFiles(), '/etc/profile.d/volter-world.sh': `${GUEST_PROFILE}\n` }))} | wm_ct_write >/dev/null || return 1`,
317
+ 'wm_ct_reset',
318
+ this.config.publishPorts ? `wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))}` : 'wm_ct_unpublish',
319
+ ].join('\n'));
320
+ this.lastUsed = this.session.call<{ used: number }>('wm_used').then((u) => u.used, () => Number.NaN);
321
+ this.loaded = head ? { id: head.id, label: head.label, dir: head.dir } : null;
322
+ this.state = 'running';
323
+ this.message = undefined;
324
+ return {
325
+ id: head?.id ?? null, label: head?.label ?? null, from: head ? 'head' : `base-${this.base}`, backend: 'container',
326
+ loadMs: r.loadMs, restoreMs: r.restoreMs, resetMs: r.resetMs, egressMs: r.egressMs,
327
+ ...(frozenClock ? { clock: 'the World\'s clock is frozen; the container reads the host\'s, which is now' } : {}),
328
+ totalMs: Math.round(hostNowMs() - t0),
329
+ };
330
+ }
331
+
332
+ private async adopt(head: SnapshotRecord): Promise<Record<string, unknown> | null> {
333
+ const slot = await this.session.call<{ container: string; owner: { handover?: string; restoreId?: string; live?: boolean } | null }>('wm_ct_running');
334
+ // a prepared slot is paused; a live handover's machine runs on (prepareLive)
335
+ const live = slot.owner?.live === true;
336
+ if (slot.container !== (live ? 'running' : 'paused') || slot.owner?.handover !== head.dir || !slot.owner.restoreId) return null;
337
+ const t0 = hostNowMs();
338
+ this.restoreId = slot.owner.restoreId;
339
+ const r = await this.session.call<Record<string, number>>([
340
+ `wm_timed egress wm_egress ${this.forwarders()} || return 1`,
341
+ `wm_own ${q(this.owner())}`, 'rm -f "$WM_SLOTDIR/preparing"',
342
+ `printf '%s' "$WM_STORE/${head.dir}" > "$WM_SLOTDIR/ct-loaded"`,
343
+ `printf '%s' ${q(JSON.stringify({ ...this.guestFiles(), '/etc/profile.d/volter-world.sh': `${GUEST_PROFILE}\n` }))} | wm_ct_write >/dev/null || return 1`,
344
+ // a live machine was never paused and its connections through the forwarders were reset at the handover: the
345
+ // connections into it (the harness's) are kept
346
+ live ? 'echo \'@{"resetMs":0}\'' : 'sudo -n crun resume "$WM_CT" || { wm_fail "cannot resume the adopted machine"; return 1; }',
347
+ live ? '' : 'wm_ct_reset',
348
+ this.config.publishPorts ? `wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))}` : 'wm_ct_unpublish',
349
+ ].join('\n'));
350
+ this.lastUsed = this.session.call<{ used: number }>('wm_used').then((u) => u.used, () => Number.NaN);
351
+ this.loaded = { id: head.id, label: head.label, dir: head.dir };
352
+ this.state = 'running';
353
+ this.message = undefined;
354
+ return { id: head.id, label: head.label, from: 'head', backend: 'container', adopted: true, ...(live ? { live: true } : {}), egressMs: r.egressMs, resetMs: r.resetMs, totalMs: Math.round(hostNowMs() - t0) };
355
+ }
356
+
357
+ override async refresh(): Promise<void> {
358
+ if (this.state !== 'running' && this.state !== 'displaced') return;
359
+ const r = await this.session.call<{ container: string; owner: { token?: string; world?: string; handover?: string } | null }>('wm_ct_running');
360
+ if (r.owner?.handover) { this.state = 'displaced'; this.message = `the slot is handed over to a branch (${r.owner.handover}); POST /machine/restore takes it back`; }
361
+ else if (r.owner?.token !== this.token) { this.state = 'displaced'; this.message = `the slot now runs ${r.owner?.world ?? 'nothing'}'s machine; POST /machine/restore takes it back`; }
362
+ else if (r.container !== 'running') { this.state = 'error'; this.message = `the container is ${r.container || 'gone'}`; }
363
+ // this machine's publisher started again if it died (idempotent while it runs): refresh runs at every mark and status read
364
+ else if (this.config.publishPorts) await this.session.call(`wm_ct_publish ${q(JSON.stringify(this.config.publishPorts))} >/dev/null 2>&1; echo '@{"published":true}'`);
365
+ }
366
+
367
+ override async checkpoint(label: string, keep = false): Promise<Record<string, unknown>> {
368
+ await this.refresh();
369
+ if (this.state !== 'running') throw Object.assign(new Error(`cannot checkpoint: the machine is ${this.state}${this.message ? ` (${this.message})` : ''}`), { status: 409 });
370
+ await this.room();
371
+ await this.basePending;
372
+ const id = `snap-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
373
+ const dir = `snapshots/${id}`;
374
+ const r = await this.session.call<Record<string, number>>([
375
+ `mkdir -p "$WM_STORE/${dir}" && : > "$WM_STORE/${dir}/.pending" || return 1`,
376
+ // the mark it descends from: the dump's pages are composed against its
377
+ `wm_ct_mark "$WM_STORE/${dir}" "${this.loaded ? `$WM_STORE/${this.loaded.dir}` : this.baseDir()}" || return 1`,
378
+ 'wm_used',
379
+ ].join('\n'));
380
+ const measured = await this.lastUsed; const before = Number.isNaN(measured) ? (r.used ?? 0) : measured;
381
+ const storeBytes = Math.max(0, (r.used ?? 0) - before);
382
+ this.lastUsed = Promise.resolve(r.used ?? before);
383
+ const record: SnapshotRecord = {
384
+ // no base named when it could not be learned (the image has no id on the host)
385
+ id, label, type: 'Full', parent: this.loaded?.id ?? `base-${this.base}`, base: this.base ? `base-${this.base}` : '', dir,
386
+ memBytes: r.memBytes ?? 0, diskBytes: Math.max(0, storeBytes - (r.memBytes ?? 0)), storeBytes,
387
+ pauseMs: r.pauseMs ?? 0, created: worldInstant(), ...(keep ? { keep: true } : {}),
388
+ };
389
+ await appendSnapshot(this.root, record);
390
+ await this.session.call(`rm -f "$WM_STORE/${dir}/.pending"; echo '@{"recorded":true}'`);
391
+ this.loaded = { id, label, dir };
392
+ return { id, pauseMs: r.pauseMs, treeMs: r.treeMs, dumpMs: r.dumpMs, memBytes: record.memBytes, diskBytes: record.diskBytes, storeBytes };
393
+ }
394
+
395
+ /** The container reads the host's kernel clock: there is no guest clock to be off. */
396
+ override async clock(): Promise<Record<string, unknown>> {
397
+ return { errorMs: 0, clock: 'the host kernel\'s (the container shares it)' };
398
+ }
399
+
400
+ override status(): Record<string, unknown> {
401
+ const s = super.status();
402
+ return { ...s, backend: 'container', guest: { ip: this.config.guestIp, container: `wm-${this.config.slot}` } };
403
+ }
404
+
405
+ override async stop(): Promise<void> {
406
+ try {
407
+ // a slot this machine handed over (prepare) holds no token of its own: it is the branch's to adopt, left as it is
408
+ 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_ct_unpublish; wm_ct_stop; wm_egress; wm_release; rm -f "$WM_SLOTDIR/ct-loaded"; fi; echo '@{"stopped":true}'`);
409
+ } finally { this.session.close(); }
410
+ }
411
+ }