Files
codex-bot d57e9b77d7
Verify database-backed documents / verify (pull_request) Failing after 6m6s
feat: make the site database authoritative for documents and catalog
2026-10-30 16:23:54 +08:00

159 lines
7.5 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 time
import urllib.request
from ci import APP, ROOT, fingerprint, run
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 report_ingress(port: int) -> None:
print(f"Host: {socket.gethostname()}", flush=True)
if shutil.which("hostname"):
run("hostname", "-I")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
for field in ("eipv4", "public-ipv4"):
try:
with opener.open(f"http://100.100.100.200/latest/meta-data/{field}", timeout=3) as response:
address = response.read(100).decode().strip()
if re.fullmatch(r"[0-9.]+", address):
print(f"Instance {field}: {address}", flush=True)
except Exception:
pass
if shutil.which("ufw"):
state = run("ufw", "status", capture=True)
print("UFW status:", state, flush=True)
else:
print("UFW is not installed", flush=True)
if shutil.which("iptables"):
rules = run("iptables", "-S", "INPUT", capture=True)
print("Host INPUT policy:", rules, flush=True)
print(f"Aliyun CLI available: {shutil.which('aliyun') is not None}", flush=True)
print(f"Expected public TCP port: {port}", 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)
auth_dir = DEPLOY_ROOT / "private-auth"
auth_dir.mkdir(mode=0o700, exist_ok=True)
auth_dir.chmod(0o700)
os.chown(auth_dir, 1000, 1000)
# Initialize once; normal updates must preserve the authenticator key.
run("docker", "run", "--rm", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/auth-cli.cjs", "init")
# Back up the entire shared database, then bootstrap documents only when empty.
# A normal release never reimports, overwrites or resets authored DB content.
migration = ROOT / ".artifacts/documents-migration.json"
if not migration.is_file():
raise RuntimeError("Verified document migration export is missing")
backup = f"/auth/before-documents-{sha}-{time.time_ns()}.sqlite"
run("docker", "run", "--rm", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/docs-cli.cjs", "backup", backup)
subprocess.run(["docker", "run", "--rm", "-i", "--network", "none", "--mount",
f"type=bind,src={auth_dir},dst=/auth", actual,
"node", "apps/docs/docs-cli.cjs", "import", "--if-empty"],
input=migration.read_bytes(), check=True)
shutil.copyfile(ROOT / "scripts/docs-admin.sh", DEPLOY_ROOT / "docs")
(DEPLOY_ROOT / "docs").chmod(0o750)
shutil.copyfile(ROOT / "scripts/auth-admin.sh", DEPLOY_ROOT / "auth")
(DEPLOY_ROOT / "auth").chmod(0o750)
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")
container = run("docker", "ps", "-q", "--filter", f"label=com.docker.compose.project={APP}",
"--filter", "label=com.docker.compose.service=docs", capture=True)
run("docker", "exec", container, "node", "apps/docs/auth-cli.cjs", "verify")
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")
report_ingress(port)
print(f"Deployed release={sha} image={actual} port={port}", flush=True)
if __name__ == "__main__":
main()