Deploy Tabularium on ces-control with verified image reuse and persistent caches
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run

This commit is contained in:
2026-10-26 20:06:30 +08:00
parent 5ceb4e57aa
commit 1eabfcfb21
18 changed files with 421 additions and 11 deletions
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
pnpm install --frozen-lockfile --store-dir /pnpm/store
uv sync --project tools/figures --locked
pnpm run lint
pnpm run typecheck
cp -R apps/docs/public/figures /tmp/expected-figures
pnpm run figures
# Generated assets must be committed alongside their scripts.
diff -r /tmp/expected-figures apps/docs/public/figures
pnpm run build
+105
View File
@@ -0,0 +1,105 @@
"""Build on ces-control; reuse only verified images with identical build inputs."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
APP = "endorphin-tabularium-docs"
INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures", "scripts"]
def run(*args: str, capture: bool = False) -> str:
result = subprocess.run(args, cwd=ROOT, text=True, check=True,
stdout=subprocess.PIPE if capture else None)
return result.stdout.strip() if capture else ""
def fingerprint() -> str:
files = run("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", *INPUTS, capture=True)
digest = hashlib.sha256()
for name in sorted(set(files.split("\0"))):
if not name:
continue
data = (ROOT / name).read_bytes()
digest.update(name.encode() + b"\0" + str(len(data)).encode() + b"\0" + data)
return digest.hexdigest()
def smoke_image(image: str, name: str) -> None:
from smoke import verify
run("docker", "run", "-d", "--name", name, "-p", "127.0.0.1::3000", image)
try:
port = run("docker", "port", name, "3000/tcp", capture=True).rsplit(":", 1)[1]
for _ in range(60):
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
if state.get("Health", {}).get("Status") == "healthy":
verify(f"http://127.0.0.1:{port}")
return
if not state["Running"]:
break
time.sleep(1)
run("docker", "logs", "--tail", "80", name)
raise RuntimeError("Production image did not become healthy")
finally:
run("docker", "rm", "-f", name)
def main() -> None:
os.chdir(ROOT)
run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py")
scope = os.environ.get("CI_CACHE_SCOPE", "main")
if not re.fullmatch(r"main|pr-[0-9]+", scope):
raise ValueError("Unsupported cache scope")
with open(f"/var/lock/{APP}-{scope}-build.lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
key = fingerprint()
verified = f"{APP}:verified-{scope}-{key}"
result = subprocess.run(["docker", "image", "inspect", verified],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
smoke_name = f"tabularium-smoke-{scope}-{os.getpid()}"
if result.returncode == 0:
print(f"CACHE HIT: reuse {verified}; no dependency install or next build", flush=True)
smoke_image(verified, smoke_name)
else:
print(f"CACHE MISS: {key}; dependency and Next.js caches remain persistent", flush=True)
checks = f"{APP}-checks:{scope}"
run("docker", "build", "-f", "Dockerfile.ci", "--target", "checks", "-t", checks, ".")
name = f"tabularium-checks-{scope}-{os.getpid()}"
output = ROOT / ".artifacts/runtime"
if output.exists():
shutil.rmtree(output)
output.mkdir(parents=True)
try:
run("docker", "run", "--name", name,
"--mount", f"type=volume,src={APP}-{scope}-pnpm,dst=/pnpm/store",
"--mount", f"type=volume,src={APP}-{scope}-uv,dst=/root/.cache/uv",
"--mount", f"type=volume,src={APP}-{scope}-next,dst=/workspace/apps/docs/.next/cache",
checks)
run("docker", "cp", f"{name}:/workspace/apps/docs/.next/standalone/.", str(output))
app = output / "apps/docs"
(app / ".next").mkdir(parents=True, exist_ok=True)
run("docker", "cp", f"{name}:/workspace/apps/docs/.next/static", str(app / ".next/static"))
run("docker", "cp", f"{name}:/workspace/apps/docs/public", str(app / "public"))
candidate = f"{APP}:candidate-{scope}"
run("docker", "build", "-f", str(ROOT / "Dockerfile"),
"--label", f"io.entroia.tabularium.fingerprint={key}",
"-t", candidate, str(output))
smoke_image(candidate, smoke_name)
run("docker", "tag", candidate, verified)
finally:
subprocess.run(["docker", "rm", "-f", name], check=False)
(ROOT / ".artifacts").mkdir(exist_ok=True)
(ROOT / ".artifacts/verified-image").write_text(verified + "\n")
print(f"VERIFIED IMAGE: {verified}", flush=True)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""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 subprocess
import urllib.request
from ci import APP, ROOT, fingerprint, run
from smoke import verify
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 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=18132\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", "18132"))
if not 1024 <= port <= 65535:
raise ValueError("Invalid port")
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")
verify(f"http://127.0.0.1:{port}")
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")
print(f"Deployed release={sha} image={actual} port={port}", flush=True)
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
"""Exercise actual production routes and assets without third-party dependencies."""
from html import unescape
import json
from pathlib import Path
import re
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
def verify(base: str) -> None:
def get(path: str) -> tuple[bytes, str]:
with urllib.request.urlopen(base.rstrip("/") + path, timeout=15) as response:
if response.status != 200:
raise RuntimeError(f"Unexpected status for {path}: {response.status}")
return response.read(), response.headers.get("Content-Type", "")
health, _ = get("/api/health")
assert json.loads(health) == {"status": "ok"}
home, _ = get("/")
assert b"TABULARIUM" in home
for file in sorted((ROOT / "content/docs").glob("*.md")):
page, _ = get(f"/docs/{file.stem}")
title = re.search(r"^# (.+)$", file.read_text(), re.M)
assert title is not None
text = unescape(re.sub(r"<[^>]+>", "", page.decode()))
assert title.group(1) in text, file.name
assets = set(re.findall(r'(?:src|href)="(/_next/static/[^" ]+)"', home.decode()))
assert assets, "No generated JavaScript/CSS assets found"
for asset in assets:
get(unescape(asset))
for file in sorted((ROOT / "apps/docs/public/figures").glob("*.svg")):
data, kind = get(f"/figures/{file.name}")
assert "image/svg+xml" in kind
assert ET.fromstring(data).tag.endswith("svg")
try:
get("/docs/this-document-does-not-exist")
except urllib.error.HTTPError as error:
assert error.code == 404
else:
raise RuntimeError("Unknown document did not return 404")
print(f"Smoke passed: {base} (health, documents, JS/CSS, SVG, 404)", flush=True)
if __name__ == "__main__":
verify(sys.argv[1])
+39
View File
@@ -0,0 +1,39 @@
"""Guard cache invalidation against stale documents and needless metadata rebuilds."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import ci
class FingerprintTests(unittest.TestCase):
def test_build_inputs_invalidate_but_metadata_and_caches_do_not(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
(root / ".gitignore").write_text(".artifacts/\n**/.next/\n")
doc = root / "content/docs/concept.md"
doc.parent.mkdir(parents=True)
doc.write_text("# Original\n")
package = root / "package.json"
package.write_text('{"private":true}\n')
with patch.object(ci, "ROOT", root):
original = ci.fingerprint()
(root / "README.md").write_text("metadata only\n")
cache = root / "apps/docs/.next/cache/state"
cache.parent.mkdir(parents=True)
cache.write_text("compiler cache\n")
self.assertEqual(original, ci.fingerprint())
doc.write_text("# Updated\n")
self.assertNotEqual(original, ci.fingerprint())
doc.write_text("# Original\n")
package.write_text('{"private":true,"version":"2"}\n')
self.assertNotEqual(original, ci.fingerprint())
package.write_text('{"private":true}\n')
doc.rename(doc.with_name("renamed.md"))
self.assertNotEqual(original, ci.fingerprint())
if __name__ == "__main__":
unittest.main()