106 lines
4.6 KiB
Python
106 lines
4.6 KiB
Python
"""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 socket
|
|
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 check_port(port: int) -> None:
|
|
# An existing container belonging to this Compose service may retain its port.
|
|
own = run("docker", "ps", "-q", "--filter", f"label=com.docker.compose.project={APP}",
|
|
"--filter", "label=com.docker.compose.service=docs", capture=True)
|
|
if own:
|
|
for container in own.splitlines():
|
|
details = json.loads(run("docker", "inspect", container, capture=True))[0]
|
|
ports = details["NetworkSettings"]["Ports"].get("3000/tcp") or []
|
|
if any(item["HostPort"] == str(port) for item in ports):
|
|
print(f"Port {port} belongs to the current Tabularium service", flush=True)
|
|
return
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
try:
|
|
probe.bind(("0.0.0.0", port))
|
|
except OSError as error:
|
|
raise RuntimeError(f"Port {port} is already occupied; no service was changed") from error
|
|
print(f"Port {port} is available", flush=True)
|
|
|
|
|
|
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=18135\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", "18135"))
|
|
if not 1024 <= port <= 65535:
|
|
raise ValueError("Invalid port")
|
|
check_port(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()
|