diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml index 33e65fb..75f0448 100644 --- a/.gitea/workflows/container.yml +++ b/.gitea/workflows/container.yml @@ -35,6 +35,7 @@ jobs: bash deploy/smoke-container.sh "$image" - name: Push the tested image to Gitea + id: publish if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/container-ci' }} shell: bash env: @@ -53,6 +54,20 @@ jobs: digest=$(awk '$2 == "digest:" {print $3}' "$DOCKER_CONFIG/push.log") [[ $digest =~ ^sha256:[0-9a-f]{64}$ ]] echo "Published ${IMAGE_REPOSITORY}@${digest}" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Request production update + if: ${{ gitea.ref == 'refs/heads/main' }} + shell: bash + env: + RELEASE_DIGEST: ${{ steps.publish.outputs.digest }} + run: | + set -euo pipefail + [[ $RELEASE_SHA =~ ^[0-9a-f]{40}$ && $RELEASE_DIGEST =~ ^sha256:[0-9a-f]{64}$ ]] + request=$(mktemp /release-request/request.XXXXXXXX) + trap 'rm -f -- "$request"' EXIT + printf '%s %s\n' "$RELEASE_SHA" "$RELEASE_DIGEST" > "$request" + mv -f -- "$request" /release-request/request - name: Remove the local image tag if: always() diff --git a/README.md b/README.md index b179a51..d4888e9 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ docker build -t autolibrary-web:local . bash deploy/smoke-container.sh autolibrary-web:local ``` -推送 `main` 后,Gitea Actions 构建、测试并发布镜像到 `gitea.kenanzhu.com/autolibrary/website:<提交SHA>`。仓库 Secrets 需配置 `REGISTRY_USERNAME` 和具有软件包写入权限的 `REGISTRY_TOKEN`。 +推送 `main` 后,Gitea Actions 构建、测试并发布镜像,再提交生产更新请求。宿主机更新服务校验当前 `main` 与镜像摘要后更新网站容器,健康检查失败时回滚。仓库 Secrets 需配置 `REGISTRY_USERNAME` 和具有软件包写入权限的 `REGISTRY_TOKEN`。 使用 `compose.yaml` 部署,设置 `AUTOLIBRARY_IMAGE`、`AUTOLIBRARY_CONFIG`、`AUTOLIBRARY_DATA_DIR` 和 `AUTOLIBRARY_LOG_DIR`,并按宿主机权限设置 `AUTOLIBRARY_UID`、`AUTOLIBRARY_GID`。前端健康检查端口由 `AUTOLIBRARY_HTTP_PORT` 指定,默认 `3003`。 -配置只读挂载,数据库、主题文件和日志持久化在宿主机。上线前备份数据,更新 Compose 使用的镜像摘要后运行 `docker compose up -d --wait`。 +配置只读挂载,数据库、主题文件和日志持久化在宿主机。生产安装 `deploy/systemd/autolibrary-update.*` 与 `deploy/update-container.py`;Runner 将专用请求目录挂载到 `/release-request`。更新日志使用 `journalctl -u autolibrary-update.service` 查看。 diff --git a/deploy/systemd/autolibrary-update.path b/deploy/systemd/autolibrary-update.path new file mode 100644 index 0000000..2f1c372 --- /dev/null +++ b/deploy/systemd/autolibrary-update.path @@ -0,0 +1,9 @@ +[Unit] +Description=Watch AutoLibrary website release requests + +[Path] +PathChanged=/var/lib/autolibrary-release-requests/request +Unit=autolibrary-update.service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/autolibrary-update.service b/deploy/systemd/autolibrary-update.service new file mode 100644 index 0000000..840932e --- /dev/null +++ b/deploy/systemd/autolibrary-update.service @@ -0,0 +1,11 @@ +[Unit] +Description=Apply an AutoLibrary website image release +After=docker.service network-online.target +Wants=network-online.target +Requires=docker.service + +[Service] +Type=oneshot +Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/usr/bin/python3 /opt/autolibrary-website/update-container.py +TimeoutStartSec=600 diff --git a/deploy/systemd/autolibrary-update.timer b/deploy/systemd/autolibrary-update.timer new file mode 100644 index 0000000..f521b9b --- /dev/null +++ b/deploy/systemd/autolibrary-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Retry pending AutoLibrary website releases + +[Timer] +OnBootSec=45s +OnUnitInactiveSec=60s +Unit=autolibrary-update.service + +[Install] +WantedBy=timers.target diff --git a/deploy/update-container.py b/deploy/update-container.py new file mode 100644 index 0000000..9300f52 --- /dev/null +++ b/deploy/update-container.py @@ -0,0 +1,117 @@ +#!/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()