@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/ct.sh
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
# The container backend's half of the machine host (backend "container"), sourced after host.sh. The machine is a
|
|
2
|
+
# container run by crun with CRIU, with no VM inside the host. On a Mac a VM inside the Lima VM faults every first
|
|
3
|
+
# touch of memory through two hypervisors (a real project's install ran 20–80x slower), so the machine is the
|
|
4
|
+
# host's own processes instead.
|
|
5
|
+
# the tree one ext4 image per slot ($WM_SLOTDIR/root.img, mounted at $WM_SLOTDIR/root): the container's root and
|
|
6
|
+
# the emulated root both live in it, so a mark is one reflink of one file however many files it holds
|
|
7
|
+
# and a restore brings back the same blocks, keeping inode numbers (and an NFS client's handles) valid
|
|
8
|
+
# the net a bridge $WM_TAP at $WM_HOSTIP and a network namespace holding $WM_GUESTIP: the Firecracker guest's
|
|
9
|
+
# addresses, so the egress forwarders and the doors into the machine are unchanged
|
|
10
|
+
# a mark the container paused, the image reflinked, the processes dumped by CRIU, the container resumed
|
|
11
|
+
# a retake the container gone, the mark's image reflinked back and mounted, the processes restored, and the
|
|
12
|
+
# connections the restore left stale (their far end was replaced) reset as the Firecracker guest agent
|
|
13
|
+
# resets them
|
|
14
|
+
WM_CT="wm-$WM_SLOT"
|
|
15
|
+
WM_NETNS="wm-$WM_SLOT"
|
|
16
|
+
WM_ROOTIMG="$WM_SLOTDIR/root.img"
|
|
17
|
+
WM_ROOT="$WM_SLOTDIR/root"
|
|
18
|
+
WM_BUNDLE="$WM_SLOTDIR/bundle"
|
|
19
|
+
|
|
20
|
+
# the bridge and the namespace, made once per boot of the host
|
|
21
|
+
wm_ct_net() {
|
|
22
|
+
if ! ip link show "$WM_TAP" >/dev/null 2>&1; then
|
|
23
|
+
sudo -n ip link add "$WM_TAP" type bridge && sudo -n ip addr add "$WM_HOSTIP/30" dev "$WM_TAP" && sudo -n ip link set "$WM_TAP" up || { wm_fail "cannot make the bridge $WM_TAP"; return 1; }
|
|
24
|
+
fi
|
|
25
|
+
if [ ! -e "/var/run/netns/$WM_NETNS" ]; then
|
|
26
|
+
sudo -n ip netns add "$WM_NETNS" && sudo -n ip link add "${WM_TAP}p" type veth peer name eth0 netns "$WM_NETNS" \
|
|
27
|
+
&& sudo -n ip link set "${WM_TAP}p" master "$WM_TAP" && sudo -n ip link set "${WM_TAP}p" up \
|
|
28
|
+
&& sudo -n ip -n "$WM_NETNS" addr add "$WM_GUESTIP/30" dev eth0 && sudo -n ip -n "$WM_NETNS" link set eth0 up \
|
|
29
|
+
&& sudo -n ip -n "$WM_NETNS" link set lo up && sudo -n ip -n "$WM_NETNS" route add default via "$WM_HOSTIP" \
|
|
30
|
+
|| { wm_fail "cannot make the network namespace $WM_NETNS"; return 1; }
|
|
31
|
+
fi
|
|
32
|
+
printf '@{"net":"%s"}\n' "$WM_NETNS"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
wm_ct_mount() {
|
|
36
|
+
# outside a restore's swap, a server a killed restore left stopped starts again (inside it, the restore does, once exported)
|
|
37
|
+
[ -n "${WM_SWAP:-}" ] || wm_ct_nfsd_resume
|
|
38
|
+
mkdir -p "$WM_ROOT"
|
|
39
|
+
# the tree's loop reads its image with direct I/O (4096-byte sectors, which direct I/O on the store needs): through
|
|
40
|
+
# the page cache the tree's files were cached once per layer (the tree, its image on the store, the store's image)
|
|
41
|
+
if ! mountpoint -q "$WM_ROOT"; then
|
|
42
|
+
local dev; dev=$(sudo -n losetup --find --show --direct-io=on --sector-size 4096 "$WM_ROOTIMG") && sudo -n mount "$dev" "$WM_ROOT" \
|
|
43
|
+
|| { [ -n "${dev:-}" ] && sudo -n losetup -d "$dev"; wm_fail "cannot mount $WM_ROOTIMG"; return 1; }
|
|
44
|
+
fi
|
|
45
|
+
wm_ct_export
|
|
46
|
+
}
|
|
47
|
+
wm_ct_umount() {
|
|
48
|
+
wm_ct_unexport
|
|
49
|
+
mountpoint -q "$WM_ROOT" || return 0
|
|
50
|
+
local dev; dev=$(findmnt -no SOURCE "$WM_ROOT")
|
|
51
|
+
sudo -n umount "$WM_ROOT" || sudo -n umount -l "$WM_ROOT" || return 1
|
|
52
|
+
case "$dev" in /dev/loop*) sudo -n losetup -d "$dev" 2>/dev/null ;; esac; return 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# WM_EXPORT ({"path","client","uid","gid"}, from the config's `export`): a path in the tree served by the host's NFS
|
|
56
|
+
# server (the container shares the host kernel, so the server cannot run inside it) with a fixed fsid: a restore brings
|
|
57
|
+
# back the same blocks, so inode numbers, and an NFS client's handles on unchanged files, stay valid across it
|
|
58
|
+
wm_ct_export() {
|
|
59
|
+
[ -n "${WM_EXPORT:-}" ] || return 0
|
|
60
|
+
# the path is made by the base's provisioning: until then there is nothing to serve (exportfs refuses a missing path,
|
|
61
|
+
# and a stale entry for one blocks every later exportfs)
|
|
62
|
+
local path; path="$WM_ROOT/$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["path"].lstrip("/"))' "$WM_EXPORT")"
|
|
63
|
+
[ -d "$path" ] || { wm_ct_unexport; return 0; }
|
|
64
|
+
python3 - "$WM_EXPORT" "$WM_ROOT" "$WM_SLOT" <<'PY' | sudo -n tee "/etc/exports.d/world-machine-$WM_SLOT.exports" >/dev/null
|
|
65
|
+
import json, sys
|
|
66
|
+
e, root, slot = json.loads(sys.argv[1]), sys.argv[2], sys.argv[3]
|
|
67
|
+
print(f'{root}/{e["path"].lstrip("/")} {e["client"]}(rw,sync,insecure,no_subtree_check,all_squash,anonuid={e["uid"]},anongid={e["gid"]},fsid={e.get("fsid", 7101)})')
|
|
68
|
+
PY
|
|
69
|
+
sudo -n exportfs -ra
|
|
70
|
+
}
|
|
71
|
+
wm_ct_unexport() {
|
|
72
|
+
[ -f "/etc/exports.d/world-machine-$WM_SLOT.exports" ] || return 0
|
|
73
|
+
sudo -n rm -f "/etc/exports.d/world-machine-$WM_SLOT.exports"; sudo -n exportfs -ra
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# wm_ct_bundle [argv...]: the OCI bundle: the image's tree as the root, the slot's namespace as the network, every
|
|
77
|
+
# other namespace new, io_uring answered ENOSYS (CRIU cannot dump one); the process is the machine's init (world-machine-init, installed by the base) unless argv names
|
|
78
|
+
# another (the base's provisioning run)
|
|
79
|
+
wm_ct_bundle() {
|
|
80
|
+
mkdir -p "$WM_BUNDLE"
|
|
81
|
+
python3 - "$WM_BUNDLE/config.json" "$WM_ROOT" "/var/run/netns/$WM_NETNS" "${WM_BINDS:-[]}" "$@" <<'PY'
|
|
82
|
+
import json, sys
|
|
83
|
+
out, root, netns, binds = sys.argv[1:5]
|
|
84
|
+
sys.argv = sys.argv[:4] + sys.argv[5:]
|
|
85
|
+
argv = sys.argv[4:] or ["/usr/local/sbin/world-machine-init"]
|
|
86
|
+
caps = ["CAP_CHOWN","CAP_DAC_OVERRIDE","CAP_FSETID","CAP_FOWNER","CAP_MKNOD","CAP_NET_RAW","CAP_SETGID","CAP_SETUID",
|
|
87
|
+
"CAP_SETFCAP","CAP_SETPCAP","CAP_NET_BIND_SERVICE","CAP_SYS_CHROOT","CAP_KILL","CAP_AUDIT_WRITE","CAP_SYS_PTRACE","CAP_NET_ADMIN"]
|
|
88
|
+
config = {
|
|
89
|
+
"ociVersion": "1.0.2",
|
|
90
|
+
"process": {"terminal": False, "user": {"uid": 0, "gid": 0}, "args": argv,
|
|
91
|
+
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root"], "cwd": "/",
|
|
92
|
+
"capabilities": {k: caps for k in ("bounding", "effective", "permitted")}, "noNewPrivileges": False},
|
|
93
|
+
"root": {"path": root, "readonly": False},
|
|
94
|
+
"hostname": "machine",
|
|
95
|
+
"mounts": [
|
|
96
|
+
{"destination": "/proc", "type": "proc", "source": "proc"},
|
|
97
|
+
{"destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]},
|
|
98
|
+
{"destination": "/dev/pts", "type": "devpts", "source": "devpts", "options": ["nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620"]},
|
|
99
|
+
{"destination": "/dev/shm", "type": "tmpfs", "source": "shm", "options": ["nosuid", "noexec", "nodev", "mode=1777", "size=268435456"]},
|
|
100
|
+
{"destination": "/sys", "type": "sysfs", "source": "sysfs", "options": ["nosuid", "noexec", "nodev", "ro"]},
|
|
101
|
+
] + [{"destination": b["destination"], "type": "bind", "source": b["source"], "options": ["rbind", "ro"]} for b in json.loads(binds)],
|
|
102
|
+
"linux": {"namespaces": [{"type": "pid"}, {"type": "ipc"}, {"type": "uts"}, {"type": "mount"}, {"type": "cgroup"}, {"type": "network", "path": netns}],
|
|
103
|
+
# CRIU cannot dump an io_uring (measured: CRIU 4.2 refused next dev under Node 24, whose libuv uses one on
|
|
104
|
+
# kernel 7.0): the machine's processes are told it does not exist, and libuv and the rest fall back
|
|
105
|
+
"seccomp": {"defaultAction": "SCMP_ACT_ALLOW",
|
|
106
|
+
"syscalls": [{"names": ["io_uring_setup", "io_uring_enter", "io_uring_register"], "action": "SCMP_ACT_ERRNO", "errnoRet": 38}]}},
|
|
107
|
+
}
|
|
108
|
+
json.dump(config, open(out, "w"), indent=1)
|
|
109
|
+
PY
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
wm_ct_state() { sudo -n crun state "$WM_CT" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null; }
|
|
113
|
+
wm_ct_running() { printf '@{"container":"%s","owner":%s}\n' "$(wm_ct_state)" "$(cat "$WM_SLOTDIR/owner.json" 2>/dev/null || echo null)"; }
|
|
114
|
+
wm_ct_stop() { [ -n "$(wm_ct_state)" ] || return 0; sudo -n crun kill -a "$WM_CT" KILL 2>/dev/null; sudo -n crun delete -f "$WM_CT" >/dev/null 2>&1; }
|
|
115
|
+
|
|
116
|
+
# wm_ct_mark <dir> [parent]: a mark into <dir> (root.img and criu/), the container running on afterwards; with <parent>
|
|
117
|
+
# (the mark the machine was last marked or restored at), the dump is then composed against the parent's
|
|
118
|
+
# (wm_ct_compose), detached. Every mark dumps: whether nothing in the machine ran since the parent cannot be told
|
|
119
|
+
# exactly (a freeze and thaw run the processes' interrupted syscalls again, costing user time and a context switch per
|
|
120
|
+
# thread, as a small real wake-up does), and a mark that reused a dump could claim a state the machine never held.
|
|
121
|
+
# A mark of an unchanged machine costs a dump's pause and, composed, almost no store (measured: 0-2 MB).
|
|
122
|
+
wm_ct_mark() {
|
|
123
|
+
local dir="$1" parent="${2:-}" t0 tp tr tc
|
|
124
|
+
mkdir -p "$dir/criu" || return 1
|
|
125
|
+
t0=$(wm_ns)
|
|
126
|
+
sudo -n crun pause "$WM_CT" || { wm_fail "cannot pause $WM_CT"; return 1; }
|
|
127
|
+
sync -f "$WM_ROOT"; sudo -n fsfreeze -f "$WM_ROOT"
|
|
128
|
+
tp=$(wm_ns)
|
|
129
|
+
cp --reflink=always "$WM_ROOTIMG" "$dir/root.img"; local rc=$?
|
|
130
|
+
sudo -n fsfreeze -u "$WM_ROOT"
|
|
131
|
+
tr=$(wm_ns)
|
|
132
|
+
[ $rc = 0 ] || { sudo -n crun resume "$WM_CT"; wm_fail "cannot reflink the tree into $dir"; return 1; }
|
|
133
|
+
# CRIU dumps the paused tree and leaves it as it found it; the resume below lets it run on
|
|
134
|
+
sudo -n crun checkpoint --leave-running --tcp-established --file-locks --image-path "$dir/criu" --work-path "$dir/criu" "$WM_CT" >"$dir/criu/dump.out" 2>&1 \
|
|
135
|
+
|| { sudo -n crun resume "$WM_CT" 2>/dev/null; wm_fail "CRIU could not dump $WM_CT: $(tail -3 "$dir/criu/dump.out")"; return 1; }
|
|
136
|
+
[ "$(wm_ct_state)" = paused ] && sudo -n crun resume "$WM_CT"
|
|
137
|
+
tc=$(wm_ns)
|
|
138
|
+
[ -n "$parent" ] && wm_ct_compose "$parent" "$dir"
|
|
139
|
+
printf '@{"pauseMs":%s,"treeMs":%s,"dumpMs":%s,"memBytes":%s}\n' "$(( (tc - t0) / 1000000 ))" "$(( (tr - tp) / 1000000 ))" "$(( (tc - tr) / 1000000 ))" \
|
|
140
|
+
"$(du -sb "$dir/criu" | cut -f1)"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# wm_ct_publish <ports JSON>: the machine's allowed listening ports on this host's loopback (publish.py, as root, one per
|
|
144
|
+
# slot). The running publisher is trusted only when its pid is a publish.py of the current version; otherwise it is
|
|
145
|
+
# stopped and started again. wm_ct_unpublish stops it (its socat children die with it).
|
|
146
|
+
wm_ct_publisher() { local pid; pid=$(cat "$WM_SLOTDIR/publish.pid" 2>/dev/null) || return 1
|
|
147
|
+
[ -n "$pid" ] && tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "$WM_SLOTDIR/publish.py" && echo "$pid"; }
|
|
148
|
+
wm_ct_publish() {
|
|
149
|
+
# the version and ports the running publisher was started with, which it writes itself once it holds the slot's lock
|
|
150
|
+
local pid sum; sum=$(sha256sum "$WM_SLOTDIR/publish.py" | cut -c1-16)$(printf '%s' "$1" | sha256sum | cut -c1-16)
|
|
151
|
+
pid=$(wm_ct_publisher) && [ "$(cat "$WM_SLOTDIR/publish.sum" 2>/dev/null)" = "$sum" ] && return 0
|
|
152
|
+
wm_ct_unpublish
|
|
153
|
+
sudo -n setsid nohup python3 "$WM_SLOTDIR/publish.py" "$WM_SLOTDIR" "$WM_NETNS" "$WM_CT" "$1" "$sum" >> "$WM_SLOTDIR/publish.log" 2>&1 < /dev/null 9>&- &
|
|
154
|
+
}
|
|
155
|
+
wm_ct_unpublish() {
|
|
156
|
+
local pid; pid=$(wm_ct_publisher) || { rm -f "$WM_SLOTDIR/publish.pid"; return 0; }
|
|
157
|
+
sudo -n kill "$pid" 2>/dev/null
|
|
158
|
+
local i; for i in $(seq 1 30); do sudo -n kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
|
159
|
+
sudo -n kill -9 "$pid" 2>/dev/null; rm -f "$WM_SLOTDIR/publish.pid"; return 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# wm_ct_compose <parent> <dir>: the mark's page files composed against its parent's (compose.py), detached
|
|
163
|
+
wm_ct_compose() {
|
|
164
|
+
[ -f "$1/criu/inventory.img" ] || return 0
|
|
165
|
+
# idle I/O priority: the next mark's pause must not wait behind it
|
|
166
|
+
sudo -n setsid nohup nice -n 10 ionice -c 3 python3 "$WM_SLOTDIR/compose.py" "$1/criu" "$2/criu" > "$2/compose.log" 2>&1 < /dev/null 9>&- &
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# the NFS server's threads, stopped for a restore's swap, started again from the count the slot kept
|
|
171
|
+
wm_ct_nfsd_resume() {
|
|
172
|
+
[ -f "$WM_SLOTDIR/nfsd.threads" ] || return 0
|
|
173
|
+
sudo -n rpc.nfsd "$(cat "$WM_SLOTDIR/nfsd.threads")" && rm -f "$WM_SLOTDIR/nfsd.threads"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# wm_ct_restore <dir> [restore id]: the machine brought back to the mark in <dir>, the id in its tree (RESTORE_ID_FILE)
|
|
177
|
+
# before its processes run
|
|
178
|
+
wm_ct_restore() {
|
|
179
|
+
local dir="$1" rid="${2:-}" t0 tl tr threads="" WM_SWAP=1
|
|
180
|
+
t0=$(wm_ns)
|
|
181
|
+
# the snapshot being read is held against a collection until the processes are back (gc reads this marker)
|
|
182
|
+
printf '%s' "$dir" > "$WM_SLOTDIR/restoring"
|
|
183
|
+
# with an export, the NFS server's threads stop for the swap: a client (the Mac's hard mount) then waits and retries
|
|
184
|
+
# instead of being answered with an error for the moment the tree is not exported (measured: one of 60 reads failed).
|
|
185
|
+
# The count is kept in the slot first, so a restore killed mid-swap is undone by the next one (wm_ct_nfsd_resume).
|
|
186
|
+
# The server is the host's: every export on it waits out the swap.
|
|
187
|
+
wm_ct_nfsd_resume
|
|
188
|
+
if [ -n "${WM_EXPORT:-}" ]; then
|
|
189
|
+
threads=$(cat /proc/fs/nfsd/threads 2>/dev/null)
|
|
190
|
+
if [ "${threads:-0}" -gt 0 ]; then printf '%s' "$threads" > "$WM_SLOTDIR/nfsd.threads" && sudo -n rpc.nfsd 0; fi
|
|
191
|
+
fi
|
|
192
|
+
wm_ct_stop; wm_ct_umount || { rm -f "$WM_SLOTDIR/restoring"; wm_ct_nfsd_resume; wm_fail "cannot unmount $WM_ROOT"; return 1; }
|
|
193
|
+
cp --reflink=always "$dir/root.img" "$WM_ROOTIMG.next" && mv -f "$WM_ROOTIMG.next" "$WM_ROOTIMG" || { rm -f "$WM_SLOTDIR/restoring"; wm_ct_nfsd_resume; wm_fail "cannot reflink $dir/root.img"; return 1; }
|
|
194
|
+
wm_ct_mount; local mrc=$?
|
|
195
|
+
wm_ct_nfsd_resume
|
|
196
|
+
[ $mrc = 0 ] || { rm -f "$WM_SLOTDIR/restoring"; return 1; }
|
|
197
|
+
[ -n "$rid" ] && printf '{"/run/world-machine/restore-id": "%s\\n"}' "$rid" | wm_ct_write >/dev/null
|
|
198
|
+
wm_ct_bundle
|
|
199
|
+
tl=$(wm_ns)
|
|
200
|
+
sudo -n crun restore -d --tcp-established --file-locks --image-path "$dir/criu" --work-path "$WM_SLOTDIR" --bundle "$WM_BUNDLE" "$WM_CT" >"$WM_SLOTDIR/restore.out" 2>&1 \
|
|
201
|
+
|| { rm -f "$WM_SLOTDIR/restoring"; wm_fail "CRIU could not restore $WM_CT from $dir: $(tail -3 "$WM_SLOTDIR/restore.out")"; return 1; }
|
|
202
|
+
rm -f "$WM_SLOTDIR/restoring"
|
|
203
|
+
tr=$(wm_ns)
|
|
204
|
+
printf '@{"loadMs":%s,"restoreMs":%s}\n' "$(( (tl - t0) / 1000000 ))" "$(( (tr - tl) / 1000000 ))"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# the connections the restore left open to the host (a twin, a door, the World), whose far end was replaced:
|
|
208
|
+
# reset so the programs holding them reconnect at once, as the Firecracker guest agent's `reset` does
|
|
209
|
+
# wm_ct_reset_egress <spec…>: only the machine's connections through the World's forwarders (each spec `port=target`, as
|
|
210
|
+
# wm_egress takes them) reset, every other connection kept: a live handover's twins changed, the machine did not
|
|
211
|
+
wm_ct_reset_egress() {
|
|
212
|
+
local t0 f="" m; t0=$(wm_ns)
|
|
213
|
+
for m in "$@"; do f="${f:+$f or }dport = :${m%%=*}"; done
|
|
214
|
+
[ -n "$f" ] && sudo -n ip netns exec "$WM_NETNS" ss -K dst "$WM_HOSTIP" "( $f )" >/dev/null 2>&1
|
|
215
|
+
printf '@{"resetMs":%s}\n' "$(( ($(wm_ns) - t0) / 1000000 ))"
|
|
216
|
+
}
|
|
217
|
+
wm_ct_reset() {
|
|
218
|
+
local t0; t0=$(wm_ns)
|
|
219
|
+
sudo -n ip netns exec "$WM_NETNS" ss -K dst "$WM_HOSTIP" >/dev/null 2>&1
|
|
220
|
+
printf '@{"resetMs":%s}\n' "$(( ($(wm_ns) - t0) / 1000000 ))"
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# wm_ct_write: a JSON object of path -> content on stdin, written into the machine's tree (the World's env file, its
|
|
224
|
+
# certificates, world-core's redirect), as the Firecracker guest agent's `write` does
|
|
225
|
+
wm_ct_write() {
|
|
226
|
+
# written as host root into a tree the machine's own processes can change: every component is opened without
|
|
227
|
+
# following a symlink (O_NOFOLLOW, relative to the one before), so a link the machine left cannot point a write at
|
|
228
|
+
# the host's own files
|
|
229
|
+
sudo -n python3 -c '
|
|
230
|
+
import json, os, sys
|
|
231
|
+
root = os.open(sys.argv[1], os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
232
|
+
for path, content in json.load(sys.stdin).items():
|
|
233
|
+
parts = [p for p in path.split("/") if p and p not in (".", "..")]
|
|
234
|
+
d = os.dup(root)
|
|
235
|
+
for part in parts[:-1]:
|
|
236
|
+
try: os.mkdir(part, 0o755, dir_fd=d)
|
|
237
|
+
except FileExistsError: pass
|
|
238
|
+
n = os.open(part, os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=d); os.close(d); d = n
|
|
239
|
+
tmp = parts[-1] + ".wm-tmp"
|
|
240
|
+
try: os.unlink(tmp, dir_fd=d)
|
|
241
|
+
except FileNotFoundError: pass
|
|
242
|
+
f = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=d)
|
|
243
|
+
os.write(f, content.encode()); os.close(f)
|
|
244
|
+
os.replace(tmp, parts[-1], src_dir_fd=d, dst_dir_fd=d)
|
|
245
|
+
os.close(d)
|
|
246
|
+
print("@" + json.dumps({"written": True}))
|
|
247
|
+
' "$WM_ROOT"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# The machine's init (/usr/local/sbin/world-machine-init): runs each /etc/world-machine/services.d/*.sh in order to
|
|
251
|
+
# completion (a service script starts its daemon detached and returns once it listens), marks itself ready only if
|
|
252
|
+
# every one succeeded, and reaps. PID 1 of the container; the base's mark dumps it with its services.
|
|
253
|
+
WM_CT_INIT='#!/usr/bin/python3
|
|
254
|
+
import glob, os, signal, subprocess, time
|
|
255
|
+
# crun hands the init its own stdio, host files outside the container that CRIU cannot dump: the container'"'"'s /dev/null instead
|
|
256
|
+
null = os.open("/dev/null", os.O_RDWR)
|
|
257
|
+
for fd in (0, 1, 2): os.dup2(null, fd)
|
|
258
|
+
if null > 2: os.close(null)
|
|
259
|
+
failed = []
|
|
260
|
+
for s in sorted(glob.glob("/etc/world-machine/services.d/*.sh")):
|
|
261
|
+
with open("/var/log/" + os.path.basename(s) + ".log", "a") as log:
|
|
262
|
+
if subprocess.run(["/bin/bash", s], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT).returncode != 0: failed.append(os.path.basename(s))
|
|
263
|
+
os.makedirs("/run", exist_ok=True)
|
|
264
|
+
open("/run/world-machine-failed" if failed else "/run/world-machine-ready", "w").write(" ".join(failed) or str(time.time()))
|
|
265
|
+
signal.signal(signal.SIGTERM, lambda *a: os._exit(0))
|
|
266
|
+
while True:
|
|
267
|
+
try: os.waitpid(-1, 0)
|
|
268
|
+
except ChildProcessError: time.sleep(1)
|
|
269
|
+
'
|
|
270
|
+
|
|
271
|
+
# wm_ct_base_fill <image> <size> <dir>: <dir>/root.img made from the OCI image <image>, with the init installed
|
|
272
|
+
wm_ct_base_fill() {
|
|
273
|
+
local image="$1" size="$2" dir="$3" mnt="$3/mnt" cid
|
|
274
|
+
mkdir -p "$mnt" || return 1
|
|
275
|
+
# 4 KiB blocks: the tree's loop has 4096-byte sectors (direct I/O), and a small image would otherwise get 1 KiB ones
|
|
276
|
+
truncate -s "$size" "$dir/root.img" && mkfs.ext4 -q -F -b 4096 "$dir/root.img" || { wm_fail "cannot make $dir/root.img"; return 1; }
|
|
277
|
+
sudo -n mount -o loop "$dir/root.img" "$mnt" || { wm_fail "cannot mount $dir/root.img"; return 1; }
|
|
278
|
+
# the image on this host, the one the base is named by (its id): `world-machine base` pulled one from a registry first
|
|
279
|
+
sudo -n podman image exists "$image" && cid=$(sudo -n podman create "$image" true) \
|
|
280
|
+
&& sudo -n podman export "$cid" | sudo -n tar -x -C "$mnt"; local rc=$?
|
|
281
|
+
[ -n "$cid" ] && sudo -n podman rm -f "$cid" >/dev/null 2>&1
|
|
282
|
+
[ $rc = 0 ] || { sudo -n umount "$mnt"; wm_fail "cannot fill the tree from $image"; return 1; }
|
|
283
|
+
printf '%s' "$WM_CT_INIT" | sudo -n tee "$mnt/usr/local/sbin/world-machine-init" >/dev/null && sudo -n chmod 755 "$mnt/usr/local/sbin/world-machine-init"
|
|
284
|
+
sudo -n mkdir -p "$mnt/etc/world-machine/services.d" "$mnt/root"
|
|
285
|
+
sudo -n cp /etc/resolv.conf "$mnt/etc/resolv.conf" 2>/dev/null
|
|
286
|
+
# the names a container runtime would give it (podman writes these only into containers it runs itself, so an exported
|
|
287
|
+
# tree has none): without them `localhost` did not resolve in the machine (measured: getaddrinfo EAI_AGAIN)
|
|
288
|
+
printf '127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\n%s\tmachine\n' "$WM_GUESTIP" | sudo -n tee "$mnt/etc/hosts" >/dev/null
|
|
289
|
+
printf 'machine\n' | sudo -n tee "$mnt/etc/hostname" >/dev/null
|
|
290
|
+
sudo -n umount "$mnt"; rmdir "$mnt"
|
|
291
|
+
printf '@{"filled":true}\n'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# wm_ct_provision <script>: the script run once in the slot's tree, as root, in the machine's namespaces; its exit code
|
|
295
|
+
wm_ct_provision() {
|
|
296
|
+
local script="$1"
|
|
297
|
+
sudo -n install -m 755 "$script" "$WM_ROOT/root/.world-machine-provision.sh" || { wm_fail "cannot place the provisioning script"; return 1; }
|
|
298
|
+
wm_ct_bundle /bin/bash -c '/root/.world-machine-provision.sh > /root/.world-machine-provision.log 2>&1'
|
|
299
|
+
sudo -n crun run --bundle "$WM_BUNDLE" "$WM_CT-provision" </dev/null >/dev/null 2>&1; local rc=$?
|
|
300
|
+
sudo -n crun delete -f "$WM_CT-provision" >/dev/null 2>&1
|
|
301
|
+
printf '@{"code":%s,"log":%s}\n' "$rc" "$(sudo -n tail -c 600 "$WM_ROOT/root/.world-machine-provision.log" 2>/dev/null | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')"
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# wm_ct_start: the machine's init started in the slot's tree, detached; ready once it has started its services
|
|
305
|
+
wm_ct_start() {
|
|
306
|
+
wm_ct_stop; wm_ct_bundle
|
|
307
|
+
# both markers cleared before the init starts: one that fails at once must still be read as failed
|
|
308
|
+
sudo -n rm -f "$WM_ROOT/run/world-machine-ready" "$WM_ROOT/run/world-machine-failed"
|
|
309
|
+
rm -f "$WM_SLOTDIR/start.err" # a CRIU run over the init's inherited stderr once left it root's
|
|
310
|
+
sudo -n crun run -d --bundle "$WM_BUNDLE" "$WM_CT" >/dev/null 2>"$WM_SLOTDIR/start.err" || { wm_fail "crun could not start $WM_CT: $(tail -2 "$WM_SLOTDIR/start.err")"; return 1; }
|
|
311
|
+
local i; for i in $(seq 1 300); do [ -f "$WM_ROOT/run/world-machine-ready" ] || [ -f "$WM_ROOT/run/world-machine-failed" ] && break; sleep 0.1; done
|
|
312
|
+
[ -f "$WM_ROOT/run/world-machine-failed" ] && { wm_fail "$WM_CT: services failed: $(cat "$WM_ROOT/run/world-machine-failed") ($(sudo -n tail -n 3 "$WM_ROOT"/var/log/*.sh.log 2>/dev/null | tail -6))"; return 1; }
|
|
313
|
+
[ -f "$WM_ROOT/run/world-machine-ready" ] || { wm_fail "$WM_CT did not report ready in 30 s"; return 1; }
|
|
314
|
+
printf '@{"started":true}\n'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# wm_ct_run <user or ""> <seconds> <argv json>: a command run in the running machine (crun exec), as <user> (a name
|
|
318
|
+
# the machine's own passwd resolves) or its root; the machine's own `timeout` ends it (and what it started in its
|
|
319
|
+
# group) past <seconds>. Its exit code (124: it ran past the bound) and the last 16,000 bytes of each output, which go
|
|
320
|
+
# to files on the host and are never held whole.
|
|
321
|
+
wm_ct_run() {
|
|
322
|
+
local user=$1 secs=$2 argv=$3 ids=""
|
|
323
|
+
[ "$(wm_ct_state)" = running ] || { wm_fail "refused: the machine is $(wm_ct_state), not running"; return 1; }
|
|
324
|
+
if [ -n "$user" ]; then
|
|
325
|
+
ids=$(sudo -n crun exec "$WM_CT" /bin/sh -c 'echo "$(id -u "$1"):$(id -g "$1")"' sh "$user" 2>/dev/null) && [ "${ids#:}" = "$ids" ] \
|
|
326
|
+
|| { wm_fail "refused: the machine has no user $user"; return 1; }
|
|
327
|
+
fi
|
|
328
|
+
sudo -n timeout -k 5 "$((secs + 15))" env PATH="$PATH" python3 - "$WM_CT" "$ids" "$argv" "$secs" <<'PY' || { wm_fail "the command could not be run in the machine"; return 1; }
|
|
329
|
+
import json, os, subprocess, sys, tempfile
|
|
330
|
+
ct, ids, argv, secs = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]), sys.argv[4]
|
|
331
|
+
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
332
|
+
# the bound inside the machine: its timeout kills the command's whole group there (crun exec's own child would
|
|
333
|
+
# otherwise outlive a kill of crun here)
|
|
334
|
+
r = subprocess.run(['crun', 'exec'] + (['--user', ids] if ids else []) + [ct, 'timeout', '-k', '5', secs] + argv,
|
|
335
|
+
stdout=out, stderr=err, stdin=subprocess.DEVNULL)
|
|
336
|
+
def tail(f):
|
|
337
|
+
size = f.seek(0, os.SEEK_END); f.seek(max(0, size - 16000)); return f.read().decode(errors='replace')
|
|
338
|
+
print('@' + json.dumps({'code': r.returncode, 'stdout': tail(out), 'stderr': tail(err), **({'timedOut': True} if r.returncode == 124 else {})}))
|
|
339
|
+
PY
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
# wm_ct_files <snap> <paths json>: part of the machine's disk back at the mark in <snap>, the machine running on: each
|
|
343
|
+
# path (relative to the machine's root) made as the mark's image, mounted read-only, has it: a folder exactly (what
|
|
344
|
+
# was made in it since removed), a file by its content (its time is not trusted: npm and tar keep old ones), owners,
|
|
345
|
+
# modes and times as they were; a path the mark did not have is removed. The processes keep running and are not told.
|
|
346
|
+
# Written as host root into a tree the machine's own processes can change while it runs, every component of the live
|
|
347
|
+
# tree is opened relative to the one before without following a symlink (as wm_ct_write), so a link the machine left
|
|
348
|
+
# or swaps in cannot lead a write or a removal out of the tree; a path whose folders are a symlink in either tree is
|
|
349
|
+
# refused. Hard links, ACLs and extended attributes are not carried; a FIFO, socket or device is left as it is.
|
|
350
|
+
wm_ct_files() {
|
|
351
|
+
local snap=$1 paths=$2 nolinks=${3:-} dry=${4:-} mnt
|
|
352
|
+
for m in "$WM_SLOTDIR"/compare.* "$WM_SLOTDIR"/files.*; do [ -d "$m" ] || continue; sudo -n umount "$m" 2>/dev/null; rmdir "$m" 2>/dev/null; done
|
|
353
|
+
mnt=$(mktemp -d "$WM_SLOTDIR/files.XXXXXX") || { wm_fail "cannot make a mount point"; return 1; }
|
|
354
|
+
# held against a collection while mounted, as a compare's (host.sh wm_store_list)
|
|
355
|
+
printf '%s' "$snap" > "$WM_SLOTDIR/comparing"
|
|
356
|
+
trap 'sudo -n umount "'"$mnt"'" 2>/dev/null; rmdir "'"$mnt"'" 2>/dev/null; rm -f "$WM_SLOTDIR/comparing"; trap - RETURN' RETURN
|
|
357
|
+
sudo -n mount -o ro,loop,noload,nodev,nosuid,noexec "$snap/root.img" "$mnt" || { wm_fail "cannot mount $snap/root.img"; return 1; }
|
|
358
|
+
sudo -n timeout -k 5 300 env PATH="$PATH" python3 - "$mnt" "$WM_ROOT" "$paths" "$nolinks" "$dry" <<'PY'
|
|
359
|
+
import errno, json, os, shutil, stat, sys
|
|
360
|
+
mnt, live, paths = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
|
|
361
|
+
# nolinks: a tree that must be whole in itself (a database's data directory): a symlink anywhere under a path, in the
|
|
362
|
+
# mark or now, is refused (a tablespace or a moved log would come back as a link to data that did not)
|
|
363
|
+
NOLINKS = sys.argv[4] == 'nolinks'
|
|
364
|
+
# dry: the checks only (every path, both trees), nothing touched: a caller that must stop something first asks this first
|
|
365
|
+
DRY = sys.argv[5] == 'dry'
|
|
366
|
+
LIMIT = 20
|
|
367
|
+
NOFOLLOW_DIR = os.O_DIRECTORY | os.O_NOFOLLOW
|
|
368
|
+
out = []
|
|
369
|
+
|
|
370
|
+
def say(obj):
|
|
371
|
+
print('@' + json.dumps(obj)); sys.exit(0 if 'error' not in obj else 1)
|
|
372
|
+
|
|
373
|
+
def open_parent(rel, create):
|
|
374
|
+
"""The live folder holding rel's last component, opened component by component without following a symlink;
|
|
375
|
+
a missing folder made (with create) as the mark has it. None when a folder is missing and not made."""
|
|
376
|
+
parts = rel.split('/')
|
|
377
|
+
d = os.open(live, NOFOLLOW_DIR)
|
|
378
|
+
here = mnt
|
|
379
|
+
for part in parts[:-1]:
|
|
380
|
+
here = os.path.join(here, part)
|
|
381
|
+
try:
|
|
382
|
+
n = os.open(part, NOFOLLOW_DIR, dir_fd=d)
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
if not create:
|
|
385
|
+
os.close(d); return None
|
|
386
|
+
st = os.lstat(here)
|
|
387
|
+
os.mkdir(part, 0o700, dir_fd=d)
|
|
388
|
+
n = os.open(part, NOFOLLOW_DIR, dir_fd=d)
|
|
389
|
+
fmeta(n, st)
|
|
390
|
+
except OSError as error:
|
|
391
|
+
os.close(d)
|
|
392
|
+
if error.errno in (errno.ELOOP, errno.ENOTDIR):
|
|
393
|
+
raise RuntimeError(f'/{rel}: /{os.path.relpath(here, mnt)} is not a folder in the machine now (a symlink or a file)')
|
|
394
|
+
raise
|
|
395
|
+
os.close(d); d = n
|
|
396
|
+
return d
|
|
397
|
+
|
|
398
|
+
def lkind(name, d):
|
|
399
|
+
try:
|
|
400
|
+
st = os.stat(name, dir_fd=d, follow_symlinks=False)
|
|
401
|
+
except FileNotFoundError:
|
|
402
|
+
return None, None
|
|
403
|
+
return stat.S_IFMT(st.st_mode), st
|
|
404
|
+
|
|
405
|
+
def remove(name, d):
|
|
406
|
+
"""d/name removed, a folder with everything in it; the entries removed, counted. Never follows a symlink, and never
|
|
407
|
+
opens anything but a folder (O_DIRECTORY: the kernel refuses a device or FIFO swapped in before opening it)."""
|
|
408
|
+
kind, _ = lkind(name, d)
|
|
409
|
+
if kind is None: return 0
|
|
410
|
+
if kind != stat.S_IFDIR:
|
|
411
|
+
os.unlink(name, dir_fd=d); return 1
|
|
412
|
+
n = os.open(name, NOFOLLOW_DIR | os.O_NONBLOCK, dir_fd=d)
|
|
413
|
+
try:
|
|
414
|
+
count = sum(remove(e, n) for e in os.listdir(n))
|
|
415
|
+
finally:
|
|
416
|
+
os.close(n)
|
|
417
|
+
os.rmdir(name, dir_fd=d)
|
|
418
|
+
return count + 1
|
|
419
|
+
|
|
420
|
+
def open_regular(name, d):
|
|
421
|
+
"""A live regular file opened to read, or None: opened as a path first (O_PATH: nothing of a device or FIFO runs),
|
|
422
|
+
checked, then reopened through it."""
|
|
423
|
+
try:
|
|
424
|
+
p = os.open(name, os.O_PATH | os.O_NOFOLLOW, dir_fd=d)
|
|
425
|
+
except OSError:
|
|
426
|
+
return None
|
|
427
|
+
try:
|
|
428
|
+
if not stat.S_ISREG(os.fstat(p).st_mode): return None
|
|
429
|
+
return os.open(f'/proc/self/fd/{p}', os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
|
430
|
+
finally:
|
|
431
|
+
os.close(p)
|
|
432
|
+
|
|
433
|
+
def same_file(src, name, d, st):
|
|
434
|
+
f = open_regular(name, d)
|
|
435
|
+
if f is None: return False
|
|
436
|
+
try:
|
|
437
|
+
if os.fstat(f).st_size != st.st_size: return False
|
|
438
|
+
with open(src, 'rb') as a, os.fdopen(os.dup(f), 'rb') as b:
|
|
439
|
+
while True:
|
|
440
|
+
u, v = a.read(1 << 20), b.read(1 << 20)
|
|
441
|
+
if u != v: return False
|
|
442
|
+
if not u: return True
|
|
443
|
+
finally:
|
|
444
|
+
os.close(f)
|
|
445
|
+
|
|
446
|
+
def fmeta(fd, st):
|
|
447
|
+
"""Owner, mode and times from the mark's stat, on an open descriptor (never a path a swap could redirect)."""
|
|
448
|
+
os.fchown(fd, st.st_uid, st.st_gid); os.fchmod(fd, stat.S_IMODE(st.st_mode)); os.utime(fd, ns=(st.st_atime_ns, st.st_mtime_ns))
|
|
449
|
+
|
|
450
|
+
def lmeta(name, d, st):
|
|
451
|
+
"""A symlink's owner and times (the link itself: lchown, utimensat without following)."""
|
|
452
|
+
os.chown(name, st.st_uid, st.st_gid, dir_fd=d, follow_symlinks=False)
|
|
453
|
+
os.utime(name, ns=(st.st_atime_ns, st.st_mtime_ns), dir_fd=d, follow_symlinks=False)
|
|
454
|
+
|
|
455
|
+
def put(src, name, d, rel, changes):
|
|
456
|
+
"""Make d/name what the mark's src is; the changes it made appended to changes."""
|
|
457
|
+
st = os.lstat(src)
|
|
458
|
+
kind, cur = lkind(name, d)
|
|
459
|
+
if stat.S_ISLNK(st.st_mode):
|
|
460
|
+
target = os.readlink(src)
|
|
461
|
+
if kind == stat.S_IFLNK and os.readlink(name, dir_fd=d) == target: return
|
|
462
|
+
remove(name, d); os.symlink(target, name, dir_fd=d); lmeta(name, d, st); changes.append(rel); return
|
|
463
|
+
if stat.S_ISREG(st.st_mode):
|
|
464
|
+
if kind == stat.S_IFREG and same_file(src, name, d, st):
|
|
465
|
+
if (cur.st_uid, cur.st_gid, stat.S_IMODE(cur.st_mode)) != (st.st_uid, st.st_gid, stat.S_IMODE(st.st_mode)):
|
|
466
|
+
f = open_regular(name, d)
|
|
467
|
+
if f is None: raise RuntimeError(f'/{rel} changed while it was being restored')
|
|
468
|
+
try: fmeta(f, st)
|
|
469
|
+
finally: os.close(f)
|
|
470
|
+
changes.append(rel)
|
|
471
|
+
return
|
|
472
|
+
tmp = f'.{name}.wm-files'
|
|
473
|
+
try: os.unlink(tmp, dir_fd=d)
|
|
474
|
+
except FileNotFoundError: pass
|
|
475
|
+
f = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=d)
|
|
476
|
+
with open(src, 'rb') as a, os.fdopen(f, 'wb') as b:
|
|
477
|
+
shutil.copyfileobj(a, b, 1 << 20); b.flush(); fmeta(b.fileno(), st)
|
|
478
|
+
if kind == stat.S_IFDIR: remove(name, d)
|
|
479
|
+
os.replace(tmp, name, src_dir_fd=d, dst_dir_fd=d); changes.append(rel); return
|
|
480
|
+
if stat.S_ISDIR(st.st_mode):
|
|
481
|
+
if kind != stat.S_IFDIR:
|
|
482
|
+
remove(name, d); os.mkdir(name, 0o700, dir_fd=d); changes.append(rel + '/')
|
|
483
|
+
n = os.open(name, NOFOLLOW_DIR, dir_fd=d)
|
|
484
|
+
try:
|
|
485
|
+
want = set(os.listdir(src))
|
|
486
|
+
for e in sorted(set(os.listdir(n)) - want):
|
|
487
|
+
remove(e, n); changes.append(f'{rel}/{e} (removed)')
|
|
488
|
+
for e in sorted(want):
|
|
489
|
+
put(os.path.join(src, e), e, n, f'{rel}/{e}', changes)
|
|
490
|
+
if kind == stat.S_IFDIR and (cur.st_uid, cur.st_gid, stat.S_IMODE(cur.st_mode)) != (st.st_uid, st.st_gid, stat.S_IMODE(st.st_mode)):
|
|
491
|
+
changes.append(rel + '/ (owner or mode)')
|
|
492
|
+
fmeta(n, st)
|
|
493
|
+
finally:
|
|
494
|
+
os.close(n)
|
|
495
|
+
return
|
|
496
|
+
# a FIFO, socket or device in the mark: left as the machine has it
|
|
497
|
+
changes.append(f'{rel} (a special file: left as it is)')
|
|
498
|
+
|
|
499
|
+
rels = [rel.strip('/') for rel in paths]
|
|
500
|
+
# every path checked before any is touched: a request refused changes nothing
|
|
501
|
+
for rel in rels:
|
|
502
|
+
parts = rel.split('/')
|
|
503
|
+
if not rel or parts[0] in ('proc', 'sys', 'dev', 'run') or any(p in ('', '.', '..') for p in parts):
|
|
504
|
+
say({'error': f'refused: files: /{rel} cannot be restored on its own'})
|
|
505
|
+
# the folders above it: real folders in the mark's image (a symlink there would have the copy follow it)
|
|
506
|
+
here = mnt
|
|
507
|
+
for part in parts[:-1]:
|
|
508
|
+
here = os.path.join(here, part)
|
|
509
|
+
if os.path.islink(here):
|
|
510
|
+
say({'error': f'refused: files: /{rel} is under a symlink in the mark (/{os.path.relpath(here, mnt)}); name the path it leads to'})
|
|
511
|
+
# and the live side as it is now (the copy checks each component again as it goes)
|
|
512
|
+
try:
|
|
513
|
+
d = open_parent(rel, create=False)
|
|
514
|
+
if d is not None: os.close(d)
|
|
515
|
+
except RuntimeError as error:
|
|
516
|
+
say({'error': f'refused: files: {error}'})
|
|
517
|
+
if NOLINKS:
|
|
518
|
+
for tree, name in ((os.path.join(mnt, rel), 'the mark'), (os.path.join(live, rel), 'the machine now')):
|
|
519
|
+
if os.path.islink(tree): say({'error': f'refused: files: /{rel} is a symlink in {name}: its data is not under it'})
|
|
520
|
+
for dpath, dnames, fnames in os.walk(tree):
|
|
521
|
+
link = next((n for n in dnames + fnames if os.path.islink(os.path.join(dpath, n))), None)
|
|
522
|
+
if link: say({'error': f'refused: files: /{rel} holds a symlink in {name} (/{os.path.relpath(os.path.join(dpath, link), tree if name == "the mark" else live)}): its data is not all under it'})
|
|
523
|
+
|
|
524
|
+
if DRY: say({'checked': ['/' + rel for rel in rels]})
|
|
525
|
+
for rel in rels:
|
|
526
|
+
src = os.path.join(mnt, rel)
|
|
527
|
+
name = rel.split('/')[-1]
|
|
528
|
+
try:
|
|
529
|
+
have = os.path.lexists(src)
|
|
530
|
+
d = open_parent(rel, create=have)
|
|
531
|
+
if d is None:
|
|
532
|
+
out.append({'path': '/' + rel, 'done': 'absent', 'changes': 0}); continue
|
|
533
|
+
try:
|
|
534
|
+
if not have:
|
|
535
|
+
n = remove(name, d)
|
|
536
|
+
out.append({'path': '/' + rel, 'done': 'removed' if n else 'absent', 'changes': n}); continue
|
|
537
|
+
changes = []
|
|
538
|
+
put(src, name, d, rel, changes)
|
|
539
|
+
# each change named below the path asked for (the path itself as `.`)
|
|
540
|
+
short = [c[len(rel) + 1:] if c.startswith(rel + '/') else ('.' if c.split(' ')[0].rstrip('/') == rel else c) for c in changes]
|
|
541
|
+
out.append({'path': '/' + rel, 'done': 'restored', 'changes': len(changes), 'sample': short[:LIMIT]})
|
|
542
|
+
finally:
|
|
543
|
+
os.close(d)
|
|
544
|
+
except Exception as error: # noqa: BLE001 - every failure is said, with what was done before it
|
|
545
|
+
done = ', '.join(o['path'] for o in out) or 'none'
|
|
546
|
+
say({'error': f'files: /{rel} failed part way ({error}); paths already back: {done}', 'paths': out})
|
|
547
|
+
say({'paths': out})
|
|
548
|
+
PY
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
# wm_ct_compare <snapshot dir> <path in the tree> [exact]: what going back to the snapshot would change in the machine, read
|
|
552
|
+
# only: the files under <path> (the snapshot's disk image mounted read-only beside the live tree; dependency, VCS and
|
|
553
|
+
# cache directories left out) and the processes (the snapshot's process tree, from its CRIU images, beside the live
|
|
554
|
+
# machine's). Bounded: 30,000 files a tree and 15 s of walking, past which the answer says it is partial; regular
|
|
555
|
+
# files and symlinks only (a FIFO or device is never opened). With `exact` (a path named on its own) nothing is left
|
|
556
|
+
# out and a file is compared by its content, never its time. One @{...} line.
|
|
557
|
+
wm_ct_compare() {
|
|
558
|
+
local snap=$1 rel=${2#/} mnt
|
|
559
|
+
case "/$rel/" in */../*|*/./*) wm_fail "compare: the path must not climb (${2})"; return 1 ;; esac
|
|
560
|
+
# a compare killed part way leaves its mount: swept here
|
|
561
|
+
for m in "$WM_SLOTDIR"/compare.* "$WM_SLOTDIR"/files.*; do [ -d "$m" ] || continue; sudo -n umount "$m" 2>/dev/null; rmdir "$m" 2>/dev/null; done
|
|
562
|
+
mnt=$(mktemp -d "$WM_SLOTDIR/compare.XXXXXX") || { wm_fail "cannot make a mount point"; return 1; }
|
|
563
|
+
# the snapshot held against a collection while it is mounted (host.sh wm_store_list reads this marker)
|
|
564
|
+
printf '%s' "$snap" > "$WM_SLOTDIR/comparing"
|
|
565
|
+
trap 'sudo -n umount "'"$mnt"'" 2>/dev/null; rmdir "'"$mnt"'" 2>/dev/null; rm -f "$WM_SLOTDIR/comparing"; trap - RETURN' RETURN
|
|
566
|
+
# read only, no journal replay (the image is crash-consistent: its machine was frozen), and nothing on it opens as a
|
|
567
|
+
# device or runs
|
|
568
|
+
sudo -n mount -o ro,loop,noload,nodev,nosuid,noexec "$snap/root.img" "$mnt" || { wm_fail "cannot mount $snap/root.img"; return 1; }
|
|
569
|
+
local pid; pid=$(sudo -n crun state "$WM_CT" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("pid",0))' 2>/dev/null || echo 0)
|
|
570
|
+
sudo -n timeout 20 env PATH="$PATH" python3 - "$mnt" "$WM_ROOT" "$rel" "$snap/criu" "$pid" "${3:-}" <<'PY'
|
|
571
|
+
import collections, json, os, stat, subprocess, sys, time
|
|
572
|
+
mnt, live, rel, criu, init = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], int(sys.argv[5] or 0)
|
|
573
|
+
# exact (a path named on its own, as a partial restore's preview asks): every file under it, by content, never by time
|
|
574
|
+
EXACT = sys.argv[6] == 'exact'
|
|
575
|
+
then_root, now_root = os.path.join(mnt, rel), os.path.join(live, rel)
|
|
576
|
+
for root, base in ((then_root, mnt), (now_root, live)): # the path stays inside each tree, symlinks resolved
|
|
577
|
+
if os.path.commonpath([os.path.realpath(root), os.path.realpath(base)]) != os.path.realpath(base):
|
|
578
|
+
print('@' + json.dumps({'error': f'compare: {rel} leaves the machine\'s tree'})); sys.exit(1)
|
|
579
|
+
DEEP = set() if EXACT else {'node_modules', '.git', '.next', '.cache', '__pycache__', '.pnpm-store', '.venv', '.yarn', '.gradle', 'Pods'}
|
|
580
|
+
TOP = set() if EXACT else {'dist', 'build', 'target', '.turbo', 'coverage', 'vendor', 'tmp'} # at the top level only (a src/build is source); tmp: the harness's own temp files (CLAUDE_CODE_TMPDIR)
|
|
581
|
+
MAXF, BUDGET, LIMIT, BIG = 30000, 15.0, 40, 8 << 20
|
|
582
|
+
start = time.time()
|
|
583
|
+
partial = []
|
|
584
|
+
|
|
585
|
+
def files(root):
|
|
586
|
+
out = {}
|
|
587
|
+
if not os.path.isdir(root) or os.path.islink(root): # one file (or symlink) compared on its own
|
|
588
|
+
st = os.lstat(root)
|
|
589
|
+
if stat.S_ISREG(st.st_mode):
|
|
590
|
+
out[os.path.basename(root)] = ('f', st.st_size, st.st_mtime_ns, root)
|
|
591
|
+
elif stat.S_ISLNK(st.st_mode):
|
|
592
|
+
out[os.path.basename(root)] = ('l', os.readlink(root), 0, root)
|
|
593
|
+
return out
|
|
594
|
+
for d, dirs, names in os.walk(root):
|
|
595
|
+
top = os.path.normpath(d) == os.path.normpath(root)
|
|
596
|
+
dirs[:] = [x for x in dirs if x not in DEEP and not (top and x in TOP)]
|
|
597
|
+
for n in names:
|
|
598
|
+
p = os.path.join(d, n)
|
|
599
|
+
try:
|
|
600
|
+
st = os.lstat(p)
|
|
601
|
+
except OSError:
|
|
602
|
+
continue
|
|
603
|
+
if stat.S_ISREG(st.st_mode):
|
|
604
|
+
out[os.path.relpath(p, root)] = ('f', st.st_size, st.st_mtime_ns, p)
|
|
605
|
+
elif stat.S_ISLNK(st.st_mode):
|
|
606
|
+
out[os.path.relpath(p, root)] = ('l', os.readlink(p), 0, p)
|
|
607
|
+
if len(out) >= MAXF or time.time() - start > BUDGET:
|
|
608
|
+
partial.append(root); return out
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
def same(a, b):
|
|
612
|
+
if a[0] != b[0] or a[1] != b[1]:
|
|
613
|
+
return False
|
|
614
|
+
if a[0] == 'l' or (a[2] == b[2] and not EXACT):
|
|
615
|
+
return True
|
|
616
|
+
if a[1] > BIG or time.time() - start > BUDGET:
|
|
617
|
+
return False # large, or out of time: by size and time only
|
|
618
|
+
def reg(path): # a regular file opened to read: never a device or FIFO swapped in, never through a final symlink
|
|
619
|
+
p = os.open(path, os.O_PATH | os.O_NOFOLLOW)
|
|
620
|
+
try:
|
|
621
|
+
if not stat.S_ISREG(os.fstat(p).st_mode): raise OSError('not a regular file')
|
|
622
|
+
return open(os.open(f'/proc/self/fd/{p}', os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK), 'rb')
|
|
623
|
+
finally:
|
|
624
|
+
os.close(p)
|
|
625
|
+
try:
|
|
626
|
+
with reg(a[3]) as x, reg(b[3]) as y:
|
|
627
|
+
while True:
|
|
628
|
+
u, v = x.read(1 << 20), y.read(1 << 20)
|
|
629
|
+
if u != v:
|
|
630
|
+
return False
|
|
631
|
+
if not u:
|
|
632
|
+
return True
|
|
633
|
+
except OSError:
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
then = files(then_root) if os.path.lexists(then_root) else {}
|
|
637
|
+
now = files(now_root) if os.path.lexists(now_root) else {}
|
|
638
|
+
back = sorted(set(then) - set(now))
|
|
639
|
+
gone = sorted(set(now) - set(then))
|
|
640
|
+
changed = sorted(p for p in set(then) & set(now) if not same(then[p], now[p]))
|
|
641
|
+
|
|
642
|
+
def ps_then():
|
|
643
|
+
r = subprocess.run(['crit', 'x', criu, 'ps'], capture_output=True, text=True)
|
|
644
|
+
if r.returncode != 0:
|
|
645
|
+
raise RuntimeError((r.stderr or 'crit failed').strip().splitlines()[-1][:200])
|
|
646
|
+
return [row.split(None, 3)[3].strip() for row in r.stdout.splitlines()[1:] if len(row.split(None, 3)) == 4]
|
|
647
|
+
|
|
648
|
+
def ps_now():
|
|
649
|
+
if not init:
|
|
650
|
+
return []
|
|
651
|
+
ns = os.readlink(f'/proc/{init}/ns/pid')
|
|
652
|
+
out = []
|
|
653
|
+
for p in os.listdir('/proc'):
|
|
654
|
+
if p.isdigit():
|
|
655
|
+
try:
|
|
656
|
+
if os.readlink(f'/proc/{p}/ns/pid') == ns:
|
|
657
|
+
out.append(open(f'/proc/{p}/comm').read().strip())
|
|
658
|
+
except OSError:
|
|
659
|
+
pass
|
|
660
|
+
return out
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
pt, pn = collections.Counter(ps_then()), collections.Counter(ps_now())
|
|
664
|
+
procs = {'restarted': sorted((pt - pn).elements())[:LIMIT], 'stopped': sorted((pn - pt).elements())[:LIMIT],
|
|
665
|
+
'then': sum(pt.values()), 'now': sum(pn.values())}
|
|
666
|
+
except Exception as error:
|
|
667
|
+
procs = {'unknown': str(error)}
|
|
668
|
+
print('@' + json.dumps({
|
|
669
|
+
'files': {'back': back[:LIMIT], 'gone': gone[:LIMIT], 'changed': changed[:LIMIT],
|
|
670
|
+
'counts': {'back': len(back), 'gone': len(gone), 'changed': len(changed)},
|
|
671
|
+
'skipped': sorted(DEEP | TOP), 'partial': bool(partial)},
|
|
672
|
+
'processes': procs,
|
|
673
|
+
}))
|
|
674
|
+
PY
|
|
675
|
+
}
|