@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/README.md +339 -0
- package/dist/src/agent.py +92 -0
- package/dist/src/agentc.py +81 -0
- package/dist/src/base.d.ts +9 -0
- package/dist/src/base.js +61 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +319 -0
- package/dist/src/compose.py +82 -0
- package/dist/src/config.d.ts +74 -0
- package/dist/src/config.js +120 -0
- package/dist/src/container.d.ts +73 -0
- package/dist/src/container.js +436 -0
- package/dist/src/ct.sh +675 -0
- package/dist/src/gc.d.ts +25 -0
- package/dist/src/gc.js +131 -0
- package/dist/src/host.d.ts +49 -0
- package/dist/src/host.js +163 -0
- package/dist/src/host.sh +302 -0
- package/dist/src/ledger.d.ts +25 -0
- package/dist/src/ledger.js +38 -0
- package/dist/src/machine.d.ts +82 -0
- package/dist/src/machine.js +367 -0
- package/dist/src/publish.py +134 -0
- package/dist/src/thin.d.ts +20 -0
- package/dist/src/thin.js +30 -0
- package/package.json +33 -0
- package/src/agent.py +92 -0
- package/src/agentc.py +81 -0
- package/src/base.ts +67 -0
- package/src/cli.ts +283 -0
- package/src/compose.py +82 -0
- package/src/config.ts +165 -0
- package/src/container.ts +411 -0
- package/src/ct.sh +675 -0
- package/src/gc.ts +129 -0
- package/src/guest-inject.test.ts +21 -0
- package/src/host.sh +302 -0
- package/src/host.ts +161 -0
- package/src/ledger.ts +61 -0
- package/src/machine.ts +314 -0
- package/src/publish.py +134 -0
- package/src/thin.test.ts +62 -0
- package/src/thin.ts +48 -0
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
|
+
}
|
package/src/container.ts
ADDED
|
@@ -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
|
+
}
|