@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/agent.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# world-machine-agent: runs inside the guest, started once when the warm base is built, so it is in
|
|
2
|
+
# every snapshot. It answers the machine host on the guest's one link: it steps the wall clock to the
|
|
3
|
+
# instant it is sent (a restored guest wakes at its snapshot's wall time), resets the connections a restore
|
|
4
|
+
# left stale and writes the World's env file. One JSON object per line each way.
|
|
5
|
+
import json, os, socket, socketserver, struct, time
|
|
6
|
+
|
|
7
|
+
# After a restore every established TCP connection to the host on this link is stale by construction: its far
|
|
8
|
+
# end belongs to the host as it was at the snapshot (a server listening here keeps the mark's client attached, a
|
|
9
|
+
# client here waits out its own timeout). reset_stale resets each (the kernel's sock_destroy, what `ss -K` does:
|
|
10
|
+
# CONFIG_INET_DIAG_DESTROY), the agent's own connection excepted (its port serves one connection at a time), over
|
|
11
|
+
# sock_diag netlink in this process: measured, spawning `ss` from a just-restored guest took 1.0-2.3 s.
|
|
12
|
+
NETLINK_SOCK_DIAG, SOCK_DIAG_BY_FAMILY, SOCK_DESTROY = 4, 20, 21
|
|
13
|
+
NLM_F_REQUEST, NLM_F_ACK, NLM_F_DUMP, NLMSG_ERROR, NLMSG_DONE = 1, 4, 0x300, 2, 3
|
|
14
|
+
TCP_ESTABLISHED = 1
|
|
15
|
+
|
|
16
|
+
def _diag(family, states, sockid=b"\0" * 48):
|
|
17
|
+
return struct.pack("=BBBBI", family, socket.IPPROTO_TCP, 0, 0, states) + sockid
|
|
18
|
+
|
|
19
|
+
def _talk(nl, kind, flags, payload, seq):
|
|
20
|
+
nl.send(struct.pack("=IHHII", 16 + len(payload), kind, flags, seq, 0) + payload)
|
|
21
|
+
|
|
22
|
+
def reset_stale(host, own_port):
|
|
23
|
+
peer = socket.inet_aton(host)
|
|
24
|
+
nl = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_SOCK_DIAG)
|
|
25
|
+
try:
|
|
26
|
+
_talk(nl, SOCK_DIAG_BY_FAMILY, NLM_F_REQUEST | NLM_F_DUMP, _diag(socket.AF_INET, 1 << TCP_ESTABLISHED), 1)
|
|
27
|
+
stale, done = [], False
|
|
28
|
+
while not done:
|
|
29
|
+
data, off = nl.recv(65536), 0
|
|
30
|
+
while off + 16 <= len(data):
|
|
31
|
+
length, kind = struct.unpack_from("=IH", data, off)
|
|
32
|
+
if kind == NLMSG_DONE: done = True; break
|
|
33
|
+
if kind == NLMSG_ERROR: raise OSError(-struct.unpack_from("=i", data, off + 16)[0], "sock_diag dump failed")
|
|
34
|
+
sockid = data[off + 20:off + 68] # inet_diag_msg: family, state, timer, retrans, then the id
|
|
35
|
+
sport = struct.unpack_from("!H", sockid, 0)[0]
|
|
36
|
+
if sockid[20:24] == peer and sport != own_port: stale.append(sockid)
|
|
37
|
+
off += (length + 3) & ~3
|
|
38
|
+
for i, sockid in enumerate(stale):
|
|
39
|
+
_talk(nl, SOCK_DESTROY, NLM_F_REQUEST | NLM_F_ACK, _diag(socket.AF_INET, 1 << TCP_ESTABLISHED, sockid), 2 + i)
|
|
40
|
+
data = nl.recv(65536)
|
|
41
|
+
err = -struct.unpack_from("=i", data, 16)[0]
|
|
42
|
+
if err and err != 2: raise OSError(err, "sock_destroy: %s" % os.strerror(err)) # ENOENT: it closed meanwhile
|
|
43
|
+
return len(stale)
|
|
44
|
+
finally:
|
|
45
|
+
nl.close()
|
|
46
|
+
|
|
47
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
48
|
+
def handle(self):
|
|
49
|
+
accepted = time.perf_counter()
|
|
50
|
+
setup_ms = round((accepted - Server.accepted_at) * 1000, 2) # from accept() returning to this handler running
|
|
51
|
+
for line in self.rfile:
|
|
52
|
+
got = time.perf_counter()
|
|
53
|
+
try:
|
|
54
|
+
req = json.loads(line)
|
|
55
|
+
op = req.get("op")
|
|
56
|
+
if op == "clock":
|
|
57
|
+
time.clock_settime(time.CLOCK_REALTIME, float(req["t"]))
|
|
58
|
+
res = {"t": time.time()}
|
|
59
|
+
elif op == "shift":
|
|
60
|
+
# moved by an amount against its own clock, read here: the step does not depend on when it arrives
|
|
61
|
+
time.clock_settime(time.CLOCK_REALTIME, time.time() + float(req["by"]))
|
|
62
|
+
res = {"t": time.time()}
|
|
63
|
+
elif op == "reset":
|
|
64
|
+
res = {"reset": reset_stale(self.client_address[0], self.server.server_address[1])}
|
|
65
|
+
elif op == "now":
|
|
66
|
+
res = {"t": time.time()}
|
|
67
|
+
elif op == "write":
|
|
68
|
+
path = req["path"]; tmp = path + ".tmp"
|
|
69
|
+
os.makedirs(os.path.dirname(path) or "/", exist_ok=True)
|
|
70
|
+
with open(tmp, "w") as f: f.write(req["content"])
|
|
71
|
+
os.replace(tmp, path)
|
|
72
|
+
res = {"ok": True}
|
|
73
|
+
else:
|
|
74
|
+
res = {"error": "unknown op %r" % op}
|
|
75
|
+
except Exception as e:
|
|
76
|
+
res = {"error": str(e)}
|
|
77
|
+
# where a reply's time goes inside the guest: waiting for the line since the connection was accepted, and
|
|
78
|
+
# handling it (a first request after a load faults this process's pages in from the snapshot file)
|
|
79
|
+
res["setupMs"] = setup_ms; setup_ms = 0.0
|
|
80
|
+
res["waitMs"] = round((got - accepted) * 1000, 2); res["handleMs"] = round((time.perf_counter() - got) * 1000, 2)
|
|
81
|
+
accepted = time.perf_counter()
|
|
82
|
+
self.wfile.write((json.dumps(res) + "\n").encode()); self.wfile.flush()
|
|
83
|
+
|
|
84
|
+
# One connection at a time, on the serving thread: the first request after a load pays for every guest
|
|
85
|
+
# page it touches (faulted in from the snapshot file), and a thread per connection touched more.
|
|
86
|
+
class Server(socketserver.TCPServer):
|
|
87
|
+
allow_reuse_address = True
|
|
88
|
+
accepted_at = 0.0
|
|
89
|
+
def get_request(self):
|
|
90
|
+
r = super().get_request(); Server.accepted_at = time.perf_counter(); return r
|
|
91
|
+
|
|
92
|
+
Server(("0.0.0.0", int(os.environ.get("WM_AGENT_PORT", "7070"))), Handler).serve_forever()
|
package/src/agentc.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# The machine host's client for the guest agent (agent.py). Clock arguments are seconds:
|
|
2
|
+
# OFFSET is this host's clock minus the World host's clock, DELTA the World clock minus the World
|
|
3
|
+
# host's clock (0 unless the World's clock is frozen). The target instant is therefore
|
|
4
|
+
# time.time() - OFFSET + DELTA, read the moment before it is sent.
|
|
5
|
+
import json, socket, sys, time
|
|
6
|
+
|
|
7
|
+
ip, port, op = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
8
|
+
started = time.time()
|
|
9
|
+
deadline = time.time() + 3
|
|
10
|
+
while True:
|
|
11
|
+
try:
|
|
12
|
+
s = socket.create_connection((ip, port), timeout=1); break
|
|
13
|
+
except OSError as e:
|
|
14
|
+
if time.time() > deadline: print('@' + json.dumps({"error": "guest agent unreachable at %s:%d: %s" % (ip, port, e)})); sys.exit(1)
|
|
15
|
+
time.sleep(0.005)
|
|
16
|
+
# the first request after a load can wait on guest memory faulted in from the file
|
|
17
|
+
s.settimeout(10); f = s.makefile("rwb")
|
|
18
|
+
connectMs = round((time.time() - started) * 1000, 1)
|
|
19
|
+
|
|
20
|
+
def call(req):
|
|
21
|
+
f.write((json.dumps(req) + "\n").encode()); f.flush()
|
|
22
|
+
return json.loads(f.readline())
|
|
23
|
+
|
|
24
|
+
def probe(offset, delta, n=5):
|
|
25
|
+
best = None
|
|
26
|
+
for _ in range(n):
|
|
27
|
+
h0 = time.time(); g = call({"op": "now"})["t"]; h1 = time.time()
|
|
28
|
+
err = g - ((h0 + h1) / 2 - offset + delta)
|
|
29
|
+
if best is None or h1 - h0 < best[1]: best = (err, h1 - h0)
|
|
30
|
+
return best
|
|
31
|
+
|
|
32
|
+
def write_files(files, out):
|
|
33
|
+
for path, content in files.items():
|
|
34
|
+
res = call({"op": "write", "path": path, "content": content})
|
|
35
|
+
if "error" in res: out["error"] = "guest agent: writing %s: %s" % (path, res["error"]); return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
if op in ("clock", "now"):
|
|
39
|
+
offset, delta = float(sys.argv[4]), float(sys.argv[5])
|
|
40
|
+
out = {}
|
|
41
|
+
# files to write on the same connection: one path, its content on stdin, or `-` and a JSON object of path ->
|
|
42
|
+
# content on stdin
|
|
43
|
+
files = (json.load(sys.stdin) if sys.argv[6] == "-" else {sys.argv[6]: sys.stdin.read()}) if len(sys.argv) > 6 else {}
|
|
44
|
+
# the restore id first, the first request after the load: a restored connection the guest drops while the clock
|
|
45
|
+
# is stepped (a second or more under load) must already find the new id
|
|
46
|
+
rid = [k for k in files if k == "/run/world-machine/restore-id"]
|
|
47
|
+
if rid and not write_files({rid[0]: files.pop(rid[0])}, out): print('@' + json.dumps(out)); sys.exit(0)
|
|
48
|
+
if op == "clock":
|
|
49
|
+
# The first request after a load waits on the guest waking (measured: its own handling 11 ms of a 288 ms
|
|
50
|
+
# reply); it is only the warm-up.
|
|
51
|
+
t0 = time.time(); first = call({"op": "now"}); before = first["t"]
|
|
52
|
+
out["connectMs"] = connectMs; out["firstReplyMs"] = round((time.time() - t0) * 1000, 1)
|
|
53
|
+
out["firstHandleMs"] = first.get("handleMs"); out["firstWaitMs"] = first.get("waitMs"); out["firstSetupMs"] = first.get("setupMs")
|
|
54
|
+
# Measure the guest's clock against the target over warm round trips, then move it by the difference,
|
|
55
|
+
# relative to its own time (op shift): unlike stepping to an instant sent ahead, the step does not land late
|
|
56
|
+
# by however long the request takes to be read (the first requests after a load wait on guest pages).
|
|
57
|
+
steps = 0
|
|
58
|
+
while True:
|
|
59
|
+
err, rtt = probe(offset, delta, 3)
|
|
60
|
+
if abs(err) <= 0.003 or steps == 4: break
|
|
61
|
+
call({"op": "shift", "by": -err}); steps += 1
|
|
62
|
+
out["clockMs"] = round((time.time() - t0) * 1000, 1)
|
|
63
|
+
out["steps"] = steps
|
|
64
|
+
out["steppedByMs"] = round(((time.time() - offset + delta) - before) * 1000)
|
|
65
|
+
# the files before the reset: a program whose connection the reset ends reads the new restore id (and the
|
|
66
|
+
# World's env) when it reconnects
|
|
67
|
+
if not write_files(files, out): print('@' + json.dumps(out)); sys.exit(0)
|
|
68
|
+
files = {}
|
|
69
|
+
# then the connections the restore left stale, once the clock reads now (a reset peer reconnects at once)
|
|
70
|
+
r0 = time.time(); res = call({"op": "reset"})
|
|
71
|
+
if "error" in res: print('@' + json.dumps({"error": "guest agent: resetting the stale connections: " + res["error"]})); sys.exit(1)
|
|
72
|
+
out["staleReset"] = res["reset"]; out["resetMs"] = round((time.time() - r0) * 1000, 1)
|
|
73
|
+
err, rtt = probe(offset, delta)
|
|
74
|
+
out.update({"errorMs": round(err * 1000, 2), "probeRttMs": round(rtt * 1000, 2)})
|
|
75
|
+
write_files(files, out)
|
|
76
|
+
print('@' + json.dumps(out))
|
|
77
|
+
elif op == "write":
|
|
78
|
+
res = call({"op": "write", "path": sys.argv[4], "content": sys.stdin.read()})
|
|
79
|
+
print('@' + json.dumps(res))
|
|
80
|
+
else:
|
|
81
|
+
print('@' + json.dumps({"error": "unknown op " + op})); sys.exit(1)
|
package/src/base.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The warm base: the declared guest image, cold-booted once, provisioned by the declared script, and
|
|
2
|
+
// snapshotted whole, in a store-wide cache keyed by everything that shapes it. A World's machine never
|
|
3
|
+
// cold-boots (20-60 s measured); it loads this or a snapshot. The build is a sequence of short steps,
|
|
4
|
+
// each run by one `world-machine base` call that advances as far as it can without waiting and says
|
|
5
|
+
// what it is waiting on; the caller runs it again.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import type { MachineConfig } from './config.ts';
|
|
8
|
+
import { GUEST_AGENT, GUEST_PROFILE, type HostSession } from './host.ts';
|
|
9
|
+
import { baseHash, bootArgs, hp } from './machine.ts';
|
|
10
|
+
import { q } from './host.ts';
|
|
11
|
+
|
|
12
|
+
export type BaseStep = { base: string; stage: string; ready: boolean; detail?: string };
|
|
13
|
+
|
|
14
|
+
export async function stepBase(config: MachineConfig, session: HostSession): Promise<BaseStep> {
|
|
15
|
+
await session.call('wm_reflink_check');
|
|
16
|
+
const hash = await baseHash(config, session);
|
|
17
|
+
const base = `base-${hash}`;
|
|
18
|
+
const B = `"$WM_STORE/${base}"`;
|
|
19
|
+
const W = `"$WM_STORE/${base}.building"`;
|
|
20
|
+
const has = async (test: string): Promise<boolean> => (await session.call<{ ok: boolean }>(`if ${test}; then echo '@{"ok":true}'; else echo '@{"ok":false}'; fi`)).ok;
|
|
21
|
+
const provision = config.provision ? readFileSync(config.provision, 'utf8') : '';
|
|
22
|
+
|
|
23
|
+
if (await has(`[ -f ${B}/vmstate ]`)) return { base, stage: 'ready', ready: true };
|
|
24
|
+
|
|
25
|
+
// 1. the rootfs, copied once into the store (it may not be on a reflink filesystem), in the background
|
|
26
|
+
if (!(await has(`[ -f ${W}/copied ]`))) {
|
|
27
|
+
// a copy that is no longer running (the host restarted, the store was remounted) is started again
|
|
28
|
+
if (!(await has(`[ ! -f ${W}/copying ] || kill -0 "$(cat ${W}/copy.pid 2>/dev/null)" 2>/dev/null`))) await session.call(`rm -f ${W}/copying ${W}/disk.ext4; echo '@{"ok":true}'`);
|
|
29
|
+
if (!(await has(`[ -f ${W}/copying ]`))) {
|
|
30
|
+
// a slot builds one base at a time: another base left half-built by this slot (its inputs moved on) is abandoned,
|
|
31
|
+
// and goes before this one starts; one with no slot recorded predates the record and is taken as this slot's
|
|
32
|
+
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" ] && rm -rf "$d"; done; echo '@{"ok":true}'`);
|
|
33
|
+
await session.call(`mkdir -p ${W} && printf '%s' "$WM_SLOT" > ${W}/slot && : > ${W}/copying && (setsid bash -c "echo \\$\\$ > ${W.replace(/"/g, '\\"')}/copy.pid; cp --sparse=always ${hp(config.rootfs).replace(/"/g, '\\"')} ${W.replace(/"/g, '\\"')}/disk.ext4 && : > ${W.replace(/"/g, '\\"')}/copied" > ${W}/copy.log 2>&1 < /dev/null &) ; echo '@{"ok":true}'`);
|
|
34
|
+
return { base, stage: 'copying the rootfs into the store', ready: false };
|
|
35
|
+
}
|
|
36
|
+
return { base, stage: 'copying the rootfs into the store', ready: false, detail: 'run again' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. a cold boot in the slot (the snapshot records the slot's disk path and tap)
|
|
40
|
+
if (!(await has(`[ -f ${W}/booted ]`))) {
|
|
41
|
+
await session.call(`wm_net >/dev/null && wm_boot ${hp(config.kernel)} ${W}/disk.ext4 ${config.vcpus} ${config.memMib} ${q(bootArgs(config))} && wm_own '{"token":"base-build"}' && : > ${W}/booted`);
|
|
42
|
+
return { base, stage: 'booting the guest', ready: false, detail: 'run again once it answers ssh' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. the guest agent and the provisioning script, started in the guest
|
|
46
|
+
if (!(await has(`[ -f ${W}/provisioning ]`))) {
|
|
47
|
+
if (!(await has('WM_GSSH_TIMEOUT=6 wm_gssh true'))) return { base, stage: 'waiting for the guest to answer ssh', ready: false };
|
|
48
|
+
await session.call([
|
|
49
|
+
`wm_gssh 'mkdir -p /usr/local/sbin && cat > /usr/local/sbin/world-machine-agent' <<'__WM_AGENT__'\n${GUEST_AGENT}\n__WM_AGENT__`,
|
|
50
|
+
`wm_gssh 'cat > /etc/profile.d/volter-world.sh' <<'__WM_PROFILE__'\n${GUEST_PROFILE}\n__WM_PROFILE__`,
|
|
51
|
+
`wm_gssh 'cat > /root/.world-machine-provision.sh' <<'__WM_PROVISION__'\n${provision}\n__WM_PROVISION__`,
|
|
52
|
+
`wm_gssh 'setsid nohup python3 /usr/local/sbin/world-machine-agent > /var/log/world-machine-agent.log 2>&1 < /dev/null & setsid nohup bash -c "bash /root/.world-machine-provision.sh > /root/.world-machine-provision.log 2>&1; echo \\$? > /root/.world-machine-provision.done" > /dev/null 2>&1 < /dev/null &' || return 1`,
|
|
53
|
+
`: > ${W}/provisioning; echo '@{"ok":true}'`,
|
|
54
|
+
].join('\n'));
|
|
55
|
+
return { base, stage: 'provisioning', ready: false, detail: 'run again' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. provisioning finished, and finished well
|
|
59
|
+
const done = await session.call<{ code: string; log: string }>(`printf '@{"code":"%s","log":%s}\\n' "$(wm_gssh 'cat /root/.world-machine-provision.done 2>/dev/null')" "$(wm_gssh 'tail -c 600 /root/.world-machine-provision.log 2>/dev/null' | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"`);
|
|
60
|
+
if (done.code === '') return { base, stage: 'provisioning', ready: false, detail: done.log.trim().split('\n').pop() };
|
|
61
|
+
if (done.code.trim() !== '0') throw new Error(`world-machine base: the provisioning script exited ${done.code.trim()}:\n${done.log}\n(remove ${config.store}/${base}.building to start over)`);
|
|
62
|
+
|
|
63
|
+
// 5. the Full snapshot: memory, device state and disk, then the slot is freed
|
|
64
|
+
const snap = await session.call<{ pauseMs: number; memBytes: number }>(`wm_agent now 0 0 >/dev/null || return 1\nwm_snapshot ${W} Full || return 1\nwm_fc_stop; wm_release\nrm -f ${W}/copying ${W}/copied ${W}/booted ${W}/provisioning ${W}/copy.log\nmv ${W} ${B}`);
|
|
65
|
+
return { base, stage: 'ready', ready: true, detail: `full snapshot in ${snap.pauseMs} ms, ${Math.round(snap.memBytes / 1048576)} MiB of memory` };
|
|
66
|
+
}
|
|
67
|
+
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
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, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { stepBase } from './base.ts';
|
|
13
|
+
import { ContainerMachine, stepContainerBase } from './container.ts';
|
|
14
|
+
import { loadConfig, type MachineConfig } from './config.ts';
|
|
15
|
+
import { HostSession, q } from './host.ts';
|
|
16
|
+
import { hp, Machine } from './machine.ts';
|
|
17
|
+
import { collect, register } from './gc.ts';
|
|
18
|
+
|
|
19
|
+
// when this module's own code first runs: every import is done (the process started at performance.timeOrigin)
|
|
20
|
+
const importedAt = Math.round(performance.now());
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const flag = (name: string): string | undefined => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
23
|
+
const command = args[0];
|
|
24
|
+
const out = (value: unknown): void => { process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`); };
|
|
25
|
+
|
|
26
|
+
function configFromArgs(): MachineConfig {
|
|
27
|
+
const path = flag('--config');
|
|
28
|
+
if (!path) throw new Error(`world-machine ${command}: --config <machine.json> is required`);
|
|
29
|
+
return loadConfig(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FC_VERSION = 'v1.17.0';
|
|
33
|
+
/** The doors' key, a file beside the machine's configuration (serve). */
|
|
34
|
+
export const DOOR_TOKEN_FILE = '.world-machine-token';
|
|
35
|
+
|
|
36
|
+
async function setup(config: MachineConfig): Promise<void> {
|
|
37
|
+
const session = new HostSession(config);
|
|
38
|
+
try {
|
|
39
|
+
const size = flag('--make-store');
|
|
40
|
+
const container = config.backend === 'container';
|
|
41
|
+
const lines = container ? [
|
|
42
|
+
// the container backend needs no KVM: crun and CRIU run the machine, podman fills its tree from the image
|
|
43
|
+
'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',
|
|
44
|
+
'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; }',
|
|
45
|
+
// CRIU refuses a listener with a connection the kernel accepted but the paused process has not (measured: a mark failed
|
|
46
|
+
// under requests to a published port); skipped, the client sees a reset and retries. crun reads CRIU's default config
|
|
47
|
+
"{ 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; }",
|
|
48
|
+
// the tree's export: the host's NFS server, v3 only, mountd on the port the machine's forward names
|
|
49
|
+
...(config.export ? [
|
|
50
|
+
'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; }',
|
|
51
|
+
`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; }`,
|
|
52
|
+
] : []),
|
|
53
|
+
] : [
|
|
54
|
+
// udev resets /dev/kvm on change events; a rule keeps it usable where a one-off chmod does not
|
|
55
|
+
`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`,
|
|
56
|
+
'[ -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; }',
|
|
57
|
+
'command -v socat >/dev/null || sudo -n apt-get install -y -qq socat >/dev/null || { wm_fail "socat is missing"; return 1; }',
|
|
58
|
+
];
|
|
59
|
+
if (size) {
|
|
60
|
+
// a loop-mounted image when the host's own filesystem cannot reflink; persisted in fstab. btrfs with zstd where the
|
|
61
|
+
// host has it: a mark's pages composed against its parent's (compose.py) keep only the new ones, compressed
|
|
62
|
+
// (measured: 25 MB for a 1.3 GB dump; 254 MB compressed alone). compress-force: with `compress` btrfs's heuristic
|
|
63
|
+
// left a mark's new pages uncompressed (687 MB), and it also compresses the tree's image (chattr +m does not stop
|
|
64
|
+
// it; a first compile measured 1.03-1.23x the Mac's). An XFS image made before is used as it is.
|
|
65
|
+
lines.push(
|
|
66
|
+
// only where the store cannot reflink already (a store on XFS or btrfs is used as it is)
|
|
67
|
+
'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"',
|
|
68
|
+
'if [ "$cow" = 0 ] && ! mountpoint -q "$WM_STORE"; then',
|
|
69
|
+
` 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`,
|
|
70
|
+
' if [ -f "$old" ]; then img=$old; fs=xfs; opts=loop,nofail',
|
|
71
|
+
// a btrfs store made before is never replaced: without btrfs here, setup fails rather than mount an empty XFS over it
|
|
72
|
+
' 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',
|
|
73
|
+
' 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',
|
|
74
|
+
' 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',
|
|
75
|
+
` [ -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; }`,
|
|
76
|
+
' 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; }',
|
|
77
|
+
' 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; }',
|
|
78
|
+
'fi',
|
|
79
|
+
|
|
80
|
+
'mkdir -p "$WM_STORE/snapshots" "$WM_SLOTDIR"',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (container) 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"');
|
|
84
|
+
else lines.push(
|
|
85
|
+
'wm_reflink_check || return 1',
|
|
86
|
+
// Firecracker and its snapshot-editor, the pinned release, when the configured paths hold nothing
|
|
87
|
+
`if [ ! -x "$WM_FC" ] || [ ! -x "$WM_SE" ]; then`,
|
|
88
|
+
` 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; }`,
|
|
89
|
+
` 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"`,
|
|
90
|
+
'fi',
|
|
91
|
+
`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`,
|
|
92
|
+
'wm_net || return 1',
|
|
93
|
+
'printf \'@{"firecracker":"%s","store":"%s"}\\n\' "$("$WM_FC" --version | head -1)" "$WM_STORE"',
|
|
94
|
+
);
|
|
95
|
+
out(await session.call(lines.join('\n')));
|
|
96
|
+
} finally { session.close(); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function base(config: MachineConfig): Promise<void> {
|
|
100
|
+
const session = new HostSession(config);
|
|
101
|
+
try {
|
|
102
|
+
const step = config.backend === 'container' ? await stepContainerBase(config, session) : await stepBase(config, session);
|
|
103
|
+
out(step);
|
|
104
|
+
if (!step.ready) process.exitCode = 3;
|
|
105
|
+
} finally { session.close(); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function gc(config: MachineConfig): Promise<void> {
|
|
109
|
+
const session = new HostSession(config);
|
|
110
|
+
try { out(await collect(config, session)); } finally { session.close(); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
114
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
115
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
119
|
+
let text = '';
|
|
120
|
+
for await (const chunk of req) text += chunk;
|
|
121
|
+
if (!text.trim()) return {};
|
|
122
|
+
const value = JSON.parse(text) as unknown;
|
|
123
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw Object.assign(new Error('the body must be a JSON object'), { status: 400 });
|
|
124
|
+
return value as Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function serve(config: MachineConfig): Promise<void> {
|
|
128
|
+
const port = Number(flag('--port') ?? process.env.PORT);
|
|
129
|
+
const root = flag('--root');
|
|
130
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error('world-machine serve: --port (or PORT) is required');
|
|
131
|
+
if (!root) throw new Error('world-machine serve: --root <dir> is required (the World passes the service data dir)');
|
|
132
|
+
const machine = config.backend === 'container' ? new ContainerMachine(config, root, process.env, port) : new Machine(config, root, process.env, port);
|
|
133
|
+
// The doors' key: every request names it (`authorization: Bearer <token>`). Loopback is not enough: the machine's own
|
|
134
|
+
// processes reach this host's loopback through the World's egress proxy, which forwards local destinations, and a
|
|
135
|
+
// door can restore, copy into and run commands in the machine. The key is a file beside this configuration, on this
|
|
136
|
+
// host only (the machine never mounts it), made once and shared by every branch's machine of the World; a caller
|
|
137
|
+
// (supercode-retake) reads it there.
|
|
138
|
+
const tokenFile = join(dirname(resolve(flag('--config')!)), DOOR_TOKEN_FILE);
|
|
139
|
+
// written whole beside and linked into place (never an empty or half-written key another serve reads), and only
|
|
140
|
+
// served with a key of its full length
|
|
141
|
+
if (!existsSync(tokenFile)) {
|
|
142
|
+
const tmp = `${tokenFile}.${process.pid}.tmp`;
|
|
143
|
+
writeFileSync(tmp, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600 });
|
|
144
|
+
try { linkSync(tmp, tokenFile); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } finally { unlinkSync(tmp); }
|
|
145
|
+
}
|
|
146
|
+
const key = readFileSync(tokenFile, 'utf8').trim();
|
|
147
|
+
if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`world-machine serve: the door key ${tokenFile} is not a 64-hex-digit key; remove it and serve again`);
|
|
148
|
+
const doorToken = Buffer.from(`Bearer ${key}`);
|
|
149
|
+
// One operation at a time: a restore and a checkpoint never interleave.
|
|
150
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
151
|
+
const serial = <T>(fn: () => Promise<T>): Promise<T> => { const next = chain.then(fn); chain = next.catch(() => undefined); return next; };
|
|
152
|
+
// Nothing is booted here: the machine comes up when a door first needs it (Machine.ensure).
|
|
153
|
+
const bringUp = async (): Promise<Record<string, unknown> | null> => {
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
const up = await machine.ensure();
|
|
156
|
+
if (up) process.stderr.write(`world-machine: brought up (${up.from}) in ${Date.now() - started} ms\n`);
|
|
157
|
+
// the collection at start follows the first bring-up, never precedes it (measured: a first GET waited 1.5 s behind
|
|
158
|
+
// one deleting two bases)
|
|
159
|
+
startCollection();
|
|
160
|
+
return up;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// the store-wide collection: no operation on the slot, so it neither waits on nor holds the slot's lock
|
|
164
|
+
let marks = 0;
|
|
165
|
+
let collectedAtStart = false;
|
|
166
|
+
function startCollection(): void {
|
|
167
|
+
if (collectedAtStart || stopping) return;
|
|
168
|
+
collectedAtStart = true;
|
|
169
|
+
void serial(collectUnlocked).catch((error: Error) => process.stderr.write(`world-machine: gc at start: ${error.message}\n`));
|
|
170
|
+
}
|
|
171
|
+
const collectUnlocked = async (): Promise<void> => {
|
|
172
|
+
machine.session.unlocked = true;
|
|
173
|
+
try {
|
|
174
|
+
const r = await collect(config, machine.session, root);
|
|
175
|
+
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`);
|
|
176
|
+
} finally { machine.session.unlocked = false; }
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const server = createServer((req, res) => {
|
|
180
|
+
const route = `${req.method} ${(req.url ?? '').split('?')[0]}`;
|
|
181
|
+
// A browser on the host must not drive this: the port is loopback, and a VM's loopback is forwarded to its host (Lima),
|
|
182
|
+
// where any web page can send a cross-origin POST and a rebound DNS name can read an answer. A request a page sends
|
|
183
|
+
// carries Origin, or a Host that is not this listener's; a cross-origin POST with a JSON body must pass a preflight,
|
|
184
|
+
// which nothing here answers. Callers (the SDK, the World) send neither Origin nor another Host.
|
|
185
|
+
const host = String(req.headers.host ?? '');
|
|
186
|
+
const refusal = req.headers.origin !== undefined ? 'a request from a web page (it carries Origin)'
|
|
187
|
+
: !new RegExp(`^(127\\.0\\.0\\.1|localhost|\\[::1\\]):${port}$`).test(host) ? `a request for another host (${host.slice(0, 80) || 'none'})`
|
|
188
|
+
: req.method === 'POST' && !/^application\/json\b/i.test(String(req.headers['content-type'] ?? '')) ? 'a POST whose body is not application/json'
|
|
189
|
+
: null;
|
|
190
|
+
if (refusal) { send(res, 403, { error: `refused: ${refusal}` }); req.resume(); return; }
|
|
191
|
+
const given = Buffer.from(String(req.headers.authorization ?? ''));
|
|
192
|
+
if (given.length !== doorToken.length || !timingSafeEqual(given, doorToken)) { send(res, 401, { error: `refused: this door needs its key (authorization: Bearer <the token in ${DOOR_TOKEN_FILE} beside the machine's configuration>)` }); req.resume(); return; }
|
|
193
|
+
const handle = async (): Promise<[number, unknown]> => {
|
|
194
|
+
switch (route) {
|
|
195
|
+
case 'GET /machine': return [200, await serial(async () => { const up = await bringUp(); await machine.refresh().catch(() => undefined); return { ...machine.status(), ...(up ? { broughtUp: up } : {}) }; })];
|
|
196
|
+
case 'POST /machine/checkpoint': {
|
|
197
|
+
const body = await readJson(req);
|
|
198
|
+
const label = typeof body.label === 'string' ? body.label : '';
|
|
199
|
+
// keep: a snapshot retention never lets go (with `thin`: a mark the agent pinned)
|
|
200
|
+
const done = await serial(async () => { await bringUp(); return machine.checkpoint(label, body.keep === true); });
|
|
201
|
+
// with `retain`, every tenth mark lets the oldest go, after the answer (the store-wide collection, unlocked)
|
|
202
|
+
if (config.retain && ++marks % 10 === 0) void serial(collectUnlocked).catch((error: Error) => process.stderr.write(`world-machine: gc after a mark: ${error.message}\n`));
|
|
203
|
+
return [200, done];
|
|
204
|
+
}
|
|
205
|
+
case 'POST /machine/present': {
|
|
206
|
+
const body = await readJson(req);
|
|
207
|
+
const positions = Array.isArray(body.positions) ? body.positions.map(Number).filter((n: number) => Number.isInteger(n) && n > 0) : [];
|
|
208
|
+
return [200, { snapshots: await serial(() => machine.present(positions)) }];
|
|
209
|
+
}
|
|
210
|
+
case 'POST /machine/compare': {
|
|
211
|
+
// what a restore to that position would change (the container backend): files under a path, and processes
|
|
212
|
+
const body = await readJson(req);
|
|
213
|
+
const position = Number(body.position);
|
|
214
|
+
if (!Number.isInteger(position) || position <= 0) return [400, { error: 'POST /machine/compare needs {position} (a machine ledger position)' }];
|
|
215
|
+
if (!('compare' in machine)) return [501, { error: 'compare is the container backend\'s' }];
|
|
216
|
+
return [200, await serial(() => (machine as unknown as { compare(p: number, path?: string): Promise<unknown> }).compare(position, typeof body.path === 'string' ? body.path : undefined))];
|
|
217
|
+
}
|
|
218
|
+
case 'POST /machine/run': {
|
|
219
|
+
// a command in the running machine (the container backend): {argv, user?, timeoutMs?} -> {code, stdout, stderr}
|
|
220
|
+
const body = await readJson(req);
|
|
221
|
+
const argv = Array.isArray(body.argv) && body.argv.length && body.argv.every((a: unknown) => typeof a === 'string') ? body.argv as string[] : null;
|
|
222
|
+
if (!argv) return [400, { error: 'POST /machine/run needs {argv: [<program>, …], user?, timeoutMs?}' }];
|
|
223
|
+
if (!(machine instanceof ContainerMachine)) return [501, { error: 'run is the container backend\'s' }];
|
|
224
|
+
const user = typeof body.user === 'string' && body.user ? body.user : undefined;
|
|
225
|
+
const timeoutMs = Number.isFinite(body.timeoutMs) ? Number(body.timeoutMs) : undefined;
|
|
226
|
+
return [200, await serial(() => machine.run(argv, user, timeoutMs))];
|
|
227
|
+
}
|
|
228
|
+
case 'POST /machine/files': {
|
|
229
|
+
// part of the disk back at a position, the machine running on (the container backend): {position, paths}
|
|
230
|
+
const body = await readJson(req);
|
|
231
|
+
const position = Number(body.position);
|
|
232
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((p: unknown) => typeof p === 'string' && p) : [];
|
|
233
|
+
if (!Number.isInteger(position) || position <= 0 || !paths.length) return [400, { error: 'POST /machine/files needs {position, paths: [<path>, …]}' }];
|
|
234
|
+
if (!(machine instanceof ContainerMachine)) return [501, { error: 'files is the container backend\'s' }];
|
|
235
|
+
// noLinks: a tree that must be whole in itself (a data directory): one holding a symlink is refused;
|
|
236
|
+
// dryRun: the checks only, nothing touched
|
|
237
|
+
return [200, await serial(() => machine.restoreFiles(position, paths, { noLinks: body.noLinks === true, dryRun: body.dryRun === true }))];
|
|
238
|
+
}
|
|
239
|
+
case 'POST /machine/restore': return [200, await serial(() => machine.restore())];
|
|
240
|
+
case 'POST /machine/prepare': {
|
|
241
|
+
const body = await readJson(req);
|
|
242
|
+
const position = Number(body.position);
|
|
243
|
+
if (!Number.isInteger(position) || position <= 0) throw Object.assign(new Error('prepare needs {"position": <machine ledger position>, "live"?: true}'), { status: 400 });
|
|
244
|
+
if (!(machine instanceof ContainerMachine)) throw Object.assign(new Error('prepare is the container backend\'s'), { status: 501 });
|
|
245
|
+
// live: the running machine itself handed over, neither restored nor paused (a partial retake: the machine stays)
|
|
246
|
+
return [200, await serial(() => machine.prepare(position, body.live === true))];
|
|
247
|
+
}
|
|
248
|
+
case 'GET /machine/clock': return [200, await serial(async () => { if (machine.state !== 'running') throw Object.assign(new Error(`no clock to read: the machine is ${machine.state}`), { status: 409 }); return machine.clock(); })];
|
|
249
|
+
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'] }];
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
handle().then(([status, body]) => send(res, status, body), (error: Error & { status?: number }) => send(res, error.status ?? 500, { error: error.message }));
|
|
253
|
+
});
|
|
254
|
+
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`));
|
|
255
|
+
// This World joins the store's registry at once, and the store is collected (what no branch of any registered World
|
|
256
|
+
// 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
|
|
257
|
+
// background, serialized with the machine's own operations.
|
|
258
|
+
void serial(async () => {
|
|
259
|
+
machine.session.unlocked = true;
|
|
260
|
+
try { await register(machine.session, root); } finally { machine.session.unlocked = false; }
|
|
261
|
+
}).catch((error: Error) => process.stderr.write(`world-machine: registering at start: ${error.message}\n`));
|
|
262
|
+
setTimeout(startCollection, 10_000).unref();
|
|
263
|
+
let stopping = false;
|
|
264
|
+
const stop = (): void => {
|
|
265
|
+
if (stopping) return; stopping = true;
|
|
266
|
+
server.close();
|
|
267
|
+
serial(() => machine.stop()).finally(() => process.exit(0));
|
|
268
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
269
|
+
};
|
|
270
|
+
process.on('SIGTERM', stop);
|
|
271
|
+
process.on('SIGINT', stop);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
if (command === 'setup') await setup(configFromArgs());
|
|
276
|
+
else if (command === 'base') await base(configFromArgs());
|
|
277
|
+
else if (command === 'serve') await serve(configFromArgs());
|
|
278
|
+
else if (command === 'gc') await gc(configFromArgs());
|
|
279
|
+
else { process.stderr.write('Usage: world-machine setup|base|gc|serve --config <machine.json> (serve also takes --port and --root)\n'); process.exitCode = 2; }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
}
|
package/src/compose.py
ADDED
|
@@ -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])
|