#!/usr/bin/env python3 """Apply validated main-branch image requests to the website Compose project.""" import fcntl import os from pathlib import Path import pwd import re import stat import subprocess import tempfile ROOT = Path("/opt/autolibrary-website") REQUEST = Path("/var/lib/autolibrary-release-requests/request") REPOSITORY = "gitea.kenanzhu.com/autolibrary/website" SOURCE = "https://gitea.kenanzhu.com/AutoLibrary/Website.git" DOCKER = ["/usr/bin/docker", "--host", "unix:///var/run/docker.sock"] def run(*args): result = subprocess.run(args, check=True, capture_output=True, text=True, timeout=240) return result.stdout.strip() def compose_up(): run( *DOCKER, "compose", "--project-name", "autolibrary", "--project-directory", str(ROOT), "--env-file", str(ROOT / "website.env"), "-f", str(ROOT / "compose.yaml"), "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "65", "website", ) def write_atomic(path, content): mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600 with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as stream: temporary = Path(stream.name) try: os.fchmod(stream.fileno(), mode) stream.write(content) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def read_request(): fd = os.open(REQUEST, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(fd, "rb") as stream: info = os.fstat(stream.fileno()) if (not stat.S_ISREG(info.st_mode) or info.st_size > 256 or info.st_uid != pwd.getpwnam("autolibrary-deploy").pw_uid): raise ValueError("Invalid release request file") value = stream.read(257).decode("ascii").strip() if not re.fullmatch(r"[0-9a-f]{40} sha256:[0-9a-f]{64}", value): raise ValueError("Invalid release request") return value def main_head(): return run("/usr/bin/git", "ls-remote", "--exit-code", SOURCE, "refs/heads/main").split()[0] def update(): try: request = read_request() except FileNotFoundError: return commit, digest = request.split() image = f"{REPOSITORY}@{digest}" environment = ROOT / "website.env" original = environment.read_text() matches = re.findall(r"^AUTOLIBRARY_IMAGE=(.+)$", original, re.MULTILINE) if len(matches) != 1 or not re.fullmatch( re.escape(REPOSITORY) + r"@sha256:[0-9a-f]{64}", matches[0] ): raise ValueError("Unexpected production image setting") if matches[0] == image: state = run( *DOCKER, "inspect", "--format", "{{.Config.Image}} {{.State.Health.Status}}", "autolibrary-website-1", ) if state == f"{image} healthy": return rejected = ROOT / "rejected-release" if rejected.exists() and rejected.read_text().strip() == request: print(f"Skipping previously rejected release {commit}", flush=True) return if main_head() != commit: print(f"Skipping outdated request {commit}", flush=True) return manifest = run(*DOCKER, "buildx", "imagetools", "inspect", f"{REPOSITORY}:{commit}") published = re.search(r"^Digest:\s+(sha256:[0-9a-f]{64})\s*$", manifest, re.MULTILINE) if not published or published.group(1) != digest: raise ValueError("Requested image differs from the published commit") run(*DOCKER, "pull", image) if main_head() != commit or read_request() != request: print(f"Request changed while pulling {commit}", flush=True) return updated = original.replace(f"AUTOLIBRARY_IMAGE={matches[0]}", f"AUTOLIBRARY_IMAGE={image}", 1) write_atomic(environment, updated) try: compose_up() except Exception: write_atomic(environment, original) write_atomic(rejected, request + "\n") compose_up() print(f"Rolled back failed release {commit}", flush=True) raise print(f"Deployed {commit} as {image}", flush=True) if __name__ == "__main__": with (ROOT / ".release.lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) update()