125 lines
6.2 KiB
Python
Executable File
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.25.6-" + 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.25.6", "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.25.6. 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()
|