#!/usr/bin/env python3 """Build and smoke-test the ProofFrame Docker image without reading secrets.""" from __future__ import annotations import argparse import json import os import subprocess import sys import time from http.client import RemoteDisconnected from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen ROOT = Path(__file__).resolve().parents[1] SCHEMA = "proofframe.docker_smoke.v1" DEFAULT_JSON = ROOT / "docs" / "assets" / "docker-smoke-report.json" DEFAULT_MD = ROOT / "docs" / "assets" / "docker-smoke-report.md" DEFAULT_IMAGE = "proofframe:submission-smoke" DEFAULT_CONTAINER = "proofframe-submission-smoke" DEFAULT_PORT = 18088 OUTPUT_TAIL_CHARS = 3000 CommandRunner = Callable[[list[str], Path, int, dict[str, str] | None], dict[str, Any]] HealthFetcher = Callable[[str, int], dict[str, Any]] SleepFn = Callable[[float], None] def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def tail_text(value: str | None) -> str: text = value or "" return text[-OUTPUT_TAIL_CHARS:] def run_command(args: list[str], cwd: Path, timeout: int, env: dict[str, str] | None = None) -> dict[str, Any]: merged_env = os.environ.copy() if env: merged_env.update(env) try: completed = subprocess.run( args, cwd=cwd, env=merged_env, capture_output=True, text=True, timeout=timeout, check=False, ) return { "ok": completed.returncode == 0, "returncode": completed.returncode, "stdout_tail": tail_text(completed.stdout), "stderr_tail": tail_text(completed.stderr), } except FileNotFoundError as exc: return {"ok": False, "returncode": None, "stdout_tail": "", "stderr_tail": str(exc)} except subprocess.TimeoutExpired as exc: return { "ok": False, "returncode": None, "stdout_tail": tail_text(exc.stdout if isinstance(exc.stdout, str) else None), "stderr_tail": f"Timed out after {timeout}s. {tail_text(exc.stderr if isinstance(exc.stderr, str) else None)}", } def fetch_health(url: str, timeout: int) -> dict[str, Any]: request = Request(url, headers={"User-Agent": "ProofFrame Docker smoke"}) try: with urlopen(request, timeout=timeout) as response: body = response.read().decode("utf-8", errors="replace") parsed = json.loads(body) return { "ok": True, "status": response.status, "json": parsed if isinstance(parsed, dict) else {}, "error": None, } except ( HTTPError, URLError, TimeoutError, ConnectionResetError, RemoteDisconnected, json.JSONDecodeError, ) as exc: return {"ok": False, "status": getattr(exc, "code", None), "json": {}, "error": str(exc)} def dockerignore_status(root: Path) -> dict[str, Any]: path = root / ".dockerignore" text = path.read_text(encoding="utf-8") if path.exists() else "" required_patterns = { ".env", ".env.*", ".env.final.local", ".venv/", "var/", "output/", ".git/", } allow_patterns = {"!.env.example", "!.env.final.example"} lines = {line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")} return { "path": ".dockerignore", "present": path.exists(), "required_patterns_present": required_patterns <= lines, "missing_required_patterns": sorted(required_patterns - lines), "example_env_allowed": allow_patterns <= lines, "missing_allow_patterns": sorted(allow_patterns - lines), } def check_item(check_id: str, label: str, ok: bool, detail: str) -> dict[str, Any]: return {"id": check_id, "label": label, "ok": ok, "detail": detail} def command_record(command_id: str, args: list[str], result: dict[str, Any]) -> dict[str, Any]: return { "id": command_id, "command": args, "ok": result.get("ok") is True, "returncode": result.get("returncode"), "stdout_tail": result.get("stdout_tail", ""), "stderr_tail": result.get("stderr_tail", ""), } def build_report( *, root: Path = ROOT, image: str = DEFAULT_IMAGE, container: str = DEFAULT_CONTAINER, port: int = DEFAULT_PORT, timeout_seconds: int = 90, command_runner: CommandRunner = run_command, health_fetcher: HealthFetcher = fetch_health, sleep_fn: SleepFn = time.sleep, ) -> dict[str, Any]: root = root.resolve() base_url = f"http://127.0.0.1:{port}" health_url = f"{base_url}/api/health" commands: list[dict[str, Any]] = [] dockerignore = dockerignore_status(root) docker_info_args = ["docker", "info", "--format", "{{json .ServerVersion}}"] docker_info = command_runner(docker_info_args, root, 30, None) commands.append(command_record("docker_info", docker_info_args, docker_info)) build_args = ["docker", "build", "-t", image, "."] build = command_runner(build_args, root, 600, None) if docker_info.get("ok") else {"ok": False} commands.append(command_record("docker_build", build_args, build)) remove_args = ["docker", "rm", "-f", container] command_runner(remove_args, root, 30, None) run_args = ["docker", "run", "-d", "--name", container, "-p", f"{port}:8088", image] run = command_runner(run_args, root, 60, None) if build.get("ok") else {"ok": False} commands.append(command_record("docker_run", run_args, run)) health: dict[str, Any] = {"ok": False, "json": {}, "error": "container did not start"} api_smoke: dict[str, Any] = {"ok": False} logs: dict[str, Any] = {"ok": False} cleanup: dict[str, Any] = {"ok": True, "returncode": 0} if run.get("ok"): try: deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: health = health_fetcher(health_url, 5) if health.get("ok"): break sleep_fn(1) api_args = [sys.executable, "scripts/api_smoke.py", "--base-url", base_url] api_env = {"PYTHONPATH": "src"} api_smoke = command_runner(api_args, root, 90, api_env) if health.get("ok") else {"ok": False} logs_args = ["docker", "logs", "--tail", "80", container] logs = command_runner(logs_args, root, 30, None) except Exception as exc: # pragma: no cover - exercised through injected fakes. error = f"{type(exc).__name__}: {exc}" if not health.get("ok"): health = {"ok": False, "json": {}, "error": error} if not api_smoke.get("ok"): api_smoke = {"ok": False, "returncode": None, "stdout_tail": "", "stderr_tail": error} logs = {"ok": False, "returncode": None, "stdout_tail": "", "stderr_tail": error} finally: cleanup = command_runner(remove_args, root, 30, None) api_args = [sys.executable, "scripts/api_smoke.py", "--base-url", base_url] commands.append(command_record("api_smoke", api_args, api_smoke)) logs_args = ["docker", "logs", "--tail", "80", container] commands.append(command_record("docker_logs", logs_args, logs)) commands.append(command_record("docker_cleanup", remove_args, cleanup)) checks = [ check_item( "dockerignore_secret_exclusions", ".dockerignore excludes local secrets and heavy runtime paths", bool( dockerignore["present"] and dockerignore["missing_required_patterns"] == [] and dockerignore["missing_allow_patterns"] == [] ), ( f"missing_required={dockerignore['missing_required_patterns']}; " f"missing_allow={dockerignore['missing_allow_patterns']}." ), ), check_item("docker_daemon", "Docker daemon is reachable", docker_info.get("ok") is True, "docker info completed."), check_item("docker_build", "Docker image builds", build.get("ok") is True, "docker build completed."), check_item("docker_run", "Docker container starts", run.get("ok") is True, "docker run returned a container id."), check_item( "docker_health", "Dockerized app reports local/mock health", bool( health.get("ok") and health.get("json", {}).get("ready") is True and health.get("json", {}).get("storage_backend") == "local" and health.get("json", {}).get("generation_backend") == "mock" and health.get("json", {}).get("b2_configured") is False and health.get("json", {}).get("genblaze_configured") is False ), f"health={health.get('json')}; error={health.get('error')}.", ), check_item("api_smoke", "API smoke passes against the Docker container", api_smoke.get("ok") is True, "api_smoke.py completed."), check_item("docker_cleanup", "Smoke container is removed", cleanup.get("ok") is True, "docker rm -f completed."), ] ok = all(check["ok"] for check in checks) return { "schema": SCHEMA, "created_at": utc_now(), "ok": ok, "mode": "docker_smoke_ready" if ok else "docker_smoke_blocked", "safe_to_share": True, "image": image, "container": container, "base_url": base_url, "dockerignore": dockerignore, "health": health, "checks": checks, "commands": commands, "secret_policy": ( "This smoke test uses local/mock mode, does not read .env.final.local, and requires .dockerignore " "to exclude local env files from the Docker build context." ), } def render_markdown(report: dict[str, Any]) -> str: lines = [ "# ProofFrame Docker Smoke Report", "", f"Mode: `{report['mode']}`", f"OK: `{str(report['ok']).lower()}`", f"Image: `{report['image']}`", f"Base URL: `{report['base_url']}`", "", "## Checks", "", ] for check in report["checks"]: marker = "OK" if check["ok"] else "FAIL" lines.append(f"- {marker} `{check['id']}`: {check['detail']}") lines.extend(["", "## Docker Context Policy", "", report["secret_policy"], "", "## Commands", ""]) for command in report["commands"]: marker = "OK" if command["ok"] else "FAIL" lines.append(f"- {marker} `{command['id']}`: `{' '.join(command['command'])}`") return "\n".join(lines) + "\n" def write_outputs(report: dict[str, Any], json_path: Path, markdown_path: Path) -> None: json_path.parent.mkdir(parents=True, exist_ok=True) markdown_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") markdown_path.write_text(render_markdown(report), encoding="utf-8") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Build and smoke-test the ProofFrame Docker image.") parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--image", default=DEFAULT_IMAGE) parser.add_argument("--container", default=DEFAULT_CONTAINER) parser.add_argument("--port", type=int, default=DEFAULT_PORT) parser.add_argument("--timeout-seconds", type=int, default=90) parser.add_argument("--json-out", type=Path, default=DEFAULT_JSON) parser.add_argument("--markdown-out", type=Path, default=DEFAULT_MD) return parser def main() -> None: args = build_parser().parse_args() report = build_report( root=args.root, image=args.image, container=args.container, port=args.port, timeout_seconds=args.timeout_seconds, ) write_outputs(report, args.json_out, args.markdown_out) print( json.dumps( { "ok": report["ok"], "mode": report["mode"], "json": str(args.json_out), "markdown": str(args.markdown_out), "image": report["image"], "failed_checks": [ {"id": check["id"], "detail": check["detail"]} for check in report["checks"] if not check["ok"] ], "failed_commands": [ { "id": command["id"], "returncode": command["returncode"], "stdout_tail": command["stdout_tail"], "stderr_tail": command["stderr_tail"], } for command in report["commands"] if not command["ok"] ], }, indent=2, ) ) raise SystemExit(0 if report["ok"] else 2) if __name__ == "__main__": main()