Files
RustProxy/scripts/upgrade.py
T
2026-10-07 13:40:21 +02:00

125 lines
6.2 KiB
Python
Executable File

#!/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.1-" + 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.1", "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.1. 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()