"""Deploy the redesigned site to the Helsinki server (ssh host "hels") and roll it back. py scripts/deploy_site.py --dry-run show the plan, change nothing py scripts/deploy_site.py back up the live files, upload, verify py scripts/deploy_site.py --rollback restore the newest backup (or --stamp for another) py scripts/deploy_site.py --portal swap the /lihont2 portal's server.py, restart it, roll back on failure Only the files listed in stage_site.PAYLOAD are touched. Before overwriting, each live file is copied to /var/backups/lihont-site// (outside the web root, so it is never served). Files are uploaded under a temporary name and renamed into place, so a visitor never sees a half-written page. Downloads, logos and the old md3-theme.css are left alone. """ from __future__ import annotations import argparse import hashlib import shlex import subprocess import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import stage_site # noqa: E402 HOST = "hels" WEB_ROOT = "/var/www/lihont" BACKUPS = "/var/backups/lihont-site" SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", HOST] def remote(command: str, data: bytes | None = None) -> subprocess.CompletedProcess: return subprocess.run(SSH + [command], input=data, capture_output=True, timeout=180) def text(result: subprocess.CompletedProcess) -> str: # ssh prints a post-quantum key exchange warning on stderr; it is not an error. lines = [line for line in result.stderr.decode("utf-8", "replace").splitlines() if line and not line.startswith("**")] return "\n".join(lines) def deploy(dry_run: bool) -> int: stage_site.main() files = [(stage_site.STAGE / target, target) for _source, target in stage_site.PAYLOAD] stamp = time.strftime("%Y%m%d-%H%M%S") backup = "%s/%s" % (BACKUPS, stamp) print("Backup folder on the server: %s" % backup) for local, target in files: print(" %-26s %7d bytes" % (target, local.stat().st_size)) if dry_run: print("Dry run: nothing was changed.") return 0 # 1. Back up every live file that exists. script = "set -e; mkdir -p %s; " % shlex.quote(backup) for _local, target in files: live = "%s/%s" % (WEB_ROOT, target) script += "if [ -f %s ]; then mkdir -p %s; cp -p %s %s; fi; " % ( shlex.quote(live), shlex.quote("%s/%s" % (backup, Path(target).parent.as_posix())), shlex.quote(live), shlex.quote("%s/%s" % (backup, target))) result = remote(script) if result.returncode: print("Backup failed, nothing was uploaded:\n" + text(result)) return 1 # 2. Upload under a temporary name, then rename into place. for local, target in files: live = "%s/%s" % (WEB_ROOT, target) command = ("set -e; mkdir -p %s; cat > %s.new && chown root:root %s.new && chmod 644 %s.new " "&& mv -f %s.new %s") % tuple( [shlex.quote(str(Path(live).parent.as_posix()))] + [shlex.quote(live)] * 5) result = remote(command, local.read_bytes()) if result.returncode: print("Upload of %s failed:\n%s\nRoll back with: py scripts/deploy_site.py --rollback" % (target, text(result))) return 1 print("uploaded %s" % target) # 3. Verify that the server holds exactly what was staged. failed = 0 for local, target in files: digest = hashlib.sha256(local.read_bytes()).hexdigest() result = remote("sha256sum %s" % shlex.quote("%s/%s" % (WEB_ROOT, target))) live = result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?" status = "ok" if live == digest else "MISMATCH" failed += status != "ok" print(" %-26s %s" % (target, status)) if failed: print("Some files differ. Roll back with: py scripts/deploy_site.py --rollback") return 1 print("Deployed. Backup: %s" % backup) return 0 PORTAL_SOURCE = Path(__file__).resolve().parents[1] / "src" / "web" / "server.py" PORTAL_PATH = "/opt/lihont2/server.py" PORTAL_BACKUPS = "/var/backups/lihont-portal" PORTAL_SERVICE = "lihont2" PORTAL_URL = "http://127.0.0.1:18750/lihont2/" def portal_healthy() -> bool: check = "sleep 2; systemctl is-active %s && curl -s -o /dev/null -w '%%{http_code}' %s" % (PORTAL_SERVICE, PORTAL_URL) result = remote(check) return result.returncode == 0 and result.stdout.decode().split()[-1:] == ["200"] def restore_portal(stamp: str) -> int: backup = "%s/%s/server.py" % (PORTAL_BACKUPS, stamp) command = "set -e; cp -p %s %s.new && mv -f %s.new %s && systemctl restart %s" % ( shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE) result = remote(command) print(text(result) or "restored %s" % backup) return result.returncode or (0 if portal_healthy() else 1) def deploy_portal(dry_run: bool) -> int: """Swap /opt/lihont2/server.py and restart the portal; sessions live in sqlite, so logins survive.""" data = PORTAL_SOURCE.read_bytes() stamp = time.strftime("%Y%m%d-%H%M%S") print("server.py: %d bytes, backup %s/%s (root only)" % (len(data), PORTAL_BACKUPS, stamp)) if dry_run: print("Dry run: nothing was changed.") return 0 # Upload next to the target and parse it with the server's own Python before anything is replaced. result = remote("cat > %s.new && python3 -c \"import ast; ast.parse(open('%s.new').read())\"" % (PORTAL_PATH, PORTAL_PATH), data) if result.returncode: print("The new server.py does not parse on the server; nothing was replaced:\n" + text(result)) remote("rm -f %s.new" % PORTAL_PATH) return 1 backup = "%s/%s" % (PORTAL_BACKUPS, stamp) command = ("set -e; umask 077; mkdir -p %s; cp -p %s %s/server.py; chown root:root %s.new; chmod 644 %s.new; " "mv -f %s.new %s; systemctl restart %s") % ( shlex.quote(backup), PORTAL_PATH, shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE) result = remote(command) if result.returncode: print("Swap failed:\n" + text(result)) return 1 if not portal_healthy(): print("The portal is not answering with the new server.py; restoring the old one.") return restore_portal(stamp) or 1 print("Portal restarted and answering. Backup: %s" % backup) return 0 def rollback_portal(stamp: str | None) -> int: result = remote("ls -1 %s | sort" % shlex.quote(PORTAL_BACKUPS)) stamps = result.stdout.decode().split() if result.returncode or not stamps: print("No portal backup found on the server.") return 1 print("Portal backups: %s" % ", ".join(stamps)) return restore_portal(stamp or stamps[-1]) def rollback(stamp: str | None) -> int: result = remote("ls -1 %s | sort" % shlex.quote(BACKUPS)) stamps = result.stdout.decode().split() if result.returncode or not stamps: print("No backup found on the server.") return 1 print("Backups: %s" % ", ".join(stamps)) stamp = stamp or stamps[-1] if stamp not in stamps: print("No such backup: %s" % stamp) return 1 backup = "%s/%s" % (BACKUPS, stamp) print("Restoring %s" % backup) command = ("set -e; cd %s; find . -type f | while read f; do " "mkdir -p \"$(dirname %s/$f)\"; cp -p \"$f\" \"%s/$f.new\" && mv -f \"%s/$f.new\" \"%s/$f\"; done; echo restored") command = command % (shlex.quote(backup), WEB_ROOT, WEB_ROOT, WEB_ROOT, WEB_ROOT) result = remote(command) print(result.stdout.decode().strip() or text(result)) return result.returncode def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--rollback", action="store_true") parser.add_argument("--stamp", help="backup to restore with --rollback (default: the newest)") parser.add_argument("--portal", action="store_true", help="act on the /lihont2 portal (server.py) instead of the static site") args = parser.parse_args() if args.portal: return rollback_portal(args.stamp) if args.rollback else deploy_portal(args.dry_run) return rollback(args.stamp) if args.rollback else deploy(args.dry_run) if __name__ == "__main__": sys.exit(main())