fix(avatar): prevent stale deployment images
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail a deployment unless frontend and backend run the expected release."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
with urllib.request.urlopen(url, timeout=20) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"{url} returned HTTP {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("base_url", help="Public site URL, for example https://digital.99hui.com")
|
||||
parser.add_argument("expected_sha", help="Full Git commit SHA being deployed")
|
||||
parser.add_argument("--backend-container", help="Backend container name for image and mount checks")
|
||||
parser.add_argument("--frontend-container", help="Frontend container name for image checks")
|
||||
parser.add_argument("--expected-db-source", help="Required host source mounted as the SQLite database")
|
||||
parser.add_argument("--expected-upload-source", help="Required host source mounted as the upload directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = args.base_url.rstrip("/")
|
||||
errors = []
|
||||
try:
|
||||
health = fetch_json(f"{base_url}/api/health").get("data") or {}
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read backend release metadata: {exc}")
|
||||
health = {}
|
||||
try:
|
||||
frontend = fetch_json(f"{base_url}/version.json")
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot read frontend release metadata: {exc}")
|
||||
frontend = {}
|
||||
|
||||
if health.get("status") != "ok":
|
||||
errors.append(f"backend status is {health.get('status')!r}")
|
||||
failed_checks = [name for name, passed in (health.get("checks") or {}).items() if not passed]
|
||||
if failed_checks:
|
||||
errors.append("backend checks failed: " + ", ".join(failed_checks))
|
||||
if health.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"backend SHA is {health.get('gitSha')!r}")
|
||||
if frontend.get("gitSha") != args.expected_sha:
|
||||
errors.append(f"frontend SHA is {frontend.get('gitSha')!r}")
|
||||
|
||||
if args.backend_container:
|
||||
backend = inspect_container(args.backend_container, errors)
|
||||
check_container_revision(backend, args.expected_sha, "backend", errors)
|
||||
check_mount(backend, args.expected_db_source, "database", errors)
|
||||
check_mount(backend, args.expected_upload_source, "uploads", errors)
|
||||
elif args.expected_db_source or args.expected_upload_source:
|
||||
errors.append("--backend-container is required when checking data mounts")
|
||||
|
||||
if args.frontend_container:
|
||||
frontend_container = inspect_container(args.frontend_container, errors)
|
||||
check_container_revision(frontend_container, args.expected_sha, "frontend", errors)
|
||||
|
||||
if errors:
|
||||
print("Deployment verification failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Deployment verified: {args.expected_sha}")
|
||||
print("Backend checks: database, uploads, pdfOcr")
|
||||
return 0
|
||||
|
||||
|
||||
def inspect_container(name, errors):
|
||||
try:
|
||||
output = subprocess.check_output(["docker", "inspect", name], text=True)
|
||||
return json.loads(output)[0]
|
||||
except Exception as exc:
|
||||
errors.append(f"cannot inspect container {name!r}: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
def check_container_revision(container, expected_sha, label, errors):
|
||||
actual = ((container.get("Config") or {}).get("Labels") or {}).get(
|
||||
"org.opencontainers.image.revision"
|
||||
)
|
||||
if actual != expected_sha:
|
||||
errors.append(f"{label} container image SHA is {actual!r}")
|
||||
|
||||
|
||||
def check_mount(container, expected_source, label, errors):
|
||||
if not expected_source:
|
||||
return
|
||||
expected = os.path.realpath(expected_source)
|
||||
sources = {
|
||||
os.path.realpath(mount.get("Source", ""))
|
||||
for mount in container.get("Mounts") or []
|
||||
}
|
||||
if expected not in sources:
|
||||
errors.append(f"{label} mount source {expected!r} is not attached")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user