#!/usr/bin/env python3 """CI wrapper for the production Dashboard ASGI HTTP smoke gate. Creates a synthetic fixture DB, starts the authenticated Streamlit ASGI wrapper, waits for HTTP 200, and always attempts process cleanup. Output is intentionally status-only. """ from __future__ import annotations import argparse import json import os import signal import subprocess import sys import tempfile import time import urllib.parse import urllib.request from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) from scripts.ci_fixture_db import create_ci_fixture_db def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run the Dashboard ASGI HTTP smoke against a synthetic CI fixture DB.") parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind") parser.add_argument("--port", type=int, default=18501, help="Deterministic smoke port") parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds") parser.add_argument("--page", default="요약", help="Dashboard page query param to request") return parser.parse_args() def wait_for_http(url: str, timeout_seconds: int) -> dict[str, Any]: deadline = time.monotonic() + timeout_seconds attempts = 0 last_error = "not_started" while time.monotonic() < deadline: attempts += 1 try: with urllib.request.urlopen(url, timeout=2) as response: if response.status == 200: return {"ready": True, "status": response.status, "attempts": attempts} last_error = f"HTTP {response.status}" except Exception as exc: # noqa: BLE001 - compact readiness diagnostic last_error = exc.__class__.__name__ time.sleep(1) return {"ready": False, "attempts": attempts, "last_error": last_error} def terminate_process(proc: subprocess.Popen[str], timeout: int = 10) -> dict[str, Any]: if proc.poll() is not None: return {"cleanup": "already_exited", "returncode": proc.returncode} try: proc.send_signal(signal.SIGTERM) proc.wait(timeout=timeout) return {"cleanup": "terminated", "returncode": proc.returncode} except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=timeout) return {"cleanup": "killed", "returncode": proc.returncode} def main() -> int: args = parse_args() smoke_url = f"http://{args.host}:{args.port}/?page={urllib.parse.quote(args.page)}" proc: subprocess.Popen[str] | None = None with tempfile.TemporaryDirectory(prefix="jarca-ledger-ci-http-") as tmp: tmp_path = Path(tmp) fixture_path = tmp_path / "fixture.sqlite3" try: fixture = create_ci_fixture_db(fixture_path) env = os.environ.copy() env["JARCA_LEDGER_DB_PATH"] = str(fixture_path) env["JARCA_DASHBOARD_ADMIN_ID"] = "a" env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = "p" env["JARCA_DASHBOARD_SIGNING_SECRET"] = "s" * 32 env["FORWARDED_ALLOW_IPS"] = "127.0.0.1" env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" cmd = [ sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--proxy-headers", ] proc = subprocess.Popen( cmd, cwd=Path(__file__).resolve().parents[1], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) readiness = wait_for_http(smoke_url, args.timeout) if not readiness["ready"]: cleanup = terminate_process(proc) print(json.dumps({"success": False, "gate": "dashboard-http-smoke", "fixture": "synthetic", "readiness": readiness, "cleanup": cleanup}, ensure_ascii=False)) return 10 cleanup = terminate_process(proc) success = cleanup.get("cleanup") in {"terminated", "already_exited", "killed"} print(json.dumps({ "success": success, "gate": "dashboard-http-smoke", "fixture": "synthetic", "fixture_integrity": fixture.get("integrity_check"), "http_status": readiness.get("status"), "attempts": readiness.get("attempts"), "cleanup": cleanup.get("cleanup"), }, ensure_ascii=False, indent=2)) return 0 if success else 11 except Exception as exc: # noqa: BLE001 - deterministic wrapper failure cleanup = terminate_process(proc) if proc is not None else {"cleanup": "not_started"} print(json.dumps({"success": False, "gate": "dashboard-http-smoke", "error_code": "wrapper_error", "error": exc.__class__.__name__, "cleanup": cleanup.get("cleanup")}, ensure_ascii=False)) return 1 if __name__ == "__main__": raise SystemExit(main())