@volter/world-machine 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ # The machine host's half of @volter/world-machine: sourced once into the persistent shell the service
2
+ # holds on the machine host (the Linux box itself, or the Lima VM on a Mac). Every function prints its
3
+ # result as one `@{json}` line; diagnostics go to stderr, which the session folds into the output.
4
+ # The caller sets WM_STORE, WM_SLOT, WM_FC, WM_SE, WM_KEY, WM_TAP, WM_HOSTIP, WM_GUESTIP before sourcing.
5
+ WM_SLOTDIR="$WM_STORE/slots/$WM_SLOT"
6
+ WM_SOCK="$WM_SLOTDIR/api.sock"
7
+ WM_AGENT_PORT=7070
8
+
9
+ wm_ns() { date +%s%N; }
10
+ # wm_timed <name> <command...>: runs it and reports `<name>Ms`
11
+ wm_timed() { local n="$1" s="$EPOCHREALTIME" rc e; shift; "$@"; rc=$?; e="$EPOCHREALTIME"; printf '@{"%sMs":%s}\n' "$n" "$(( (${e/./} - ${s/./}) / 1000 ))"; return $rc; }
12
+ wm_ms_since() { echo $(( ($(date +%s%N) - $1) / 1000000 )); }
13
+ wm_fail() { printf '@{"error":%s}\n' "$(printf '%s' "$*" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"; return 1; }
14
+
15
+ # A job backgrounded from a function of this session holds the copies bash keeps of the session's own descriptors while
16
+ # the function runs with redirections (the service's pipes, the slot lock): `world-machine base` stayed until its tree's
17
+ # fill ended (23 s measured). Such a job closes them first; one that execs a program does not need to (they close on exec).
18
+ # (listed first, and closed with no redirection of its own: one would be saved to a high descriptor the loop then closes,
19
+ # leaving stderr lost)
20
+ wm_closefds() { local fds=(/proc/$BASHPID/fd/*) f n; for f in "${fds[@]}"; do n=${f##*/}; case "$n" in 0|1|2|*[!0-9]*) ;; *) eval "exec $n>&-" ;; esac; done; return 0; }
21
+
22
+ wm_init() {
23
+ mkdir -p "$WM_STORE/snapshots" "$WM_SLOTDIR" || return 1
24
+ printf '@{"home":"%s","store":"%s"}\n' "$HOME" "$WM_STORE"
25
+ }
26
+
27
+ # A reflink-capable store is a precondition, never a preference: a whole-image copy dominated every
28
+ # pause it was part of (1–13 s measured), so there is no fallback to one.
29
+ wm_reflink_check() {
30
+ local probe="$WM_STORE/.reflink-probe"
31
+ head -c 4096 /dev/zero > "$probe" 2>/dev/null || { wm_fail "cannot write the store $WM_STORE"; return 1; }
32
+ if ! cp --reflink=always "$probe" "$probe.clone" 2>/dev/null; then
33
+ rm -f "$probe" "$probe.clone"
34
+ wm_fail "the store $WM_STORE is on $(stat -f -c %T "$WM_STORE") and cannot reflink; world-machine needs XFS (reflink=1) or btrfs there and never falls back to a full copy (run \`world-machine setup --make-store <size>\`)"
35
+ return 1
36
+ fi
37
+ rm -f "$probe" "$probe.clone"
38
+ wm_store_dio
39
+ printf '@{"reflink":true,"fs":"%s"}\n' "$(stat -f -c %T "$WM_STORE")"
40
+ }
41
+
42
+ # A store that is a loop image (setup --make-store) reads and writes its backing file with direct I/O: through the
43
+ # page cache every page was cached twice, and a mark's 1.3 GB dump waited on writeback with the memory full
44
+ # (measured: 7.5 s paused, 0.7-1.0 s with direct I/O). fstab cannot ask for it, so it is set here, on each use.
45
+ wm_store_dio() {
46
+ local dev; dev=$(findmnt -no SOURCE --target "$WM_STORE" 2>/dev/null); dev=${dev%%[*}
47
+ case "$dev" in /dev/loop*) [ "$(cat "/sys/block/${dev#/dev/}/loop/dio" 2>/dev/null)" = 1 ] || sudo -n losetup --direct-io=on "$dev" 2>/dev/null || true ;; esac
48
+ }
49
+
50
+ # The guest's one link: a tap with the host end at WM_HOSTIP, forwarding from it dropped.
51
+ wm_net() {
52
+ [ -n "$WM_NET_READY" ] && { printf '@{"tap":"%s"}\n' "$WM_TAP"; return 0; }
53
+ if ! ip link show "$WM_TAP" >/dev/null 2>&1; then
54
+ sudo -n ip tuntap add "$WM_TAP" mode tap user "$(id -un)" || { wm_fail "cannot create tap $WM_TAP (needs sudo; run world-machine setup)"; return 1; }
55
+ sudo -n ip addr add "$WM_HOSTIP/30" dev "$WM_TAP" && sudo -n ip link set "$WM_TAP" up || { wm_fail "cannot address tap $WM_TAP"; return 1; }
56
+ fi
57
+ if command -v iptables >/dev/null && ! sudo -n iptables -C FORWARD -i "$WM_TAP" -j DROP 2>/dev/null; then
58
+ sudo -n iptables -I FORWARD -i "$WM_TAP" -j DROP 2>/dev/null || true
59
+ fi
60
+ WM_NET_READY=1
61
+ printf '@{"tap":"%s"}\n' "$WM_TAP"
62
+ }
63
+
64
+ wm_api() {
65
+ local code
66
+ code=$(curl -s --unix-socket "$WM_SOCK" -X "$1" "http://localhost/$2" -H 'Content-Type: application/json' ${3:+-d "$3"} -o "$WM_SLOTDIR/api.out" -w '%{http_code}')
67
+ case "$code" in 2*) return 0 ;; esac
68
+ wm_fail "firecracker $1 /$2 answered $code: $(cat "$WM_SLOTDIR/api.out" 2>/dev/null)"
69
+ }
70
+
71
+ wm_fc_pid() {
72
+ local pid
73
+ pid=$(cat "$WM_SLOTDIR/fc.pid" 2>/dev/null) || return 1
74
+ [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] && case "$(cat /proc/$pid/comm)" in firecracker*) echo "$pid"; return 0 ;; esac
75
+ return 1
76
+ }
77
+
78
+ wm_fc_stop() {
79
+ local pid i
80
+ if pid=$(wm_fc_pid); then
81
+ kill -KILL "$pid" 2>/dev/null
82
+ for i in $(seq 1 400); do [ -d "/proc/$pid" ] || break; sleep 0.005; done
83
+ fi
84
+ rm -f "$WM_SLOTDIR/fc.pid" "$WM_SOCK"
85
+ }
86
+
87
+ wm_fc_start() {
88
+ local i
89
+ rm -f "$WM_SOCK"
90
+ # the pid is written by the process that becomes firecracker (setsid may fork, so $! can be its parent).
91
+ # `exec`: the backgrounded `cd && …` list is a forked copy of this session's shell, holding the session's
92
+ # stdout; without it that copy waits on Firecracker for the machine's whole life, so the service's pipe to
93
+ # this shell never closes (on Linux `world-machine base` then never exits; over Lima's ssh it was hidden)
94
+ ( cd "$WM_SLOTDIR" && exec setsid bash -c 'echo $$ > "$1"; shift; exec "$@"' _ "$WM_SLOTDIR/fc.pid" "$WM_FC" --api-sock "$WM_SOCK" > "$WM_SLOTDIR/fc.log" 2>&1 < /dev/null & )
95
+ for i in $(seq 1 1000); do [ -S "$WM_SOCK" ] && return 0; sleep 0.002; done
96
+ wm_fail "firecracker did not open $WM_SOCK: $(tail -3 "$WM_SLOTDIR/fc.log")"
97
+ }
98
+
99
+ wm_running() {
100
+ local pid state='stopped'
101
+ if pid=$(wm_fc_pid); then
102
+ state=$(curl -s --unix-socket "$WM_SOCK" http://localhost/ 2>/dev/null | python3 -c 'import json,sys
103
+ try: print(json.load(sys.stdin).get("state","unknown"))
104
+ except Exception: print("unknown")')
105
+ fi
106
+ printf '@{"fc":"%s","owner":%s}\n' "$state" "$(cat "$WM_SLOTDIR/owner.json" 2>/dev/null || echo null)"
107
+ }
108
+
109
+ wm_own() { printf '%s\n' "$1" > "$WM_SLOTDIR/owner.json"; }
110
+ wm_release() { rm -f "$WM_SLOTDIR/owner.json"; }
111
+
112
+ # wm_boot <kernel> <disk-source> <vcpus> <mem-mib> <boot-args>: a cold boot on a reflink clone of the source disk.
113
+ wm_boot() {
114
+ wm_fc_stop
115
+ cp --reflink=always "$2" "$WM_SLOTDIR/disk.ext4" || { wm_fail "cannot reflink $2 into the slot"; return 1; }
116
+ wm_fc_start || return 1
117
+ wm_api PUT machine-config "{\"vcpu_count\":$3,\"mem_size_mib\":$4,\"track_dirty_pages\":true}" || return 1
118
+ wm_api PUT boot-source "{\"kernel_image_path\":\"$1\",\"boot_args\":\"$5\"}" || return 1
119
+ wm_api PUT drives/rootfs "{\"drive_id\":\"rootfs\",\"path_on_host\":\"$WM_SLOTDIR/disk.ext4\",\"is_root_device\":true,\"is_read_only\":false}" || return 1
120
+ wm_api PUT network-interfaces/eth0 "{\"iface_id\":\"eth0\",\"host_dev_name\":\"$WM_TAP\",\"guest_mac\":\"06:00:AC:10:00:02\"}" || return 1
121
+ wm_api PUT actions '{"action_type":"InstanceStart"}' || return 1
122
+ printf '@{"booted":true}\n'
123
+ }
124
+
125
+ # wm_snapshot <dir> <Full|Diff>: the pause. Memory (all of it, or the pages dirtied since the last
126
+ # snapshot or load) and the device state are written, the disk is reflink-cloned beside them, and the
127
+ # guest runs again. A diff and the device state are written to memory (/dev/shm) and moved into the
128
+ # store after the guest resumes: written into the store they made the pause (447 ms for 13 MB measured
129
+ # on a loaded Mac). The disk's dirty pages are flushed before pausing, since a clone flushes them first.
130
+ wm_snapshot() {
131
+ local d="$1" mem state t0 tw tp shm="/dev/shm/wm-$WM_SLOT-$$"
132
+ mkdir -p "$d" || return 1
133
+ if [ "$2" = Diff ]; then mem="$shm.diff"; state="$shm.vmstate"; else mem="$d/mem"; state="$d/vmstate"; fi
134
+ sync "$WM_SLOTDIR/disk.ext4"
135
+ t0=$(wm_ns)
136
+ wm_api PATCH vm '{"state":"Paused"}' || return 1
137
+ tp=$(wm_ns)
138
+ if ! wm_api PUT snapshot/create "{\"snapshot_type\":\"$2\",\"snapshot_path\":\"$state\",\"mem_file_path\":\"$mem\"}"; then wm_api PATCH vm '{"state":"Resumed"}'; return 1; fi
139
+ tw=$(wm_ns)
140
+ if ! cp --reflink=always "$WM_SLOTDIR/disk.ext4" "$d/disk.ext4"; then wm_api PATCH vm '{"state":"Resumed"}'; wm_fail "cannot reflink the disk into $d"; return 1; fi
141
+ wm_api PATCH vm '{"state":"Resumed"}' || return 1
142
+ printf '@{"pauseMs":%s,"memWriteMs":%s,"diskCloneMs":%s,"memBytes":%s}\n' \
143
+ "$(( ($(wm_ns) - t0) / 1000000 ))" "$(( (tw - tp) / 1000000 ))" "$(( ($(wm_ns) - tw) / 1000000 ))" "$(du -B1 "$mem" | cut -f1)"
144
+ WM_LAST_DIFF="$mem"
145
+ if [ "$2" = Diff ]; then mv "$state" "$d/vmstate" || { rm -f "$state" "$mem"; wm_fail "cannot move the device state into $d"; return 1; }; fi
146
+ }
147
+
148
+ # wm_materialize <parent-mem> <dir>: one full memory file per entry, so a restore loads one file:
149
+ # a reflink copy of the parent's, with the diff wm_snapshot left in /dev/shm applied by Firecracker's
150
+ # snapshot-editor.
151
+ wm_materialize() {
152
+ local t0; t0=$(wm_ns)
153
+ cp --reflink=always "$1" "$2/mem.tmp" || { rm -f "$WM_LAST_DIFF"; wm_fail "cannot reflink $1"; return 1; }
154
+ if ! "$WM_SE" edit-memory rebase --memory-path "$2/mem.tmp" --diff-path "$WM_LAST_DIFF" >/dev/null; then rm -f "$WM_LAST_DIFF"; wm_fail "snapshot-editor rebase failed for $2"; return 1; fi
155
+ mv "$2/mem.tmp" "$2/mem" && rm -f "$WM_LAST_DIFF"
156
+ printf '@{"materializeMs":%s}\n' "$(wm_ms_since "$t0")"
157
+ }
158
+
159
+ # wm_restore <dir>: in place. The slot's process is replaced, the disk becomes a reflink clone of the
160
+ # snapshot's, and the memory file is loaded as the backing of guest memory, resumed.
161
+ wm_restore() {
162
+ local d="$1" t0 tl
163
+ [ -f "$d/vmstate" ] && [ -f "$d/mem" ] && [ -f "$d/disk.ext4" ] || { wm_fail "snapshot $d is incomplete"; return 1; }
164
+ t0=$(wm_ns)
165
+ wm_fc_stop
166
+ cp --reflink=always "$d/disk.ext4" "$WM_SLOTDIR/disk.ext4" || { wm_fail "cannot reflink the disk of $d"; return 1; }
167
+ wm_fc_start || return 1
168
+ tl=$(wm_ns)
169
+ wm_api PUT snapshot/load "{\"snapshot_path\":\"$d/vmstate\",\"mem_backend\":{\"backend_type\":\"File\",\"backend_path\":\"$d/mem\"},\"track_dirty_pages\":true,\"resume_vm\":true}" || return 1
170
+ printf '%s' "$d" > "$WM_SLOTDIR/loaded" # what the collector must keep while this machine runs
171
+ printf '@{"loadMs":%s,"restoreMs":%s}\n' "$(wm_ms_since "$tl")" "$(wm_ms_since "$t0")"
172
+ }
173
+
174
+ # Retention (gc.ts). The store's registry of Worlds, kept under a lock since every serve writes it.
175
+ wm_register() {
176
+ ( flock 9; python3 - "$WM_STORE/roots.json" "$1" <<'PY'
177
+ import json, os, sys
178
+ path, root = sys.argv[1:3]
179
+ roots = json.load(open(path)) if os.path.exists(path) else []
180
+ if root not in roots: roots.append(root)
181
+ tmp = path + '.tmp'; json.dump(roots, open(tmp, 'w'), indent=1); os.replace(tmp, path)
182
+ PY
183
+ ) 9> "$WM_STORE/.roots.lock" || { wm_fail "cannot register $1 in $WM_STORE/roots.json"; return 1; }
184
+ printf '@{"registered":%s}\n' "$(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
185
+ }
186
+
187
+ # The store as the collector sees it: registered Worlds, snapshot directories (those a checkpoint is still
188
+ # writing marked pending), warm bases, and what each slot's machine was loaded from.
189
+ wm_store_list() {
190
+ python3 - "$WM_STORE" <<'PY'
191
+ import json, os, sys
192
+ s = sys.argv[1]
193
+ ls = lambda d: sorted(os.listdir(d)) if os.path.isdir(d) else []
194
+ snaps = [n for n in ls(os.path.join(s, 'snapshots')) if os.path.isdir(os.path.join(s, 'snapshots', n))]
195
+ loaded = []
196
+ for slot in ls(os.path.join(s, 'slots')):
197
+ # only a slot whose Firecracker is running holds what it was loaded from
198
+ f, pid = os.path.join(s, 'slots', slot, 'loaded'), os.path.join(s, 'slots', slot, 'fc.pid')
199
+ try: running = open('/proc/%s/comm' % open(pid).read().strip()).read().startswith('firecracker')
200
+ except OSError: running = False
201
+ if running:
202
+ try: loaded.append(open(f).read().strip())
203
+ except OSError: pass
204
+ # a container slot being restored or prepared holds the snapshot it is reading (ct.sh wm_ct_restore, container.ts
205
+ # prepare), and a prepared slot the snapshot it hands over, until the branch adopting it names it in its ledger; a
206
+ # container slot holds what its machine was restored from (ct-loaded) until that machine's service stops
207
+ for name in ('restoring', 'preparing', 'ct-loaded', 'comparing'): # comparing: ct.sh wm_ct_compare's mount
208
+ try: loaded.append(open(os.path.join(s, 'slots', slot, name)).read().strip())
209
+ except OSError: pass
210
+ try: handover = json.load(open(os.path.join(s, 'slots', slot, 'owner.json'))).get('handover')
211
+ except (OSError, ValueError, AttributeError): handover = None
212
+ if handover: loaded.append(handover)
213
+ roots = json.load(open(os.path.join(s, 'roots.json'))) if os.path.exists(os.path.join(s, 'roots.json')) else []
214
+ # each container base's (or base build's) filled tree; one deleted during the scan names none
215
+ base_trees = {}
216
+ for n in ls(s):
217
+ if not n.startswith('base-'): continue
218
+ try: base_trees[n] = open(os.path.join(s, n, 'tree')).read().strip()
219
+ except OSError: pass
220
+ print('@' + json.dumps({'roots': roots, 'snapshots': snaps, 'pending': [n for n in snaps if os.path.exists(os.path.join(s, 'snapshots', n, '.pending'))],
221
+ 'bases': [n for n in ls(s) if n.startswith('base-')], 'trees': [n for n in ls(s) if n.startswith('tree-')],
222
+ 'baseTrees': base_trees, 'loaded': loaded}))
223
+ PY
224
+ }
225
+
226
+ # wm_gc_delete <dropped-roots-json> <store-relative path...>: delete, drop the gone registrations, give the
227
+ # space back to the host (fstrim on the store's filesystem), and report the store's use before and after.
228
+ wm_gc_delete() {
229
+ local dropped="$1" before after p mnt trimmed=""; shift
230
+ sync -f "$WM_STORE"; before=$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')
231
+ for p in "$@"; do case "$p" in snapshots/snap-*|base-*|tree-*) rm -rf -- "${WM_STORE:?}/$p" ;; *) wm_fail "refusing to delete $p"; return 1 ;; esac; done
232
+ ( flock 9; python3 - "$WM_STORE/roots.json" "$dropped" <<'PY'
233
+ import json, os, sys
234
+ path, dropped = sys.argv[1], set(json.loads(sys.argv[2]))
235
+ if os.path.exists(path):
236
+ roots = [r for r in json.load(open(path)) if r not in dropped]
237
+ tmp = path + '.tmp'; json.dump(roots, open(tmp, 'w'), indent=1); os.replace(tmp, path)
238
+ PY
239
+ ) 9> "$WM_STORE/.roots.lock"
240
+ mnt=$(findmnt -n -o TARGET --target "$WM_STORE")
241
+ # the deletes committed first: btrfs frees their extents only then, and a trim before it gave nothing back (measured:
242
+ # 1.66 GB deleted, the store's image unchanged until a later trim returned 1.45 GB)
243
+ sync -f "$WM_STORE"
244
+ trimmed=$(sudo -n fstrim -v "$mnt" 2>&1 | tail -1)
245
+ sync -f "$WM_STORE"; after=$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')
246
+ printf '@{"usedBefore":%s,"usedAfter":%s,"trimmed":%s}\n' "$before" "$after" "$(printf '%s' "$trimmed" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
247
+ }
248
+
249
+ # wm_room <bytes> [path...]: refused (wm_fail) unless every filesystem a store write lands on has <bytes> free: the
250
+ # store, the file backing it when it is a loop image (a sparse XFS image promises room its backing filesystem may not
251
+ # have), and each path named (on a Mac, the home the Lima VM mounts: its free space is the Mac's, beneath the VM's own
252
+ # sparse disk). A write the lowest layer cannot hold fails as an I/O error in the layers above it, which is how a
253
+ # store is corrupted; a mark refused first is only a mark not taken.
254
+ wm_room() {
255
+ local need="$1" p dev back line av tg least="" at=""; shift
256
+ set -- "$WM_STORE" "$@"
257
+ dev=$(findmnt -no SOURCE --target "$WM_STORE" 2>/dev/null); dev=${dev%%[*} # btrfs names a subvolume: /dev/loop0[/sub]
258
+ case "$dev" in /dev/loop*) back=$(losetup -nO BACK-FILE "$dev" 2>/dev/null | tr -d ' '); [ -n "$back" ] && set -- "$@" "$(dirname "$back")" ;; esac
259
+ for p in "$@"; do
260
+ [ -e "$p" ] || continue
261
+ line=$(df -B1 --output=avail,target "$p" 2>/dev/null | tail -1) || continue
262
+ read -r av tg <<<"$line"
263
+ if [ -z "$least" ] || [ "$av" -lt "$least" ]; then least=$av; at=$tg; fi
264
+ done
265
+ [ -n "$least" ] || { wm_fail "cannot read the free space under $WM_STORE"; return 1; }
266
+ [ "$least" -ge "$need" ] || { wm_fail "no room for a mark: $at has $((least / 1048576)) MB free and a mark needs about $((need / 1048576)) MB there (its estimate and the reserve); nothing was written. Free space there, or collect the snapshots no branch names (world-machine gc)"; return 1; }
267
+ printf '@{"room":%s,"at":"%s"}\n' "$least" "$at"
268
+ }
269
+
270
+ # the store's used bytes, after a syncfs: XFS frees an unlinked file's blocks in the background, and
271
+ # a df read without it counted a just-deleted memory diff (+43 MB measured)
272
+ wm_used() { sync -f "$WM_STORE"; printf '@{"used":%s}\n' "$(df -B1 --output=used "$WM_STORE" | tail -1 | tr -d ' ')"; }
273
+
274
+ # wm_egress <host-port>=<target-host>:<target-port> ...: the guest's only way out. Each stable
275
+ # guest-visible port on the tap's host address forwards to one twin of the World that owns the slot.
276
+ wm_egress() {
277
+ local pid m
278
+ # unchanged and all alive: nothing to re-point
279
+ if [ "$*" = "$(cat "$WM_SLOTDIR/egress.spec" 2>/dev/null)" ] && [ -s "$WM_SLOTDIR/egress.pids" ]; then
280
+ m=0; for pid in $(cat "$WM_SLOTDIR/egress.pids"); do [ "$(cat /proc/$pid/comm 2>/dev/null)" = socat ] || m=1; done
281
+ [ "$m" = 0 ] && { printf '@{"forwarders":%s,"unchanged":true}\n' "$#"; return 0; }
282
+ fi
283
+ for pid in $(cat "$WM_SLOTDIR/egress.pids" 2>/dev/null); do
284
+ [ -r "/proc/$pid/comm" ] && [ "$(cat /proc/$pid/comm)" = socat ] && kill "$pid" 2>/dev/null
285
+ done
286
+ : > "$WM_SLOTDIR/egress.pids"
287
+ for m in "$@"; do
288
+ # to this host's loopback, from 127.0.0.2: the World's proxy knows a machine's connection by it and keeps the
289
+ # machine off this host's own network (world-runtime redirect-proxy.ts isGuestSource)
290
+ local src=""; case "${m#*=}" in 127.*|localhost:*) src=",bind=127.0.0.2" ;; esac
291
+ # 9>&-: the container backend's slot lock (host.ts) is never held by a forwarder that outlives the shell
292
+ setsid bash -c 'echo $$ >> "$1"; shift; exec "$@"' _ "$WM_SLOTDIR/egress.pids" socat "TCP-LISTEN:${m%%=*},bind=$WM_HOSTIP,fork,reuseaddr" "TCP:${m#*=}$src" > /dev/null 2>&1 < /dev/null 9>&- &
293
+ done
294
+ printf '%s' "$*" > "$WM_SLOTDIR/egress.spec"
295
+ printf '@{"forwarders":%s}\n' "$#"
296
+ }
297
+
298
+ # The guest agent (agent.py, inside the guest): the clock and the World's env file.
299
+ wm_agent() { python3 "$WM_SLOTDIR/agentc.py" "$WM_GUESTIP" "$WM_AGENT_PORT" "$@"; }
300
+
301
+ # bounded: under load a guest ssh login has taken 18 s
302
+ wm_gssh() { timeout "${WM_GSSH_TIMEOUT:-20}" ssh -q -i "$WM_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=2 -o BatchMode=yes "root@$WM_GUESTIP" "$@"; }
@@ -0,0 +1,25 @@
1
+ export declare const LEDGER = "machine";
2
+ export type SnapshotRecord = {
3
+ id: string;
4
+ label: string;
5
+ type: 'Full' | 'Diff';
6
+ /** the entry this one's memory diff was taken against, or the warm base */
7
+ parent: string;
8
+ base: string;
9
+ /** directory of vmstate, mem and disk.ext4, relative to the store */
10
+ dir: string;
11
+ memBytes: number;
12
+ diskBytes: number;
13
+ storeBytes: number;
14
+ pauseMs: number;
15
+ created: string;
16
+ /** taken to be kept (a mark the agent pinned): retention (`thin`) never lets it go */
17
+ keep?: boolean;
18
+ };
19
+ export declare function appendSnapshot(root: string, record: SnapshotRecord): Promise<void>;
20
+ /** The last snapshot in this branch's view of the ledger, or null. */
21
+ export declare function headSnapshot(root: string): SnapshotRecord | null;
22
+ /** The head a branch forked from this one at `position` (entries of this branch's whole log) starts with: what
23
+ * `world branch --at machine@<position>` gives the branch's machine to restore. */
24
+ export declare function headAt(root: string, position: number): SnapshotRecord | null;
25
+ export declare const worldInstant: () => string;
@@ -0,0 +1,38 @@
1
+ // The machine's ledger: one World state service, `machine`, one entry per checkpoint, written through
2
+ // the kernel's own write path (applyTwinWrite, as every twin records its writes). The World's `mark`
3
+ // finds it like any twin's log and `branch --at machine@N` forks it at N; the head a branch restores is
4
+ // the last snapshot in the branch's view, its parent's history up to the fork position and its own
5
+ // entries after it.
6
+ import { applyTwinWrite, wholeLog, worldNow } from '@volter/world-core';
7
+ export const LEDGER = 'machine';
8
+ export async function appendSnapshot(root, record) {
9
+ const { id, ...fields } = record;
10
+ await applyTwinWrite(LEDGER, {
11
+ operation: 'snapshot.create',
12
+ subjectType: 'snapshot',
13
+ subjectId: id,
14
+ fields,
15
+ occurredAt: record.created,
16
+ actor: { kind: 'system', id: 'world-machine' },
17
+ }, root);
18
+ }
19
+ /** The last snapshot in this branch's view of the ledger, or null. */
20
+ export function headSnapshot(root) {
21
+ return headOf(wholeLog(LEDGER, root));
22
+ }
23
+ /** The head a branch forked from this one at `position` (entries of this branch's whole log) starts with: what
24
+ * `world branch --at machine@<position>` gives the branch's machine to restore. */
25
+ export function headAt(root, position) {
26
+ const seen = new Set();
27
+ return headOf(wholeLog(LEDGER, root).filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true))).slice(0, position));
28
+ }
29
+ function headOf(log) {
30
+ const reverted = new Set(log.filter((e) => e.op === 'revert' && e.revertsActionId).map((e) => e.revertsActionId));
31
+ for (let i = log.length - 1; i >= 0; i--) {
32
+ const e = log[i];
33
+ if (e.op === 'set' && e.subject.type === 'snapshot' && !reverted.has(e.id))
34
+ return { id: e.subject.id, ...e.fields };
35
+ }
36
+ return null;
37
+ }
38
+ export const worldInstant = () => worldNow();
@@ -0,0 +1,82 @@
1
+ import type { MachineConfig } from './config.js';
2
+ import { HostSession } from './host.js';
3
+ type Loaded = {
4
+ id: string;
5
+ label: string;
6
+ dir: string;
7
+ } | null;
8
+ /** Rewritten with a new id at each restore, before stale connections are reset (see guestFiles). */
9
+ export declare const RESTORE_ID_FILE = "/run/world-machine/restore-id";
10
+ export type MachineState = 'idle' | 'running' | 'displaced' | 'no-base' | 'error';
11
+ /** A machine-host path as one shell word: `~/x` expands on that host. */
12
+ export declare const hp: (path: string) => string;
13
+ /** Every `<VENDOR>_TWIN_URL` the World handed this service. */
14
+ export declare function twinUrls(env: Record<string, string | undefined>): Record<string, URL>;
15
+ /** A stable guest-visible port per vendor: derived from the name (FNV-1a into 20000-29999), a
16
+ * collision moving the later name in sorted order up by one; a configured port wins. */
17
+ export declare function egressPorts(vendors: string[], fixed: Record<string, number>): Record<string, number>;
18
+ /** The guest kernel's command line. The guest's address is on-link with the tap's host end and it
19
+ * has no gateway: no route leads anywhere but the machine host. */
20
+ export declare const bootArgs: (config: MachineConfig) => string;
21
+ export declare function baseHash(config: MachineConfig, session: HostSession): Promise<string>;
22
+ export declare class Machine {
23
+ readonly config: MachineConfig;
24
+ readonly root: string;
25
+ readonly env: Record<string, string | undefined>;
26
+ readonly port: number;
27
+ state: MachineState;
28
+ message: string | undefined;
29
+ loaded: Loaded;
30
+ protected base: string | undefined;
31
+ protected lastUsed: Promise<number>;
32
+ protected readonly token: string;
33
+ /** This load's restore id (RESTORE_ID_FILE); a backend that can write it before the machine runs sets it first. */
34
+ protected restoreId: string;
35
+ readonly session: HostSession;
36
+ protected readonly ports: Record<string, number>;
37
+ protected readonly twins: Record<string, URL>;
38
+ constructor(config: MachineConfig, root: string, env: Record<string, string | undefined>, port: number);
39
+ /** What the guest sees: vendor → its stable URL on the tap's host address. */
40
+ egress(): Record<string, string>;
41
+ /** The World's live env (VOLTER_WORLD_ENV_FILE), `export K='v'` lines. */
42
+ protected worldEnv(): Record<string, string>;
43
+ /** The World's proxy as the machine reaches it, read from its live env at each load: its guests' listener
44
+ * (`VOLTER_WORLD_GUEST_PROXY`, where every connection is kept off this host's own network, whatever NAT makes its
45
+ * source look like; world-runtime redirect-proxy.ts), else, from an older World, its one listener. */
46
+ protected proxy(): URL | null;
47
+ /** What the guest is given at each start and restore: /etc/world.env, the World's live env as the guest sees it,
48
+ * and the files it names. Twin URLs become the stable egress ones, the World's proxy its forwarder (the twins'
49
+ * own addresses bypass it), NODE_OPTIONS the guest's copy of the redirect, and a value naming a certificate file on the
50
+ * World's host (its CA and trust bundle, PEM) a copy of it under /etc; what only that host can use (a
51
+ * directory there, a loopback URL of its other listeners) is left out and named, and an empty value is left unset. */
52
+ protected guestFiles(): Record<string, string>;
53
+ protected forwarders(): string;
54
+ protected owner(): string;
55
+ protected baseDir(): string;
56
+ /** The machine, brought up when first asked for: nothing runs until a door needs it, so a new branch's
57
+ * machine is loaded once, by the restore that retakes to its mark, instead of booting its head at start and
58
+ * being replaced a moment later. `GET /machine` and `checkpoint` bring up the head if the ledger has one,
59
+ * else the warm base; `restore` loads the head itself. Returns the restore's facts when it brought it up. */
60
+ ensure(): Promise<Record<string, unknown> | null>;
61
+ /** This branch's head, loaded in place; with no head, the warm base. */
62
+ restore(): Promise<Record<string, unknown>>;
63
+ /** Is the slot still this service's? A later restore by another World's machine takes it. */
64
+ refresh(): Promise<void>;
65
+ checkpoint(label: string, keep?: boolean): Promise<Record<string, unknown>>;
66
+ /** Refused (507) unless every filesystem beneath the store has room for a mark: the last mark's store growth, at
67
+ * least `firstMarkBytes` when there is none, plus the configured reserve. Checked before anything is written. */
68
+ protected room(firstMarkBytes?: number): Promise<void>;
69
+ /** For each position of this machine's ledger (a mark's `machine@N`), the snapshot a branch forked there would restore
70
+ * and whether the store still holds it: gc with `retain` lets old ones go. */
71
+ present(positions: number[]): Promise<Array<{
72
+ position: number;
73
+ id: string | null;
74
+ present: boolean;
75
+ }>>;
76
+ /** The guest's wall clock against the World's, now. */
77
+ clock(): Promise<Record<string, unknown>>;
78
+ status(): Record<string, unknown>;
79
+ /** On service stop: the slot is released only if it is still this service's. */
80
+ stop(): Promise<void>;
81
+ }
82
+ export {};