Apply published main images to the production website
Build and publish website container / build (push) Successful in 56s
Build and publish website container / build (push) Successful in 56s
This commit is contained in:
1 parent
4861439581
commit
8c5f639347
6 files changed
+164
-2
No files matched your search
@@ -35,6 +35,7 @@ jobs:
|
|||||||
bash deploy/smoke-container.sh "$image"
|
bash deploy/smoke-container.sh "$image"
|
||||||
|
|
||||||
- name: Push the tested image to Gitea
|
- name: Push the tested image to Gitea
|
||||||
|
id: publish
|
||||||
if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/container-ci' }}
|
if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/container-ci' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -53,6 +54,20 @@ jobs:
|
|||||||
digest=$(awk '$2 == "digest:" {print $3}' "$DOCKER_CONFIG/push.log")
|
digest=$(awk '$2 == "digest:" {print $3}' "$DOCKER_CONFIG/push.log")
|
||||||
[[ $digest =~ ^sha256:[0-9a-f]{64}$ ]]
|
[[ $digest =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||||
echo "Published ${IMAGE_REPOSITORY}@${digest}"
|
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
|
- name: Remove the local image tag
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ docker build -t autolibrary-web:local .
|
|||||||
bash deploy/smoke-container.sh 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.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` 查看。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Retry pending AutoLibrary website releases
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=45s
|
||||||
|
OnUnitInactiveSec=60s
|
||||||
|
Unit=autolibrary-update.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user