"""Swap the LihonT members service (badges and avatar shapes) on the Helsinki server and roll it back. py scripts/deploy_members.py --dry-run compare the live server.py with the local one, change nothing py scripts/deploy_members.py back up, upload, restart lihont-members, roll back on failure py scripts/deploy_members.py --rollback restore the newest backup (or --stamp ) Only /opt/lihont-members/server.py is replaced; its database is never touched (new tables are created by the service itself when it starts, so an older server.py keeps working on the same file). The live file is copied to server.py.bak- first, the new one is compiled on the server before it goes live, and the service is asked for its health after the restart; a failed check puts the backup back. """ from __future__ import annotations import argparse import hashlib import shlex import subprocess import sys import time from pathlib import Path HOST = "hels" FOLDER = "/opt/lihont-members" LIVE = FOLDER + "/server.py" SERVICE = "lihont-members" HEALTH = "http://127.0.0.1:18746/members/v1/health" SOURCE = Path(__file__).resolve().parents[1] / "services" / "members" / "server.py" 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=120) 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 digest_of_live() -> str: result = remote("sha256sum %s" % shlex.quote(LIVE)) return result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?" def healthy() -> bool: return remote("curl -sf %s" % shlex.quote(HEALTH)).returncode == 0 def deploy(dry_run: bool) -> int: data = SOURCE.read_bytes() local = hashlib.sha256(data).hexdigest() live = digest_of_live() print("local %s\nlive %s" % (local, live)) if dry_run: print("Dry run: nothing was changed." if local != live else "Already deployed.") return 0 if local == live: print("The server already runs this file.") return 0 stamp = time.strftime("%Y%m%d-%H%M%S") backup = "%s.bak-%s" % (LIVE, stamp) result = remote("set -e; cat > /tmp/members-server.new && python3 -m py_compile /tmp/members-server.new " "&& cp -p %s %s && install -m 644 /tmp/members-server.new %s && rm -f /tmp/members-server.new" % (shlex.quote(LIVE), shlex.quote(backup), shlex.quote(LIVE)), data) if result.returncode: print("Upload failed, the live file is untouched:\n" + text(result)) return 1 print("backup: %s" % backup) remote("systemctl restart %s" % SERVICE) time.sleep(2) if healthy(): print("The service answers; deployed.") return 0 print("The service did not answer: putting the backup back.") remote("cp -p %s %s && systemctl restart %s" % (shlex.quote(backup), shlex.quote(LIVE), SERVICE)) time.sleep(2) print("Rolled back; the service answers: %s" % healthy()) return 1 def rollback(stamp: str | None) -> int: if stamp: backup = "%s.bak-%s" % (LIVE, stamp) else: listing = remote("ls -1 %s.bak-2* 2>/dev/null | tail -1" % shlex.quote(LIVE)).stdout.decode().strip() if not listing: print("No dated backup found.") return 1 backup = listing result = remote("cp -p %s %s && systemctl restart %s" % (shlex.quote(backup), shlex.quote(LIVE), SERVICE)) if result.returncode: print("Rollback failed:\n" + text(result)) return 1 time.sleep(2) print("Restored %s; the service answers: %s" % (backup, healthy())) return 0 if healthy() else 1 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") args = parser.parse_args() return rollback(args.stamp) if args.rollback else deploy(args.dry_run) if __name__ == "__main__": sys.exit(main())