#!/usr/bin/env python3 """Replace a stopped RustProxy source tree, preserving old code, data and .env.""" from __future__ import annotations import argparse import json import os from pathlib import Path import shutil import sys import uuid MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh", "README.md", "ROADMAP.md", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS", ".env.example", ".gitignore"] def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None = None) -> Path: source = source.resolve() if target.is_symlink(): raise ValueError("Target must not be a symlink.") target = target.resolve() if not stopped: raise ValueError("Stop RustProxy and pass --stopped. Source replacement is not a live-update operation.") if target == source or target in source.parents or source in target.parents: raise ValueError("Source and destination must be separate, non-nested directories.") manifest = target / "Cargo.toml" if not manifest.is_file() or 'name = "rust-proxy"' not in manifest.read_text(encoding="utf-8"): raise ValueError("Target is not an existing RustProxy source directory.") locks = [] try: if os.name == "posix": import fcntl for directory in {target / "data", data_dir or target / "data"}: path = directory / "process.lock" if path.exists(): if path.is_symlink(): raise ValueError("Refusing a symlinked process lock.") stream = path.open("r+") try: fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: stream.close() raise ValueError("A RustProxy server still owns the data directory. Stop it first.") from None locks.append(stream) for name in MANAGED: if (target / name).is_symlink(): raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.") backup = target / ".upgrade-backup" / ("before-0.27.2-" + uuid.uuid4().hex[:12]) backup.mkdir(parents=True, mode=0o700) installed: list[str] = [] moved: list[str] = [] try: for name in MANAGED: old, new = target / name, source / name if old.exists(): old.rename(backup / name) moved.append(name) if new.is_dir(): installed.append(name) shutil.copytree(new, old) if name == "langs" and (backup / name).is_dir(): # Preserve additional site language packs; bundled packs are # updated, with their previous bytes retained in the backup. for pack in (backup / name).glob("*.json"): if pack.is_symlink(): raise ValueError("Language packs must not be symlinks.") if not (old / pack.name).exists(): shutil.copy2(pack, old / pack.name) elif new.is_file(): installed.append(name) shutil.copy2(new, old) # Old renewal observations were diagnostic scheduler state only; # certificates, accounts and renewal profiles live elsewhere. 0.19.1 # has no runtime JSON importer. Validate the obsolete sidecar now, # write the completed source-upgrade manifest, then remove the file # as the final fallible operation. SQLite observations are rebuilt by # the normal renewal scheduler. No second legacy copy is retained. runtime_data = (data_dir or (target / "data")).resolve() certs_dir = runtime_data / "certs" if certs_dir.is_symlink(): raise ValueError("Certificate data directory must not be a symlink during legacy cleanup.") legacy = certs_dir / "renewals.json" remove_legacy = legacy.exists() if remove_legacy and (legacy.is_symlink() or not legacy.is_file()): raise ValueError("Obsolete renewal observation sidecar must be a regular file.") (backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.27.2", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") if remove_legacy: legacy.unlink() except BaseException: for name in reversed(installed): path = target / name if path.is_dir(): shutil.rmtree(path) elif path.exists(): path.unlink() for name in moved: (backup / name).rename(target / name) raise return backup finally: for stream in locks: stream.close() def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("target", type=Path) parser.add_argument("--stopped", action="store_true") parser.add_argument("--data-dir", type=Path, help="also check the process lock when data lives outside target/data") args = parser.parse_args() os.umask(0o077) data_dir = args.data_dir or (Path(os.environ["RUST_PROXY_DATA_DIR"]) if os.environ.get("RUST_PROXY_DATA_DIR") else None) try: backup = upgrade(Path(__file__).resolve().parents[1], args.target, stopped=args.stopped, data_dir=data_dir) except (OSError, ValueError) as error: parser.exit(2, f"Upgrade refused or rolled back: {error}\n") print(f"Installed RustProxy 0.27.2. Previous managed source files: {backup}") print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.") print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh") if __name__ == "__main__": main()