Deploy Tabularium on ces-control with verified image reuse and persistent caches
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
This commit is contained in:
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user