@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/host.sh
ADDED
|
@@ -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 {};
|