Spaces:
Running
Running
Leandro von Werra commited on
Commit ·
615a703
1
Parent(s): 1dfeefd
Copy open Codex rollouts to closed objects before a deploy
Browse filesA restart loses whatever the FUSE writer has not flushed. Snapshot each open
rollout out through the bucket API, verify it by readback, and reconcile on the
way up.
- scripts/migrate-open-rollouts.sh +330 -0
scripts/migrate-open-rollouts.sh
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Get every open Codex rollout onto the bucket as a CLOSED object, before a
|
| 3 |
+
# deploy replaces the container.
|
| 4 |
+
#
|
| 5 |
+
# scripts/migrate-open-rollouts.sh # snapshot, upload, verify
|
| 6 |
+
# scripts/migrate-open-rollouts.sh --dry-run # report, touch nothing
|
| 7 |
+
# scripts/migrate-open-rollouts.sh --restore # boot side: reconcile
|
| 8 |
+
#
|
| 9 |
+
# WHY THIS EXISTS
|
| 10 |
+
#
|
| 11 |
+
# Codex appends to $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl and
|
| 12 |
+
# holds that descriptor open for the whole life of a resumed session. That path
|
| 13 |
+
# is a symlink onto the /data bucket (entrypoint.sh), which is FUSE over object
|
| 14 |
+
# storage, and the mount's streaming writer can hold the entire open epoch in
|
| 15 |
+
# memory until close. Reads inside this container see the buffer, so the file
|
| 16 |
+
# looks complete from here. The OBJECT does not have it.
|
| 17 |
+
#
|
| 18 |
+
# Measured on prod, 2026-08-13, three live Codex sessions:
|
| 19 |
+
#
|
| 20 |
+
# rollout-2026-08-05T14-33-11-… local 9 671 486 B object 767 B
|
| 21 |
+
# rollout-2026-08-07T15-30-46-… local 8 051 317 B object 767 B
|
| 22 |
+
# rollout-2026-08-13T14-09-11-… local 971 492 B object absent
|
| 23 |
+
#
|
| 24 |
+
# 767 bytes is the session header written at open. Everything after it exists
|
| 25 |
+
# only in this container. Kill the container and ~18 MB of conversation across
|
| 26 |
+
# three agents is gone — which is exactly the loss mode PR #25 fixes, and
|
| 27 |
+
# exactly what deploying that fix would trigger one last time on the way in.
|
| 28 |
+
#
|
| 29 |
+
# So: copy the writer's own view out through the bucket API (HTTP, not the
|
| 30 |
+
# mount), verify it landed, and only then let the deploy proceed.
|
| 31 |
+
#
|
| 32 |
+
# WHAT IT DOES NOT DO
|
| 33 |
+
#
|
| 34 |
+
# It does not stop, signal, or touch the Codex processes. It reads. A rollout is
|
| 35 |
+
# append-only JSONL, so a snapshot taken while the writer runs is a valid prefix
|
| 36 |
+
# of the session — we trim a partial trailing line and record how many bytes
|
| 37 |
+
# that cost. Losing the last half-written line is not the failure mode anyone is
|
| 38 |
+
# worried about here.
|
| 39 |
+
#
|
| 40 |
+
# It does not write the canonical path. If it did, the dying writer could flush
|
| 41 |
+
# its 767-byte view back over a full copy — the clobber this is meant to
|
| 42 |
+
# prevent. Copies go to a separate migration prefix, and --restore reconciles
|
| 43 |
+
# them on the way up, once no writer holds the file.
|
| 44 |
+
set -euo pipefail
|
| 45 |
+
|
| 46 |
+
MODE=snapshot
|
| 47 |
+
DRY=0
|
| 48 |
+
for a in "$@"; do
|
| 49 |
+
case "$a" in
|
| 50 |
+
--restore) MODE=restore ;;
|
| 51 |
+
--dry-run) DRY=1 ;;
|
| 52 |
+
-h|--help) sed -n '2,6p' "$0"; exit 0 ;;
|
| 53 |
+
*) echo "unknown argument: $a" >&2; exit 2 ;;
|
| 54 |
+
esac
|
| 55 |
+
done
|
| 56 |
+
|
| 57 |
+
[ -n "${HF_TOKEN:-}" ] || { echo "HF_TOKEN is not set" >&2; exit 1; }
|
| 58 |
+
|
| 59 |
+
DATA_DIR="${DATA_DIR:-/data}"
|
| 60 |
+
CODEX_DURABLE="${CODEX_DURABLE:-$DATA_DIR/state/codex}"
|
| 61 |
+
# The dev deploy script names a Space's bucket "<space>-data"; prod follows the
|
| 62 |
+
# same rule (lvwerra/agent-manager -> lvwerra/agent-manager-data). Override with
|
| 63 |
+
# AM_BUCKET when running against something else.
|
| 64 |
+
BUCKET="${AM_BUCKET:-${SPACE_ID:?SPACE_ID unset and AM_BUCKET not given}-data}"
|
| 65 |
+
# Staging is local POSIX disk, never the bucket: the whole point is to hold a
|
| 66 |
+
# closed byte-exact copy somewhere the FUSE writer has no opinion about.
|
| 67 |
+
STAGE="${AM_LOCAL:-/tmp}/rollout-migration"
|
| 68 |
+
|
| 69 |
+
export CODEX_DURABLE BUCKET STAGE MODE DRY
|
| 70 |
+
|
| 71 |
+
python3 - <<'PY'
|
| 72 |
+
import hashlib, json, os, re, shutil, sys, time
|
| 73 |
+
from pathlib import Path
|
| 74 |
+
|
| 75 |
+
from huggingface_hub import (
|
| 76 |
+
download_bucket_files,
|
| 77 |
+
list_bucket_tree,
|
| 78 |
+
sync_bucket,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
TOKEN = os.environ["HF_TOKEN"]
|
| 82 |
+
BUCKET = os.environ["BUCKET"]
|
| 83 |
+
DURABLE = Path(os.environ["CODEX_DURABLE"])
|
| 84 |
+
STAGE = Path(os.environ["STAGE"])
|
| 85 |
+
DRY = os.environ["DRY"] == "1"
|
| 86 |
+
RESTORE = os.environ["MODE"] == "restore"
|
| 87 |
+
|
| 88 |
+
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
| 89 |
+
PREFIX = "state/codex/migration" # remote home for closed copies
|
| 90 |
+
ROLLOUT = re.compile(r"/sessions/.*/rollout-[^/]+\.jsonl$")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def remote_of(p: Path) -> str:
|
| 94 |
+
"""Bucket key for a path under the mount: /data/state/x -> state/x."""
|
| 95 |
+
return str(p.relative_to(DATA_DIR))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def object_size(key: str):
|
| 99 |
+
"""Size of one bucket object, or None if there is no object.
|
| 100 |
+
|
| 101 |
+
Deliberately NOT get_bucket_file_metadata(): on these xet-backed objects its
|
| 102 |
+
.size is wrong. Measured 2026-08-13 against known-length uploads — a
|
| 103 |
+
9 796 319 B rollout reported 767, a 2 329 B manifest reported 694 — while
|
| 104 |
+
list_bucket_tree reported both correctly and download_bucket_files returned
|
| 105 |
+
byte-exact content. Do not "simplify" this back to a metadata call: every
|
| 106 |
+
number this script prints, and its entire safe/unsafe verdict, depends on
|
| 107 |
+
the size being real.
|
| 108 |
+
"""
|
| 109 |
+
parent, _, name = key.rpartition("/")
|
| 110 |
+
for f in list_bucket_tree(BUCKET, parent, recursive=False, token=TOKEN):
|
| 111 |
+
if Path(f.path).name == name:
|
| 112 |
+
return getattr(f, "size", None)
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def open_rollouts():
|
| 117 |
+
"""Every rollout held open for WRITING, with the writer's own file position.
|
| 118 |
+
|
| 119 |
+
/proc is the only honest source here. An agent's rollout is identified by
|
| 120 |
+
the descriptor a live Codex process holds, not by anything on disk: mtime
|
| 121 |
+
on the mount is stale (it tracks the last object commit, not the last
|
| 122 |
+
append), so a find -newermt sweep misses precisely the files at risk.
|
| 123 |
+
"""
|
| 124 |
+
out = {}
|
| 125 |
+
for pid_dir in Path("/proc").iterdir():
|
| 126 |
+
if not pid_dir.name.isdigit():
|
| 127 |
+
continue
|
| 128 |
+
fd_dir = pid_dir / "fd"
|
| 129 |
+
try:
|
| 130 |
+
fds = list(fd_dir.iterdir())
|
| 131 |
+
except OSError:
|
| 132 |
+
continue # not ours, or exited mid-scan
|
| 133 |
+
for fd in fds:
|
| 134 |
+
try:
|
| 135 |
+
target = os.readlink(fd)
|
| 136 |
+
except OSError:
|
| 137 |
+
continue
|
| 138 |
+
if not ROLLOUT.search(target):
|
| 139 |
+
continue
|
| 140 |
+
try:
|
| 141 |
+
info = (pid_dir / "fdinfo" / fd.name).read_text()
|
| 142 |
+
except OSError:
|
| 143 |
+
continue
|
| 144 |
+
flags = pos = 0
|
| 145 |
+
for line in info.splitlines():
|
| 146 |
+
if line.startswith("flags:"):
|
| 147 |
+
flags = int(line.split()[1], 8)
|
| 148 |
+
elif line.startswith("pos:"):
|
| 149 |
+
pos = int(line.split()[1])
|
| 150 |
+
# O_RDONLY is 0 in the low two bits. Readers (the manager parsing a
|
| 151 |
+
# trace, a repin hook) are not at risk and must not be snapshotted
|
| 152 |
+
# at their seek position.
|
| 153 |
+
if flags & 0o3 == 0:
|
| 154 |
+
continue
|
| 155 |
+
prev = out.get(target)
|
| 156 |
+
if prev is None or pos > prev["pos"]:
|
| 157 |
+
out[target] = {"pos": pos, "pid": int(pid_dir.name)}
|
| 158 |
+
return out
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def trim_to_last_record(raw: bytes):
|
| 162 |
+
"""Drop a half-written trailing line. Returns (kept, dropped, bad_lines)."""
|
| 163 |
+
cut = raw.rfind(b"\n")
|
| 164 |
+
kept, dropped = (raw[: cut + 1], len(raw) - cut - 1) if cut >= 0 else (b"", len(raw))
|
| 165 |
+
bad = 0
|
| 166 |
+
for line in kept.splitlines():
|
| 167 |
+
if not line.strip():
|
| 168 |
+
continue
|
| 169 |
+
try:
|
| 170 |
+
json.loads(line)
|
| 171 |
+
except Exception:
|
| 172 |
+
bad += 1
|
| 173 |
+
return kept, dropped, bad
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def snapshot():
|
| 177 |
+
live = open_rollouts()
|
| 178 |
+
if not live:
|
| 179 |
+
print("no Codex rollout is open for writing — nothing to migrate")
|
| 180 |
+
return 0
|
| 181 |
+
|
| 182 |
+
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
|
| 183 |
+
run_dir = STAGE / run_id
|
| 184 |
+
entries, concerns = [], 0
|
| 185 |
+
|
| 186 |
+
print(f"==> {len(live)} open rollout(s); bucket {BUCKET}")
|
| 187 |
+
for path, fd in sorted(live.items()):
|
| 188 |
+
p = Path(path)
|
| 189 |
+
raw = p.read_bytes() # the writer's view, buffer included
|
| 190 |
+
kept, dropped, bad = trim_to_last_record(raw)
|
| 191 |
+
digest = hashlib.sha256(kept).hexdigest()
|
| 192 |
+
|
| 193 |
+
remote_size = object_size(remote_of(p))
|
| 194 |
+
|
| 195 |
+
at_risk = len(kept) - (remote_size or 0)
|
| 196 |
+
staged = run_dir / remote_of(p)
|
| 197 |
+
if not DRY:
|
| 198 |
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
| 199 |
+
staged.write_bytes(kept)
|
| 200 |
+
|
| 201 |
+
entries.append({
|
| 202 |
+
"path": str(p),
|
| 203 |
+
"remote": remote_of(p),
|
| 204 |
+
"pid": fd["pid"],
|
| 205 |
+
"writer_pos": fd["pos"],
|
| 206 |
+
"bytes": len(kept),
|
| 207 |
+
"sha256": digest,
|
| 208 |
+
"partial_tail_dropped": dropped,
|
| 209 |
+
"invalid_lines": bad,
|
| 210 |
+
"object_bytes_before": remote_size,
|
| 211 |
+
"bytes_at_risk": at_risk,
|
| 212 |
+
})
|
| 213 |
+
if bad:
|
| 214 |
+
concerns += 1
|
| 215 |
+
flag = f" !! {bad} unparseable line(s)" if bad else ""
|
| 216 |
+
print(f" {p.name}")
|
| 217 |
+
print(f" local {len(kept):>10} object {str(remote_size):>10}"
|
| 218 |
+
f" at risk {at_risk:>10} dropped {dropped}{flag}")
|
| 219 |
+
|
| 220 |
+
if DRY:
|
| 221 |
+
print("==> --dry-run: nothing staged, nothing uploaded")
|
| 222 |
+
return 0
|
| 223 |
+
|
| 224 |
+
manifest = {
|
| 225 |
+
"run_id": run_id,
|
| 226 |
+
"created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 227 |
+
"bucket": BUCKET,
|
| 228 |
+
"space": os.environ.get("SPACE_ID"),
|
| 229 |
+
"entries": entries,
|
| 230 |
+
}
|
| 231 |
+
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
| 232 |
+
|
| 233 |
+
dest = f"hf://buckets/{BUCKET}/{PREFIX}/{run_id}"
|
| 234 |
+
print(f"==> uploading {run_dir} -> {dest}")
|
| 235 |
+
sync_bucket(source=str(run_dir), dest=dest, token=TOKEN, quiet=True)
|
| 236 |
+
|
| 237 |
+
# Verify by reading the objects back, not by trusting the writer. A copy
|
| 238 |
+
# nobody checked is the same bet we are trying to get off.
|
| 239 |
+
print("==> verifying")
|
| 240 |
+
for e in entries:
|
| 241 |
+
key = f"{PREFIX}/{run_id}/{e['remote']}"
|
| 242 |
+
size = object_size(key)
|
| 243 |
+
if size is None:
|
| 244 |
+
print(f" !! {e['remote']}: no object after upload")
|
| 245 |
+
concerns += 1
|
| 246 |
+
continue
|
| 247 |
+
if size != e["bytes"]:
|
| 248 |
+
print(f" !! {e['remote']}: object {size} B, staged {e['bytes']} B")
|
| 249 |
+
concerns += 1
|
| 250 |
+
continue
|
| 251 |
+
check = run_dir / "verify" / e["remote"]
|
| 252 |
+
check.parent.mkdir(parents=True, exist_ok=True)
|
| 253 |
+
download_bucket_files(BUCKET, [(key, str(check))], token=TOKEN)
|
| 254 |
+
got = hashlib.sha256(check.read_bytes()).hexdigest()
|
| 255 |
+
if got != e["sha256"]:
|
| 256 |
+
print(f" !! {e['remote']}: sha256 mismatch on readback")
|
| 257 |
+
concerns += 1
|
| 258 |
+
else:
|
| 259 |
+
print(f" ok {Path(e['remote']).name} {e['bytes']} B {got[:12]}")
|
| 260 |
+
check.unlink(missing_ok=True)
|
| 261 |
+
shutil.rmtree(run_dir / "verify", ignore_errors=True)
|
| 262 |
+
|
| 263 |
+
print(f"==> run {run_id} at hf://buckets/{BUCKET}/{PREFIX}/{run_id}")
|
| 264 |
+
if concerns:
|
| 265 |
+
print(f"!! {concerns} problem(s) — do NOT restart until these are understood",
|
| 266 |
+
file=sys.stderr)
|
| 267 |
+
return 1
|
| 268 |
+
print("==> every open rollout is a verified closed object; safe to deploy")
|
| 269 |
+
return 0
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def restore():
|
| 273 |
+
"""Boot side. Put back anything the dying writer failed to flush.
|
| 274 |
+
|
| 275 |
+
Runs before agents start, when no descriptor is held. A migrated copy is
|
| 276 |
+
only ever restored when it is strictly longer than what is on the canonical
|
| 277 |
+
path, so a container that shut down cleanly — and therefore flushed a
|
| 278 |
+
complete rollout — is left completely alone.
|
| 279 |
+
"""
|
| 280 |
+
runs = sorted(
|
| 281 |
+
{Path(f.path).parts[3] for f in list_bucket_tree(BUCKET, PREFIX, recursive=True, token=TOKEN)
|
| 282 |
+
if Path(f.path).name == "manifest.json"}
|
| 283 |
+
)
|
| 284 |
+
if not runs:
|
| 285 |
+
print("no migration runs on the bucket — nothing to restore")
|
| 286 |
+
return 0
|
| 287 |
+
|
| 288 |
+
run_id = runs[-1]
|
| 289 |
+
local_manifest = STAGE / f"manifest-{run_id}.json"
|
| 290 |
+
local_manifest.parent.mkdir(parents=True, exist_ok=True)
|
| 291 |
+
download_bucket_files(BUCKET, [(f"{PREFIX}/{run_id}/manifest.json", str(local_manifest))],
|
| 292 |
+
token=TOKEN)
|
| 293 |
+
manifest = json.loads(local_manifest.read_text())
|
| 294 |
+
print(f"==> newest migration run {run_id}, {len(manifest['entries'])} rollout(s)")
|
| 295 |
+
|
| 296 |
+
restored = failed = 0
|
| 297 |
+
for e in manifest["entries"]:
|
| 298 |
+
target = Path(e["path"])
|
| 299 |
+
have = target.stat().st_size if target.exists() else 0
|
| 300 |
+
if have >= e["bytes"]:
|
| 301 |
+
print(f" skip {target.name}: on disk {have} B >= migrated {e['bytes']} B")
|
| 302 |
+
continue
|
| 303 |
+
if DRY:
|
| 304 |
+
print(f" would restore {target.name}: {have} B -> {e['bytes']} B")
|
| 305 |
+
restored += 1
|
| 306 |
+
continue
|
| 307 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 308 |
+
tmp = STAGE / "restore" / e["remote"]
|
| 309 |
+
tmp.parent.mkdir(parents=True, exist_ok=True)
|
| 310 |
+
download_bucket_files(BUCKET, [(f"{PREFIX}/{run_id}/{e['remote']}", str(tmp))],
|
| 311 |
+
token=TOKEN)
|
| 312 |
+
if hashlib.sha256(tmp.read_bytes()).hexdigest() != e["sha256"]:
|
| 313 |
+
print(f" !! {target.name}: migrated copy fails its own checksum; left alone")
|
| 314 |
+
failed += 1
|
| 315 |
+
continue
|
| 316 |
+
# Stage locally, then one close()d copy onto the mount. Never stream a
|
| 317 |
+
# download straight through FUSE — that is the write pattern this whole
|
| 318 |
+
# script exists to work around.
|
| 319 |
+
shutil.copyfile(tmp, target)
|
| 320 |
+
tmp.unlink(missing_ok=True)
|
| 321 |
+
print(f" restored {target.name}: {have} B -> {e['bytes']} B")
|
| 322 |
+
restored += 1
|
| 323 |
+
|
| 324 |
+
shutil.rmtree(STAGE / "restore", ignore_errors=True)
|
| 325 |
+
print(f"==> {restored} restored, {failed} failed")
|
| 326 |
+
return 1 if failed else 0
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
sys.exit(restore() if RESTORE else snapshot())
|
| 330 |
+
PY
|