@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/dist/src/cli.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// world-machine — the machine as a World `process` service (README.md).
|
|
3
|
+
//
|
|
4
|
+
// world-machine setup --config <file> [--make-store <size>] prepare the machine host (idempotent)
|
|
5
|
+
// world-machine base --config <file> advance the warm base build one step; rerun until ready
|
|
6
|
+
// world-machine gc --config <file> delete the snapshots and warm bases no World's branch names
|
|
7
|
+
// world-machine serve --config <file> --port <n> --root <dir> the service (the World passes --port and --root)
|
|
8
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { existsSync, linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { createServer } from 'node:http';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { stepBase } from "./base.js";
|
|
13
|
+
import { ContainerMachine, stepContainerBase } from "./container.js";
|
|
14
|
+
import { loadConfig } from "./config.js";
|
|
15
|
+
import { HostSession, q } from "./host.js";
|
|
16
|
+
import { hp, Machine } from "./machine.js";
|
|
17
|
+
import { collect, register } from "./gc.js";
|
|
18
|
+
// when this module's own code first runs: every import is done (the process started at performance.timeOrigin)
|
|
19
|
+
const importedAt = Math.round(performance.now());
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
22
|
+
const command = args[0];
|
|
23
|
+
const out = (value) => { process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`); };
|
|
24
|
+
function configFromArgs() {
|
|
25
|
+
const path = flag('--config');
|
|
26
|
+
if (!path)
|
|
27
|
+
throw new Error(`world-machine ${command}: --config <machine.json> is required`);
|
|
28
|
+
return loadConfig(path);
|
|
29
|
+
}
|
|
30
|
+
const FC_VERSION = 'v1.17.0';
|
|
31
|
+
/** The doors' key, a file beside the machine's configuration (serve). */
|
|
32
|
+
export const DOOR_TOKEN_FILE = '.world-machine-token';
|
|
33
|
+
async function setup(config) {
|
|
34
|
+
const session = new HostSession(config);
|
|
35
|
+
try {
|
|
36
|
+
const size = flag('--make-store');
|
|
37
|
+
const container = config.backend === 'container';
|
|
38
|
+
const lines = container ? [
|
|
39
|
+
// the container backend needs no KVM: crun and CRIU run the machine, podman fills its tree from the image
|
|
40
|
+
'for p in socat crun criu podman; do command -v $p >/dev/null || sudo -n apt-get install -y -qq $p >/dev/null || { wm_fail "$p is missing"; return 1; }; done',
|
|
41
|
+
'sudo -n criu check >/dev/null 2>&1 || { wm_fail "criu check fails on this kernel: $(sudo -n criu check 2>&1 | tail -1)"; return 1; }',
|
|
42
|
+
// CRIU refuses a listener with a connection the kernel accepted but the paused process has not (measured: a mark failed
|
|
43
|
+
// under requests to a published port); skipped, the client sees a reset and retries. crun reads CRIU's default config
|
|
44
|
+
"{ sudo -n mkdir -p /etc/criu && { grep -qx skip-in-flight /etc/criu/default.conf 2>/dev/null || echo skip-in-flight | sudo -n tee -a /etc/criu/default.conf >/dev/null; }; } || { wm_fail \"cannot write /etc/criu/default.conf\"; return 1; }",
|
|
45
|
+
// the tree's export: the host's NFS server, v3 only, mountd on the port the machine's forward names
|
|
46
|
+
...(config.export ? [
|
|
47
|
+
'command -v exportfs >/dev/null || sudo -n apt-get install -y -qq nfs-kernel-server >/dev/null || { wm_fail "nfs-kernel-server is missing"; return 1; }',
|
|
48
|
+
`printf '[mountd]\\nport=20048\\n[nfsd]\\nvers3=y\\nvers4=n\\nudp=n\\n' | sudo -n tee /etc/nfs.conf.d/world-machine.conf >/dev/null && sudo -n mkdir -p /etc/exports.d && sudo -n rm -f /etc/exports.d/world-machine-$WM_SLOT.exports && sudo -n systemctl restart nfs-server || { wm_fail "cannot configure the NFS server"; return 1; }`,
|
|
49
|
+
] : []),
|
|
50
|
+
] : [
|
|
51
|
+
// udev resets /dev/kvm on change events; a rule keeps it usable where a one-off chmod does not
|
|
52
|
+
`if [ ! -w /dev/kvm ] || [ ! -f /etc/udev/rules.d/99-world-machine-kvm.rules ]; then echo 'KERNEL=="kvm", MODE="0666"' | sudo -n tee /etc/udev/rules.d/99-world-machine-kvm.rules >/dev/null && sudo -n udevadm control --reload && sudo -n udevadm trigger --name-match=kvm && sudo -n udevadm settle; fi`,
|
|
53
|
+
'[ -w /dev/kvm ] || { wm_fail "/dev/kvm is not writable (no KVM here: on a Mac, a Lima >= 2 VM with nestedVirtualization: true)"; return 1; }',
|
|
54
|
+
'command -v socat >/dev/null || sudo -n apt-get install -y -qq socat >/dev/null || { wm_fail "socat is missing"; return 1; }',
|
|
55
|
+
];
|
|
56
|
+
if (size) {
|
|
57
|
+
// a loop-mounted image when the host's own filesystem cannot reflink; persisted in fstab. btrfs with zstd where the
|
|
58
|
+
// host has it: a mark's pages composed against its parent's (compose.py) keep only the new ones, compressed
|
|
59
|
+
// (measured: 25 MB for a 1.3 GB dump; 254 MB compressed alone). compress-force: with `compress` btrfs's heuristic
|
|
60
|
+
// left a mark's new pages uncompressed (687 MB), and it also compresses the tree's image (chattr +m does not stop
|
|
61
|
+
// it; a first compile measured 1.03-1.23x the Mac's). An XFS image made before is used as it is.
|
|
62
|
+
lines.push(
|
|
63
|
+
// only where the store cannot reflink already (a store on XFS or btrfs is used as it is)
|
|
64
|
+
'mkdir -p "$WM_STORE"; t="$WM_STORE/.reflink-probe"; : > "$t"; if cp --reflink=always "$t" "$t.2" 2>/dev/null; then cow=1; else cow=0; fi; rm -f "$t" "$t.2"', 'if [ "$cow" = 0 ] && ! mountpoint -q "$WM_STORE"; then', ` sudo -n mkdir -p /var/lib/volter-world-machine; old=/var/lib/volter-world-machine/store-$WM_SLOT.xfs; img=/var/lib/volter-world-machine/store-$WM_SLOT.btrfs`, ' if [ -f "$old" ]; then img=$old; fs=xfs; opts=loop,nofail',
|
|
65
|
+
// a btrfs store made before is never replaced: without btrfs here, setup fails rather than mount an empty XFS over it
|
|
66
|
+
' elif [ -f "$img" ]; then { command -v mkfs.btrfs >/dev/null || sudo -n apt-get install -y -qq btrfs-progs >/dev/null; } && sudo -n modprobe btrfs || { wm_fail "the store $img is btrfs and btrfs is not available here"; return 1; }; fs=btrfs; opts=loop,nofail,compress-force=zstd:1', ' elif { command -v mkfs.btrfs >/dev/null || sudo -n apt-get install -y -qq btrfs-progs >/dev/null; } && sudo -n modprobe btrfs 2>/dev/null; then fs=btrfs; opts=loop,nofail,compress-force=zstd:1', ' else img=$old; fs=xfs; opts=loop,nofail; command -v mkfs.xfs >/dev/null || sudo -n apt-get install -y -qq xfsprogs >/dev/null || { wm_fail "xfsprogs is missing"; return 1; }; fi', ` [ -f "$img" ] || { sudo -n truncate -s ${q(size)} "$img" && if [ "$fs" = btrfs ]; then sudo -n mkfs.btrfs -q "$img"; else sudo -n mkfs.xfs -q -m reflink=1 "$img"; fi; } || { wm_fail "cannot make $img"; return 1; }`, ' mkdir -p "$WM_STORE" && sudo -n mount -t "$fs" -o "$opts" "$img" "$WM_STORE" && sudo -n chown "$(id -u):$(id -g)" "$WM_STORE" || { wm_fail "cannot mount $img at $WM_STORE"; return 1; }', ' grep -q "$img" /etc/fstab || { echo "$img $WM_STORE $fs $opts 0 0" | sudo -n tee -a /etc/fstab >/dev/null && sudo -n systemctl daemon-reload; }', 'fi', 'mkdir -p "$WM_STORE/snapshots" "$WM_SLOTDIR"');
|
|
67
|
+
}
|
|
68
|
+
if (container)
|
|
69
|
+
lines.push('wm_reflink_check || return 1', 'wm_ct_net || return 1', 'printf \'@{"crun":"%s","criu":"%s","store":"%s"}\\n\' "$(crun --version | head -1)" "$(criu --version | head -1)" "$WM_STORE"');
|
|
70
|
+
else
|
|
71
|
+
lines.push('wm_reflink_check || return 1',
|
|
72
|
+
// Firecracker and its snapshot-editor, the pinned release, when the configured paths hold nothing
|
|
73
|
+
`if [ ! -x "$WM_FC" ] || [ ! -x "$WM_SE" ]; then`, ` a=$(uname -m); t=$(mktemp -d); curl -fsSL -o "$t/fc.tgz" https://github.com/firecracker-microvm/firecracker/releases/download/${FC_VERSION}/firecracker-${FC_VERSION}-$a.tgz && tar -xzf "$t/fc.tgz" -C "$t" || { wm_fail "cannot download Firecracker ${FC_VERSION}"; return 1; }`, ` mkdir -p "$(dirname "$WM_FC")" "$(dirname "$WM_SE")"; [ -x "$WM_FC" ] || install -m 755 "$t/release-${FC_VERSION}-$a/firecracker-${FC_VERSION}-$a" "$WM_FC"; [ -x "$WM_SE" ] || install -m 755 "$t/release-${FC_VERSION}-$a/snapshot-editor-${FC_VERSION}-$a" "$WM_SE"; rm -rf "$t"`, 'fi', `for f in ${hp(config.kernel)} ${hp(config.rootfs)} ${hp(config.sshKey)}; do [ -r "$f" ] || { wm_fail "the configured $f is not readable on the machine host"; return 1; }; done`, 'wm_net || return 1', 'printf \'@{"firecracker":"%s","store":"%s"}\\n\' "$("$WM_FC" --version | head -1)" "$WM_STORE"');
|
|
74
|
+
out(await session.call(lines.join('\n')));
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
session.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function base(config) {
|
|
81
|
+
const session = new HostSession(config);
|
|
82
|
+
try {
|
|
83
|
+
const step = config.backend === 'container' ? await stepContainerBase(config, session) : await stepBase(config, session);
|
|
84
|
+
out(step);
|
|
85
|
+
if (!step.ready)
|
|
86
|
+
process.exitCode = 3;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
session.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function gc(config) {
|
|
93
|
+
const session = new HostSession(config);
|
|
94
|
+
try {
|
|
95
|
+
out(await collect(config, session));
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
session.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function send(res, status, body) {
|
|
102
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
103
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
104
|
+
}
|
|
105
|
+
async function readJson(req) {
|
|
106
|
+
let text = '';
|
|
107
|
+
for await (const chunk of req)
|
|
108
|
+
text += chunk;
|
|
109
|
+
if (!text.trim())
|
|
110
|
+
return {};
|
|
111
|
+
const value = JSON.parse(text);
|
|
112
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
113
|
+
throw Object.assign(new Error('the body must be a JSON object'), { status: 400 });
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
async function serve(config) {
|
|
117
|
+
const port = Number(flag('--port') ?? process.env.PORT);
|
|
118
|
+
const root = flag('--root');
|
|
119
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
120
|
+
throw new Error('world-machine serve: --port (or PORT) is required');
|
|
121
|
+
if (!root)
|
|
122
|
+
throw new Error('world-machine serve: --root <dir> is required (the World passes the service data dir)');
|
|
123
|
+
const machine = config.backend === 'container' ? new ContainerMachine(config, root, process.env, port) : new Machine(config, root, process.env, port);
|
|
124
|
+
// The doors' key: every request names it (`authorization: Bearer <token>`). Loopback is not enough: the machine's own
|
|
125
|
+
// processes reach this host's loopback through the World's egress proxy, which forwards local destinations, and a
|
|
126
|
+
// door can restore, copy into and run commands in the machine. The key is a file beside this configuration, on this
|
|
127
|
+
// host only (the machine never mounts it), made once and shared by every branch's machine of the World; a caller
|
|
128
|
+
// (supercode-retake) reads it there.
|
|
129
|
+
const tokenFile = join(dirname(resolve(flag('--config'))), DOOR_TOKEN_FILE);
|
|
130
|
+
// written whole beside and linked into place (never an empty or half-written key another serve reads), and only
|
|
131
|
+
// served with a key of its full length
|
|
132
|
+
if (!existsSync(tokenFile)) {
|
|
133
|
+
const tmp = `${tokenFile}.${process.pid}.tmp`;
|
|
134
|
+
writeFileSync(tmp, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600 });
|
|
135
|
+
try {
|
|
136
|
+
linkSync(tmp, tokenFile);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code !== 'EEXIST')
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
unlinkSync(tmp);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const key = readFileSync(tokenFile, 'utf8').trim();
|
|
147
|
+
if (!/^[0-9a-f]{64}$/.test(key))
|
|
148
|
+
throw new Error(`world-machine serve: the door key ${tokenFile} is not a 64-hex-digit key; remove it and serve again`);
|
|
149
|
+
const doorToken = Buffer.from(`Bearer ${key}`);
|
|
150
|
+
// One operation at a time: a restore and a checkpoint never interleave.
|
|
151
|
+
let chain = Promise.resolve();
|
|
152
|
+
const serial = (fn) => { const next = chain.then(fn); chain = next.catch(() => undefined); return next; };
|
|
153
|
+
// Nothing is booted here: the machine comes up when a door first needs it (Machine.ensure).
|
|
154
|
+
const bringUp = async () => {
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
const up = await machine.ensure();
|
|
157
|
+
if (up)
|
|
158
|
+
process.stderr.write(`world-machine: brought up (${up.from}) in ${Date.now() - started} ms\n`);
|
|
159
|
+
// the collection at start follows the first bring-up, never precedes it (measured: a first GET waited 1.5 s behind
|
|
160
|
+
// one deleting two bases)
|
|
161
|
+
startCollection();
|
|
162
|
+
return up;
|
|
163
|
+
};
|
|
164
|
+
// the store-wide collection: no operation on the slot, so it neither waits on nor holds the slot's lock
|
|
165
|
+
let marks = 0;
|
|
166
|
+
let collectedAtStart = false;
|
|
167
|
+
function startCollection() {
|
|
168
|
+
if (collectedAtStart || stopping)
|
|
169
|
+
return;
|
|
170
|
+
collectedAtStart = true;
|
|
171
|
+
void serial(collectUnlocked).catch((error) => process.stderr.write(`world-machine: gc at start: ${error.message}\n`));
|
|
172
|
+
}
|
|
173
|
+
const collectUnlocked = async () => {
|
|
174
|
+
machine.session.unlocked = true;
|
|
175
|
+
try {
|
|
176
|
+
const r = await collect(config, machine.session, root);
|
|
177
|
+
process.stderr.write(`world-machine: gc kept ${r.live} snapshots over ${r.branches} branches, deleted ${r.deletedSnapshots.length} snapshots (${r.expired.length} past retain), ${r.deletedBases.length} bases and ${r.deletedTrees.length} filled trees, freed ${r.freedBytes} bytes\n`);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
machine.session.unlocked = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const server = createServer((req, res) => {
|
|
184
|
+
const route = `${req.method} ${(req.url ?? '').split('?')[0]}`;
|
|
185
|
+
// A browser on the host must not drive this: the port is loopback, and a VM's loopback is forwarded to its host (Lima),
|
|
186
|
+
// where any web page can send a cross-origin POST and a rebound DNS name can read an answer. A request a page sends
|
|
187
|
+
// carries Origin, or a Host that is not this listener's; a cross-origin POST with a JSON body must pass a preflight,
|
|
188
|
+
// which nothing here answers. Callers (the SDK, the World) send neither Origin nor another Host.
|
|
189
|
+
const host = String(req.headers.host ?? '');
|
|
190
|
+
const refusal = req.headers.origin !== undefined ? 'a request from a web page (it carries Origin)'
|
|
191
|
+
: !new RegExp(`^(127\\.0\\.0\\.1|localhost|\\[::1\\]):${port}$`).test(host) ? `a request for another host (${host.slice(0, 80) || 'none'})`
|
|
192
|
+
: req.method === 'POST' && !/^application\/json\b/i.test(String(req.headers['content-type'] ?? '')) ? 'a POST whose body is not application/json'
|
|
193
|
+
: null;
|
|
194
|
+
if (refusal) {
|
|
195
|
+
send(res, 403, { error: `refused: ${refusal}` });
|
|
196
|
+
req.resume();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const given = Buffer.from(String(req.headers.authorization ?? ''));
|
|
200
|
+
if (given.length !== doorToken.length || !timingSafeEqual(given, doorToken)) {
|
|
201
|
+
send(res, 401, { error: `refused: this door needs its key (authorization: Bearer <the token in ${DOOR_TOKEN_FILE} beside the machine's configuration>)` });
|
|
202
|
+
req.resume();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const handle = async () => {
|
|
206
|
+
switch (route) {
|
|
207
|
+
case 'GET /machine': return [200, await serial(async () => { const up = await bringUp(); await machine.refresh().catch(() => undefined); return { ...machine.status(), ...(up ? { broughtUp: up } : {}) }; })];
|
|
208
|
+
case 'POST /machine/checkpoint': {
|
|
209
|
+
const body = await readJson(req);
|
|
210
|
+
const label = typeof body.label === 'string' ? body.label : '';
|
|
211
|
+
// keep: a snapshot retention never lets go (with `thin`: a mark the agent pinned)
|
|
212
|
+
const done = await serial(async () => { await bringUp(); return machine.checkpoint(label, body.keep === true); });
|
|
213
|
+
// with `retain`, every tenth mark lets the oldest go, after the answer (the store-wide collection, unlocked)
|
|
214
|
+
if (config.retain && ++marks % 10 === 0)
|
|
215
|
+
void serial(collectUnlocked).catch((error) => process.stderr.write(`world-machine: gc after a mark: ${error.message}\n`));
|
|
216
|
+
return [200, done];
|
|
217
|
+
}
|
|
218
|
+
case 'POST /machine/present': {
|
|
219
|
+
const body = await readJson(req);
|
|
220
|
+
const positions = Array.isArray(body.positions) ? body.positions.map(Number).filter((n) => Number.isInteger(n) && n > 0) : [];
|
|
221
|
+
return [200, { snapshots: await serial(() => machine.present(positions)) }];
|
|
222
|
+
}
|
|
223
|
+
case 'POST /machine/compare': {
|
|
224
|
+
// what a restore to that position would change (the container backend): files under a path, and processes
|
|
225
|
+
const body = await readJson(req);
|
|
226
|
+
const position = Number(body.position);
|
|
227
|
+
if (!Number.isInteger(position) || position <= 0)
|
|
228
|
+
return [400, { error: 'POST /machine/compare needs {position} (a machine ledger position)' }];
|
|
229
|
+
if (!('compare' in machine))
|
|
230
|
+
return [501, { error: 'compare is the container backend\'s' }];
|
|
231
|
+
return [200, await serial(() => machine.compare(position, typeof body.path === 'string' ? body.path : undefined))];
|
|
232
|
+
}
|
|
233
|
+
case 'POST /machine/run': {
|
|
234
|
+
// a command in the running machine (the container backend): {argv, user?, timeoutMs?} -> {code, stdout, stderr}
|
|
235
|
+
const body = await readJson(req);
|
|
236
|
+
const argv = Array.isArray(body.argv) && body.argv.length && body.argv.every((a) => typeof a === 'string') ? body.argv : null;
|
|
237
|
+
if (!argv)
|
|
238
|
+
return [400, { error: 'POST /machine/run needs {argv: [<program>, …], user?, timeoutMs?}' }];
|
|
239
|
+
if (!(machine instanceof ContainerMachine))
|
|
240
|
+
return [501, { error: 'run is the container backend\'s' }];
|
|
241
|
+
const user = typeof body.user === 'string' && body.user ? body.user : undefined;
|
|
242
|
+
const timeoutMs = Number.isFinite(body.timeoutMs) ? Number(body.timeoutMs) : undefined;
|
|
243
|
+
return [200, await serial(() => machine.run(argv, user, timeoutMs))];
|
|
244
|
+
}
|
|
245
|
+
case 'POST /machine/files': {
|
|
246
|
+
// part of the disk back at a position, the machine running on (the container backend): {position, paths}
|
|
247
|
+
const body = await readJson(req);
|
|
248
|
+
const position = Number(body.position);
|
|
249
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === 'string' && p) : [];
|
|
250
|
+
if (!Number.isInteger(position) || position <= 0 || !paths.length)
|
|
251
|
+
return [400, { error: 'POST /machine/files needs {position, paths: [<path>, …]}' }];
|
|
252
|
+
if (!(machine instanceof ContainerMachine))
|
|
253
|
+
return [501, { error: 'files is the container backend\'s' }];
|
|
254
|
+
// noLinks: a tree that must be whole in itself (a data directory): one holding a symlink is refused;
|
|
255
|
+
// dryRun: the checks only, nothing touched
|
|
256
|
+
return [200, await serial(() => machine.restoreFiles(position, paths, { noLinks: body.noLinks === true, dryRun: body.dryRun === true }))];
|
|
257
|
+
}
|
|
258
|
+
case 'POST /machine/restore': return [200, await serial(() => machine.restore())];
|
|
259
|
+
case 'POST /machine/prepare': {
|
|
260
|
+
const body = await readJson(req);
|
|
261
|
+
const position = Number(body.position);
|
|
262
|
+
if (!Number.isInteger(position) || position <= 0)
|
|
263
|
+
throw Object.assign(new Error('prepare needs {"position": <machine ledger position>, "live"?: true}'), { status: 400 });
|
|
264
|
+
if (!(machine instanceof ContainerMachine))
|
|
265
|
+
throw Object.assign(new Error('prepare is the container backend\'s'), { status: 501 });
|
|
266
|
+
// live: the running machine itself handed over, neither restored nor paused (a partial retake: the machine stays)
|
|
267
|
+
return [200, await serial(() => machine.prepare(position, body.live === true))];
|
|
268
|
+
}
|
|
269
|
+
case 'GET /machine/clock': return [200, await serial(async () => { if (machine.state !== 'running')
|
|
270
|
+
throw Object.assign(new Error(`no clock to read: the machine is ${machine.state}`), { status: 409 }); return machine.clock(); })];
|
|
271
|
+
default: return [404, { error: `no route ${route}`, routes: ['GET /machine', 'POST /machine/checkpoint', 'POST /machine/restore', 'POST /machine/prepare', 'POST /machine/present', 'POST /machine/compare', 'POST /machine/files', 'POST /machine/run', 'GET /machine/clock'] }];
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
handle().then(([status, body]) => send(res, status, body), (error) => send(res, error.status ?? 500, { error: error.message }));
|
|
275
|
+
});
|
|
276
|
+
server.listen(port, '127.0.0.1', () => process.stderr.write(`world-machine: answering on ${port} at ${new Date().toISOString()}, ${Math.round(performance.now())} ms after the process started (imports done at ${importedAt} ms)\n`));
|
|
277
|
+
// This World joins the store's registry at once, and the store is collected (what no branch of any registered World
|
|
278
|
+
// names goes, gc.ts) after the machine's first bring-up, or 10 s after start when nothing brings it up by then. In the
|
|
279
|
+
// background, serialized with the machine's own operations.
|
|
280
|
+
void serial(async () => {
|
|
281
|
+
machine.session.unlocked = true;
|
|
282
|
+
try {
|
|
283
|
+
await register(machine.session, root);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
machine.session.unlocked = false;
|
|
287
|
+
}
|
|
288
|
+
}).catch((error) => process.stderr.write(`world-machine: registering at start: ${error.message}\n`));
|
|
289
|
+
setTimeout(startCollection, 10_000).unref();
|
|
290
|
+
let stopping = false;
|
|
291
|
+
const stop = () => {
|
|
292
|
+
if (stopping)
|
|
293
|
+
return;
|
|
294
|
+
stopping = true;
|
|
295
|
+
server.close();
|
|
296
|
+
serial(() => machine.stop()).finally(() => process.exit(0));
|
|
297
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
298
|
+
};
|
|
299
|
+
process.on('SIGTERM', stop);
|
|
300
|
+
process.on('SIGINT', stop);
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
if (command === 'setup')
|
|
304
|
+
await setup(configFromArgs());
|
|
305
|
+
else if (command === 'base')
|
|
306
|
+
await base(configFromArgs());
|
|
307
|
+
else if (command === 'serve')
|
|
308
|
+
await serve(configFromArgs());
|
|
309
|
+
else if (command === 'gc')
|
|
310
|
+
await gc(configFromArgs());
|
|
311
|
+
else {
|
|
312
|
+
process.stderr.write('Usage: world-machine setup|base|gc|serve --config <machine.json> (serve also takes --port and --root)\n');
|
|
313
|
+
process.exitCode = 2;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
process.stderr.write(`${error.message}\n`);
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""compose.py <parent criu dir> <mark criu dir>: a mark's CRIU page files rewritten in place so that every page
|
|
3
|
+
identical to one of its parent's shares the parent's blocks (FICLONERANGE, runs coalesced), and only the other pages
|
|
4
|
+
are new data. The container backend runs it detached after each mark (ct.sh wm_ct_compose). A mark is a full dump
|
|
5
|
+
(arm64 has no soft-dirty tracking), and one turn later most of its pages are the parent's (measured on a `next dev`:
|
|
6
|
+
95%). Written by reference from the start, the new data extents hold only new pages, so a compressing store (btrfs,
|
|
7
|
+
compress-force=zstd) keeps only those, compressed: 25 MB for a 1.3 GB dump. A store's own block dedup after the fact
|
|
8
|
+
(duperemove) freed almost nothing there, since a compressed extent lives while any page of it is unique. Each file is
|
|
9
|
+
replaced by one rename (made beside the dump, with the file's own mode and owner), its content byte for byte the same,
|
|
10
|
+
so a restore reading it meanwhile reads either copy.
|
|
11
|
+
Prints one @{...} line: pages, duplicates, ranges cloned, seconds."""
|
|
12
|
+
import fcntl, glob, hashlib, json, os, struct, sys, time
|
|
13
|
+
|
|
14
|
+
FICLONERANGE = 0x4020940d
|
|
15
|
+
PAGE = 4096
|
|
16
|
+
CHUNK = PAGE * 256
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pages(path):
|
|
20
|
+
"""(offset, page) over a file, 4 KiB at a time."""
|
|
21
|
+
with open(path, 'rb') as fh:
|
|
22
|
+
off = 0
|
|
23
|
+
while True:
|
|
24
|
+
block = fh.read(CHUNK)
|
|
25
|
+
if not block: return
|
|
26
|
+
for i in range(0, len(block), PAGE): yield off + i, block[i:i + PAGE]
|
|
27
|
+
off += len(block)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(parent, mark):
|
|
31
|
+
t0 = time.time()
|
|
32
|
+
index, fds = {}, {}
|
|
33
|
+
for f in sorted(glob.glob(os.path.join(parent, 'pages-*.img'))):
|
|
34
|
+
fds[f] = os.open(f, os.O_RDONLY)
|
|
35
|
+
for off, page in pages(f):
|
|
36
|
+
if len(page) == PAGE: index.setdefault(hashlib.blake2b(page, digest_size=16).digest(), (f, off))
|
|
37
|
+
total = dups = clones = 0
|
|
38
|
+
# the temporary files live beside the dump, not in it: a reader listing criu/ never sees a half-written one
|
|
39
|
+
work = os.path.join(os.path.dirname(os.path.abspath(mark)), '.compose')
|
|
40
|
+
os.makedirs(work, exist_ok=True)
|
|
41
|
+
for f in sorted(glob.glob(os.path.join(mark, 'pages-*.img'))):
|
|
42
|
+
tmp = os.path.join(work, os.path.basename(f))
|
|
43
|
+
st = os.stat(f)
|
|
44
|
+
# the file's own mode and owner (CRIU writes some 0600: a process's memory holds its secrets)
|
|
45
|
+
dst = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
46
|
+
os.fchown(dst, st.st_uid, st.st_gid); os.fchmod(dst, st.st_mode & 0o7777)
|
|
47
|
+
run = None # [src path, src offset, dst offset, length]: the clonable run being gathered
|
|
48
|
+
|
|
49
|
+
def flush():
|
|
50
|
+
nonlocal clones
|
|
51
|
+
if run:
|
|
52
|
+
fcntl.ioctl(dst, FICLONERANGE, struct.pack('qQQQ', fds[run[0]], run[1], run[3], run[2]))
|
|
53
|
+
clones += 1
|
|
54
|
+
try:
|
|
55
|
+
size = 0
|
|
56
|
+
for off, page in pages(f):
|
|
57
|
+
total += 1; size = off + len(page)
|
|
58
|
+
hit = index.get(hashlib.blake2b(page, digest_size=16).digest()) if len(page) == PAGE else None
|
|
59
|
+
if hit:
|
|
60
|
+
dups += 1
|
|
61
|
+
if run and run[0] == hit[0] and run[1] + run[3] == hit[1] and run[2] + run[3] == off: run[3] += PAGE
|
|
62
|
+
else: flush(); run = [hit[0], hit[1], off, PAGE]
|
|
63
|
+
else:
|
|
64
|
+
flush(); run = None
|
|
65
|
+
os.pwrite(dst, page, off)
|
|
66
|
+
flush()
|
|
67
|
+
os.ftruncate(dst, size)
|
|
68
|
+
os.fsync(dst)
|
|
69
|
+
except OSError as error:
|
|
70
|
+
# a store that cannot clone ranges (or a page file gone meanwhile): the mark stays as CRIU wrote it
|
|
71
|
+
os.close(dst); os.unlink(tmp)
|
|
72
|
+
try: os.rmdir(work)
|
|
73
|
+
except OSError: pass
|
|
74
|
+
print('@' + json.dumps({'composed': False, 'error': str(error)})); return
|
|
75
|
+
os.close(dst)
|
|
76
|
+
os.rename(tmp, f)
|
|
77
|
+
os.rmdir(work)
|
|
78
|
+
print('@' + json.dumps({'composed': True, 'pages': total, 'dups': dups, 'clones': clones, 'seconds': round(time.time() - t0, 2)}))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == '__main__':
|
|
82
|
+
main(*sys.argv[1:3])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type MachineHost = {
|
|
2
|
+
kind: 'linux';
|
|
3
|
+
} | {
|
|
4
|
+
kind: 'lima';
|
|
5
|
+
instance: string;
|
|
6
|
+
limaHome: string;
|
|
7
|
+
};
|
|
8
|
+
/** The firecracker backend: a microVM on KVM, snapshotted by Firecracker (memory diffs, a reflinked disk). The
|
|
9
|
+
* container backend (ct.sh): the host's own processes in a container, dumped by CRIU with their tree in one reflinked
|
|
10
|
+
* image; for a host where a VM would be nested (a Mac: first-touch memory faulted through two hypervisors ran a real
|
|
11
|
+
* install 20–80x slower). */
|
|
12
|
+
export type MachineBackend = 'firecracker' | 'container';
|
|
13
|
+
export type MachineConfig = {
|
|
14
|
+
host: MachineHost;
|
|
15
|
+
backend: MachineBackend;
|
|
16
|
+
/** container: the OCI image the machine's tree is made from, and the tree's size (a sparse ext4 image). */
|
|
17
|
+
image: string;
|
|
18
|
+
imageSize: string;
|
|
19
|
+
/** container: host directories bound read-only into the container (binaries the machine runs that its image lacks). */
|
|
20
|
+
binds: Array<{
|
|
21
|
+
source: string;
|
|
22
|
+
destination: string;
|
|
23
|
+
}>;
|
|
24
|
+
/** container: which of the machine's listening ports are published on the host's loopback at the same port, as the
|
|
25
|
+
* machine's own localhost (publish.py): a twin delivering to `http://localhost:<port>` reaches the app, and a Lima host
|
|
26
|
+
* forwards them on to the Mac. Only allowed ports: the machine runs code the user did not write, and a port it held
|
|
27
|
+
* first would be taken from a host service that binds it later. None unless set. */
|
|
28
|
+
publishPorts?: {
|
|
29
|
+
ranges: Array<[number, number]>;
|
|
30
|
+
except: number[];
|
|
31
|
+
};
|
|
32
|
+
/** container: a path in the machine's tree served to `client` by the host's NFS server (v3), as `uid`:`gid`. */
|
|
33
|
+
export?: {
|
|
34
|
+
path: string;
|
|
35
|
+
client: string;
|
|
36
|
+
uid: number;
|
|
37
|
+
gid: number;
|
|
38
|
+
};
|
|
39
|
+
/** The reflink-capable directory (XFS or btrfs) holding warm bases, snapshots and the slot. */
|
|
40
|
+
store: string;
|
|
41
|
+
/** A mark is refused unless the store, the file backing it and each of `paths` (machine-host paths on the
|
|
42
|
+
* filesystems beneath it, such as a Mac's home mounted in its Lima VM) have its estimate plus `reserveBytes` free. */
|
|
43
|
+
room: {
|
|
44
|
+
paths: string[];
|
|
45
|
+
reserveBytes: number;
|
|
46
|
+
};
|
|
47
|
+
/** How many of this World's snapshots the store keeps beyond every branch's head, the newest by when the host took
|
|
48
|
+
* 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).
|
|
49
|
+
* Another World on the store keeps its own. Every snapshot kept while a ledger names it when unset. */
|
|
50
|
+
retain?: number;
|
|
51
|
+
/** With `retain`: past the newest `retain`, the newest snapshot of each hour for a day and of each day for a month
|
|
52
|
+
* stay too (thin.ts), and a snapshot taken with `keep` is never let go. */
|
|
53
|
+
thin?: boolean;
|
|
54
|
+
/** The one machine this service runs in place; its tap, addresses and disk path are fixed. */
|
|
55
|
+
slot: string;
|
|
56
|
+
firecracker: string;
|
|
57
|
+
snapshotEditor: string;
|
|
58
|
+
kernel: string;
|
|
59
|
+
rootfs: string;
|
|
60
|
+
/** The private key whose public half the rootfs authorizes for root. */
|
|
61
|
+
sshKey: string;
|
|
62
|
+
/** Absolute path of the provisioning script on the World's host, or undefined for none. */
|
|
63
|
+
provision?: string;
|
|
64
|
+
vcpus: number;
|
|
65
|
+
memMib: number;
|
|
66
|
+
tap: string;
|
|
67
|
+
hostIp: string;
|
|
68
|
+
guestIp: string;
|
|
69
|
+
/** A fixed guest-visible port per vendor, overriding the derived one. */
|
|
70
|
+
egressPorts: Record<string, number>;
|
|
71
|
+
/** Where the machine host reaches a twin listening on the World host's loopback. */
|
|
72
|
+
twinHost: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function loadConfig(path: string): MachineConfig;
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
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'];
|
|
9
|
+
export function loadConfig(path) {
|
|
10
|
+
const file = resolve(path);
|
|
11
|
+
let raw;
|
|
12
|
+
try {
|
|
13
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new Error(`world-machine: cannot read the machine config ${file}: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
for (const key of Object.keys(raw))
|
|
19
|
+
if (!FIELDS.includes(key) && !key.startsWith('//'))
|
|
20
|
+
throw new Error(`world-machine: unknown field "${key}" in ${file} (known: ${FIELDS.join(', ')})`);
|
|
21
|
+
const str = (key, fallback) => {
|
|
22
|
+
const v = raw[key] ?? fallback;
|
|
23
|
+
if (typeof v !== 'string' || v === '')
|
|
24
|
+
throw new Error(`world-machine: "${key}" must be a non-empty string in ${file}`);
|
|
25
|
+
return v;
|
|
26
|
+
};
|
|
27
|
+
const num = (key, fallback) => {
|
|
28
|
+
const v = raw[key] ?? fallback;
|
|
29
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0)
|
|
30
|
+
throw new Error(`world-machine: "${key}" must be a positive integer in ${file}`);
|
|
31
|
+
return v;
|
|
32
|
+
};
|
|
33
|
+
const hostRaw = (raw.host ?? (process.platform === 'darwin' ? undefined : { kind: 'linux' }));
|
|
34
|
+
let host;
|
|
35
|
+
if (hostRaw?.kind === 'linux')
|
|
36
|
+
host = { kind: 'linux' };
|
|
37
|
+
else if (hostRaw?.kind === 'lima' && typeof hostRaw.instance === 'string') {
|
|
38
|
+
const home = typeof hostRaw.limaHome === 'string' ? hostRaw.limaHome : process.env.LIMA_HOME ?? '~/.lima';
|
|
39
|
+
host = { kind: 'lima', instance: hostRaw.instance, limaHome: home.startsWith('~/') ? resolve(homedir(), home.slice(2)) : resolve(dirname(file), home) };
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
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)`);
|
|
43
|
+
const backend = str('backend', 'firecracker');
|
|
44
|
+
if (backend !== 'firecracker' && backend !== 'container')
|
|
45
|
+
throw new Error(`world-machine: "backend" must be "firecracker" or "container" in ${file}`);
|
|
46
|
+
const vm = backend === 'firecracker';
|
|
47
|
+
const store = str('store', '~/.cache/volter-world-machine');
|
|
48
|
+
const egressPorts = (raw.egressPorts ?? {});
|
|
49
|
+
if (typeof egressPorts !== 'object' || Object.values(egressPorts).some((p) => !Number.isInteger(p)))
|
|
50
|
+
throw new Error(`world-machine: "egressPorts" must map a vendor to a port in ${file}`);
|
|
51
|
+
const slot = str('slot', 'default');
|
|
52
|
+
if (!/^[a-z0-9-]+$/.test(slot))
|
|
53
|
+
throw new Error(`world-machine: "slot" must be lowercase letters, digits and dashes in ${file}`);
|
|
54
|
+
return {
|
|
55
|
+
host,
|
|
56
|
+
backend,
|
|
57
|
+
image: vm ? '' : str('image'),
|
|
58
|
+
imageSize: vm ? '' : str('imageSize', '40G'),
|
|
59
|
+
binds: vm ? [] : binds(raw.binds, file),
|
|
60
|
+
...(raw.publishPorts === undefined ? {} : vm ? (() => { throw new Error(`world-machine: "publishPorts" is for the container backend only, in ${file}`); })() : { publishPorts: publishOf(raw.publishPorts, file) }),
|
|
61
|
+
...(vm || raw.export === undefined ? {} : { export: exportOf(raw.export, file) }),
|
|
62
|
+
store,
|
|
63
|
+
room: roomOf(raw.room, file),
|
|
64
|
+
...(raw.retain === undefined ? {} : { retain: num('retain', 1) }),
|
|
65
|
+
...(raw.thin === undefined ? {} : typeof raw.thin !== 'boolean' ? (() => { throw new Error(`world-machine: "thin" must be true or false in ${file}`); })()
|
|
66
|
+
: raw.thin && raw.retain === undefined ? (() => { throw new Error(`world-machine: "thin" thins what "retain" lets go, and needs it, in ${file}`); })() : { thin: raw.thin }),
|
|
67
|
+
slot,
|
|
68
|
+
firecracker: str('firecracker', `${store}/bin/firecracker`),
|
|
69
|
+
snapshotEditor: str('snapshotEditor', `${store}/bin/snapshot-editor`),
|
|
70
|
+
kernel: vm ? str('kernel') : '',
|
|
71
|
+
rootfs: vm ? str('rootfs') : '',
|
|
72
|
+
sshKey: vm ? str('sshKey') : '',
|
|
73
|
+
...(raw.provision === undefined ? {} : { provision: resolve(dirname(file), str('provision')) }),
|
|
74
|
+
vcpus: num('vcpus', 2),
|
|
75
|
+
memMib: num('memMib', 1024),
|
|
76
|
+
tap: str('tap', 'wm0'),
|
|
77
|
+
hostIp: str('hostIp', '172.16.0.1'),
|
|
78
|
+
guestIp: str('guestIp', '172.16.0.2'),
|
|
79
|
+
egressPorts,
|
|
80
|
+
twinHost: host.kind === 'lima' ? 'host.lima.internal' : '127.0.0.1',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function binds(raw, file) {
|
|
84
|
+
if (raw === undefined)
|
|
85
|
+
return [];
|
|
86
|
+
if (!Array.isArray(raw) || raw.some((b) => typeof b?.source !== 'string' || typeof b?.destination !== 'string' || !b.destination.startsWith('/'))) {
|
|
87
|
+
throw new Error(`world-machine: "binds" must be [{"source":"<host dir>","destination":"/<path in the machine>"}] in ${file}`);
|
|
88
|
+
}
|
|
89
|
+
return raw;
|
|
90
|
+
}
|
|
91
|
+
function exportOf(raw, file) {
|
|
92
|
+
const e = raw;
|
|
93
|
+
if (typeof e?.path !== 'string' || !e.path.startsWith('/') || typeof e.client !== 'string' || !Number.isInteger(e.uid) || !Number.isInteger(e.gid)) {
|
|
94
|
+
throw new Error(`world-machine: "export" must be {"path":"/<path in the machine>","client":"<address>","uid":<n>,"gid":<n>} in ${file}`);
|
|
95
|
+
}
|
|
96
|
+
return { path: e.path, client: e.client, uid: e.uid, gid: e.gid };
|
|
97
|
+
}
|
|
98
|
+
function roomOf(raw, file) {
|
|
99
|
+
const r = (raw ?? {});
|
|
100
|
+
const paths = r.paths ?? [];
|
|
101
|
+
const reserveBytes = r.reserveBytes ?? 2 * 2 ** 30;
|
|
102
|
+
if (typeof r !== 'object' || !Array.isArray(paths) || paths.some((p) => typeof p !== 'string' || p === '') || !Number.isInteger(reserveBytes) || reserveBytes < 0) {
|
|
103
|
+
throw new Error(`world-machine: "room" must be {"paths":["<machine-host path>", ...],"reserveBytes":<n>} in ${file}`);
|
|
104
|
+
}
|
|
105
|
+
return { paths: paths, reserveBytes: reserveBytes };
|
|
106
|
+
}
|
|
107
|
+
function publishOf(raw, file) {
|
|
108
|
+
const r = raw;
|
|
109
|
+
const port = (n) => Number.isInteger(n) && n >= 1024 && n <= 65535;
|
|
110
|
+
const ranges = r?.ranges;
|
|
111
|
+
const except = r?.except ?? [];
|
|
112
|
+
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))) {
|
|
113
|
+
throw new Error(`world-machine: "publishPorts" must be {"ranges":[[from,to],...],"except":[port,...]} with ports 1024-65535 in ${file}`);
|
|
114
|
+
}
|
|
115
|
+
// the kernel hands out ephemeral ports from 32768 (Linux's default range): the World's own services take theirs there
|
|
116
|
+
if (ranges.some(([lo, hi]) => hi >= 32768 && lo <= 60999)) {
|
|
117
|
+
throw new Error(`world-machine: "publishPorts" ranges must stay below 32768 (the ephemeral ports the World's services use) in ${file}`);
|
|
118
|
+
}
|
|
119
|
+
return { ranges: ranges, except: except };
|
|
120
|
+
}
|