"""Verify production image in an isolated Compose project on any Docker daemon.""" import argparse import os import secrets import signal import subprocess import tempfile import time from pathlib import Path from uuid import uuid4 import yaml ROOT = Path(__file__).resolve().parents[1] DOCKER = os.getenv("DOCKER_BIN", "docker") def isolated_compose(project): config = yaml.safe_load((ROOT / "docker-compose.yml").read_text()) config["name"] = project service = config["services"]["pigway-frame-flow"] for key in ("container_name", "ports", "env_file"): service.pop(key, None) service["image"] = f"pigway-frame-flow-ci:{project}" service["build"]["context"] = str(ROOT) service["environment"].update( KOKONNA_MOCK="true", KOKONNA_WRITE_ENABLED="false", KOKONNA_API_KEY="", FRAME_FLOW_TOKEN=secrets.token_urlsafe(32), ) service["volumes"] = ["data:/data", "config:/config"] config["volumes"] = {"data": {}, "config": {}} return config def compose(path, *args, **kwargs): project = yaml.safe_load(path.read_text())["name"] return subprocess.run( [DOCKER, "compose", "-p", project, "-f", str(path), *args], check=True, **kwargs ) def cleanup(path): if not path or not path.exists(): return config = yaml.safe_load(path.read_text()) compose(path, "down", "--volumes", "--remove-orphans", "--timeout", "10") image = config["services"]["pigway-frame-flow"]["image"] if ( subprocess.run( [DOCKER, "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode == 0 ): subprocess.run([DOCKER, "image", "rm", image], check=True) path.unlink() def ready(path): project = yaml.safe_load(path.read_text())["name"] for _ in range(30): result = subprocess.run( [ DOCKER, "compose", "-p", project, "-f", str(path), "exec", "-T", "pigway-frame-flow", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/ready', timeout=3)", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if result.returncode == 0: return time.sleep(2) raise RuntimeError("CI container /ready did not return HTTP 200") def verify(): project = "ffci-" + uuid4().hex with tempfile.NamedTemporaryFile( prefix=project + "-", suffix=".yml", mode="w", delete=False ) as file: path = Path(file.name) config = isolated_compose(project) yaml.safe_dump(config, file) env = os.environ | { "COMPOSE_FILE": str(path), "COMPOSE_PROJECT_NAME": project, "FRAME_FLOW_IMAGE": config["services"]["pigway-frame-flow"]["image"], } # The always() workflow step can clean up if this process is interrupted. if os.getenv("GITHUB_ENV"): with Path(os.environ["GITHUB_ENV"]).open("a") as file: file.write(f"FRAME_FLOW_CI_COMPOSE={path}\n") try: subprocess.run([str(ROOT / "scripts/build-verify.sh")], env=env, check=True) compose(path, "up", "-d") for restart in (False, True): if restart: compose(path, "restart", "pigway-frame-flow") ready(path) compose( path, "exec", "-T", "pigway-frame-flow", "python", "-", input=(ROOT / "scripts/smoke-test.py").read_bytes(), ) container = compose( path, "ps", "-q", "pigway-frame-flow", capture_output=True, text=True ).stdout.strip() subprocess.run( [ DOCKER, "inspect", container, "--format", "{{.HostConfig.RestartPolicy.Name}} {{.State.Health.Status}}", ], check=True, ) finally: cleanup(path) def main(): parser = argparse.ArgumentParser() parser.add_argument("--cleanup", action="store_true") args = parser.parse_args() if args.cleanup: path = os.getenv("FRAME_FLOW_CI_COMPOSE") cleanup(Path(path) if path else None) else: # Normal cancellation runs finally; SIGKILL is handled by always() below. def cancelled(*_): raise SystemExit(143) signal.signal(signal.SIGTERM, cancelled) verify() if __name__ == "__main__": main()