"""Deploy a verified image, never rebuild during deployment or rollback.""" import fcntl import json import os from pathlib import Path import re import shutil import subprocess import urllib.request from ci import APP, ROOT, fingerprint, run from smoke import verify DEPLOY_ROOT = Path("/opt/endorphin-tabularium-docs") CONFIG = Path("/etc/endorphin-tabularium-docs/production.env") def latest_main() -> str: base = os.environ["GITEA_SERVER_URL"].rstrip("/") if base != "https://gitea.entroia.com": raise ValueError("Unexpected Gitea API origin") request = urllib.request.Request(base + "/api/v1/repos/Zayden-Jung/endorphin-tabularium-docs/branches/main", headers={"Authorization": "token " + os.environ["GITEA_TOKEN"]}) with urllib.request.urlopen(request, timeout=25) as response: return json.load(response)["commit"]["id"] def compose(release: Path, *args: str) -> None: run("docker", "compose", "-p", APP, "--env-file", str(CONFIG), "--env-file", str(release / "release.env"), "-f", str(release / "compose.yml"), *args) def main() -> None: if os.geteuid() != 0: raise RuntimeError("Deployment requires root on ces-control") sha = os.environ["TABULARIUM_RELEASE_SHA"] if not re.fullmatch(r"[0-9a-f]{40}", sha) or sha != run("git", "rev-parse", "HEAD", capture=True): raise ValueError("Release SHA does not match checkout") DEPLOY_ROOT.mkdir(parents=True, exist_ok=True) with (DEPLOY_ROOT / ".deploy.lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) if latest_main() != sha: print("Skipping stale release: main has advanced", flush=True) return expected = f"{APP}:verified-main-{fingerprint()}" actual = (ROOT / ".artifacts/verified-image").read_text().strip() if actual != expected: raise RuntimeError("Verified image does not match current source") run("docker", "image", "inspect", actual, capture=True) CONFIG.parent.mkdir(parents=True, exist_ok=True) if not CONFIG.exists(): CONFIG.write_text("TABULARIUM_BIND=0.0.0.0\nTABULARIUM_PORT=18132\n") CONFIG.chmod(0o600) settings = dict(line.split("=", 1) for line in CONFIG.read_text().splitlines() if line and not line.startswith("#")) port = int(settings.get("TABULARIUM_PORT", "18132")) if not 1024 <= port <= 65535: raise ValueError("Invalid port") current = DEPLOY_ROOT / "current" previous = current.resolve() if current.is_symlink() else None release = DEPLOY_ROOT / "releases" / sha release.mkdir(parents=True, exist_ok=True) shutil.copyfile(ROOT / "docker-compose.ces-control.yml", release / "compose.yml") (release / "release.env").write_text(f"TABULARIUM_IMAGE={actual}\n") try: compose(release, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120") verify(f"http://127.0.0.1:{port}") except Exception: if previous is not None: print("Deployment failed; restoring previous verified release", flush=True) compose(previous, "up", "-d", "--no-build", "--wait", "--wait-timeout", "120") else: compose(release, "stop", "docs") raise pending = DEPLOY_ROOT / "current.next" if pending.is_symlink(): pending.unlink() pending.symlink_to(release) pending.replace(current) compose(release, "ps") print(f"Deployed release={sha} image={actual} port={port}", flush=True) if __name__ == "__main__": main()