Files
Website/deploy/update-container.py
T
KenanZhu 8c5f639347
Build and publish website container / build (push) Successful in 56s
Apply published main images to the production website
2026-11-05 13:05:07 +08:00

118 lines
4.2 KiB
Python

#!/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()