@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/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
+ }