Spaces:
Running
Running
File size: 8,617 Bytes
22de727 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | """Pull annotations + the 2 macro YAMLs down from a MiniWeb Railway deployment.
Backs up the current LOCAL data/annotations + macro YAMLs (timestamped tarball
under data/backups/), then downloads and REPLACES them with the deployment's
copies, served by the token-gated GET /recovery/{annotations,macros}/download
endpoints.
Usage:
MINIWEB_RECOVERY_TOKEN=<token> python src/miniweb/scripts/pull_from_railway.py
python src/miniweb/scripts/pull_from_railway.py --url https://<app>.up.railway.app --token <token>
Options:
--url deployment base URL (default $MINIWEB_RAILWAY_URL or the prod URL)
--token recovery token (default $MINIWEB_RECOVERY_TOKEN)
--no-backup skip the local backup (not recommended)
--annotations-only / --macros-only pull just one of the two
--new-only import new task directories, preserve existing local work;
retain the remote snapshot and a difference manifest
"""
import argparse
import glob
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tarfile
import tempfile
import time
import urllib.request
REPO = pathlib.Path(__file__).resolve().parents[3] # the repository
DATA = REPO / "data"
ANN = DATA / "annotations"
MACRO_YAMLS = ["macros.yaml", "macro_locations.yaml"]
BACKUP_DIR = DATA / "backups"
DEFAULT_URL = os.environ.get("MINIWEB_RAILWAY_URL",
os.environ.get("MINIWEB_DEPLOY_URL", ""))
def _download(base, token, path, dest):
req = urllib.request.Request(base.rstrip("/") + path,
headers={"X-Recovery-Token": token})
with urllib.request.urlopen(req, timeout=1200) as r, open(dest, "wb") as f:
shutil.copyfileobj(r, f, length=1 << 20)
def _safe_extract(tar_path, dest):
"""Validate the archive fully (truncated gzip raises), then extract."""
with tarfile.open(tar_path, "r:gz") as tar:
members = tar.getmembers() # forces a full parse
for m in members: # reject path traversal
if (m.name.startswith("/") or ".." in pathlib.PurePosixPath(m.name).parts
or not (m.isfile() or m.isdir())):
raise ValueError(f"unsafe path in archive: {m.name}")
tar.extractall(dest)
return len(members)
def _backup(ts):
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
bak = BACKUP_DIR / f"pull_backup_{ts}.tar.gz"
with tarfile.open(bak, "w:gz") as tar:
if ANN.exists():
tar.add(ANN, arcname="annotations")
for y in MACRO_YAMLS:
p = DATA / y
if p.exists():
tar.add(p, arcname=y)
try:
shown = bak.relative_to(REPO)
except ValueError:
shown = bak
print(f"✓ backed up local annotations + macro YAMLs -> {shown} "
f"({bak.stat().st_size // 1024} KB)")
def _tree_hashes(directory):
result = {}
for p in sorted(directory.rglob("*")):
if p.is_file():
with p.open("rb") as stream:
result[str(p.relative_to(directory))] = hashlib.file_digest(stream, "sha256").hexdigest()
return result
def _import_new(snapshot, destination):
"""Existing directories are never overwritten, including partial local work."""
manifest = {"new": [], "unchanged": [], "existing_differences": [], "local_only": [], "trashed_locally": []}
remote_keys = set()
for metadata in sorted(snapshot.glob("*/*/task.json")):
relative = metadata.parent.relative_to(snapshot)
if any(part.startswith(".") for part in relative.parts):
continue
key = str(relative)
remote_keys.add(key)
# A task deleted locally (moved to .trash/<annotator>/<task_id>-<stamp>) stays deleted.
if any((destination / ".trash" / relative.parent).glob(glob.escape(relative.name) + "-*")):
manifest["trashed_locally"].append(key)
continue
# Validate new metadata before copying any files for this task.
task = json.loads(metadata.read_text())
if not isinstance(task, dict) or task.get("task_id") != relative.name:
raise ValueError(f"Invalid task metadata: {key}")
target = destination / relative
if target.exists():
local, remote = _tree_hashes(target), _tree_hashes(metadata.parent)
changed = [p for p in sorted(local.keys() | remote.keys()) if local.get(p) != remote.get(p)]
if changed:
manifest["existing_differences"].append({"key": key, "files": changed})
else:
manifest["unchanged"].append(key)
else:
target.parent.mkdir(parents=True, exist_ok=True)
# Stage on the same filesystem; expose only complete task directories.
with tempfile.TemporaryDirectory(prefix=".import-", dir=target.parent) as staging:
ready = pathlib.Path(staging) / relative.name
shutil.copytree(metadata.parent, ready)
ready.rename(target)
manifest["new"].append(key)
manifest["local_only"] = sorted(str(p.parent.relative_to(destination))
for p in destination.glob("*/*/task.json")
if str(p.parent.relative_to(destination)) not in remote_keys and
not any(part.startswith(".") for part in p.relative_to(destination).parts))
return manifest
def _pull_annotations(base, token, new_only=False, ts=None):
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = tmp.name
try:
_download(base, token, "/recovery/annotations/download", tmp_path)
# Extract and validate BEFORE touching local, including archive links.
DATA.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".railway-pull-", dir=DATA) as staging:
snapshot = pathlib.Path(staging) / "annotations"
snapshot.mkdir()
_safe_extract(tmp_path, snapshot)
if new_only:
run = DATA / "railway_pulls" / (ts or time.strftime("%Y%m%d_%H%M%S"))
run.mkdir(parents=True, exist_ok=False)
snapshot.rename(run / "remote_annotations")
manifest = _import_new(run / "remote_annotations", ANN)
manifest.update({"source": base, "mode": "new-only", "snapshot": str(run / "remote_annotations")})
(run / "manifest.json").write_text(json.dumps(manifest, indent=2))
print(f"✓ imported {len(manifest['new'])} new tasks; preserved "
f"{len(manifest['existing_differences'])} differing local tasks. Manifest: {run / 'manifest.json'}")
else:
if ANN.exists():
shutil.rmtree(ANN)
snapshot.rename(ANN)
finally:
os.remove(tmp_path)
tasks = sum(1 for _ in ANN.glob("*/*/task.json"))
print(f"✓ pulled annotations -> data/annotations ({tasks} tasks)")
def _pull_macros(base, token):
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = tmp.name
try:
_download(base, token, "/recovery/macros/download", tmp_path)
with tarfile.open(tmp_path, "r:gz") as t:
names = [m.name for m in t.getmembers()]
_safe_extract(tmp_path, DATA)
finally:
os.remove(tmp_path)
print(f"✓ pulled macro YAMLs -> data/ ({', '.join(names)})")
def main():
ap = argparse.ArgumentParser(description="Pull annotations + macro YAMLs from Railway.")
ap.add_argument("--url", default=DEFAULT_URL)
ap.add_argument("--token", default=os.environ.get("MINIWEB_RECOVERY_TOKEN", ""))
ap.add_argument("--no-backup", action="store_true")
ap.add_argument("--annotations-only", action="store_true")
ap.add_argument("--macros-only", action="store_true")
ap.add_argument("--new-only", action="store_true", help="Preserve existing tasks; implies --annotations-only")
args = ap.parse_args()
if args.new_only and args.macros_only:
ap.error("--new-only cannot be combined with --macros-only")
if not args.token:
sys.exit("No token: set MINIWEB_RECOVERY_TOKEN or pass --token")
print(f"Pulling from {args.url}")
ts = time.strftime("%Y%m%d_%H%M%S")
if not args.no_backup:
_backup(ts)
if not args.macros_only:
_pull_annotations(args.url, args.token, new_only=args.new_only, ts=ts)
if not (args.annotations_only or args.new_only):
_pull_macros(args.url, args.token)
print("Done.")
if __name__ == "__main__":
main()
|