#!/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())