#!/usr/bin/env python3 """Exercise generated managed ingress with a real Caddy container.""" from __future__ import annotations import argparse from pathlib import Path import re import shutil import subprocess import sys import tempfile import time from uuid import uuid4 META_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(META_ROOT / "tools" / "deployment")) from govoplan_deploy.bundle import ( # noqa: E402 render_caddy_config, render_load_balancer_config, ) from govoplan_deploy.model import default_spec # noqa: E402 DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$") def _run(argv: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run( argv, check=check, capture_output=True, text=True, timeout=60, ) def _published_port(container: str, target: int) -> int: output = _run(["docker", "port", container, f"{target}/tcp"]).stdout.strip() try: return int(output.rsplit(":", 1)[1]) except (IndexError, ValueError) as exc: raise RuntimeError(f"cannot determine published port from {output!r}") from exc def _curl(url: str, *, headers: bool = False) -> str: argv = ["curl", "--silent", "--show-error", "--insecure"] if headers: argv.extend(["--head"]) argv.append(url) return _run(argv).stdout def _wait_for_https(port: int) -> str: deadline = time.monotonic() + 30 last_error = "" while time.monotonic() < deadline: try: return _curl(f"https://localhost:{port}/health") except subprocess.CalledProcessError as exc: last_error = exc.stderr.strip() time.sleep(0.5) raise RuntimeError(f"managed ingress did not become ready: {last_error}") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--caddy-image", required=True) parser.add_argument("--load-balancer-image", required=True) args = parser.parse_args() for label, image in ( ("--caddy-image", args.caddy_image), ("--load-balancer-image", args.load_balancer_image), ): if DIGEST_IMAGE.fullmatch(image) is None: parser.error(f"{label} must be pinned by sha256 digest") if shutil.which("docker") is None or shutil.which("curl") is None: parser.error("docker and curl are required") suffix = uuid4().hex[:10] network = f"govoplan-ingress-drill-{suffix}" ingress = f"govoplan-ingress-{suffix}" backend = f"govoplan-ingress-backend-{suffix}" data_volume = f"govoplan-ingress-data-{suffix}" config_volume = f"govoplan-ingress-config-{suffix}" cleanup = [ ["docker", "rm", "--force", ingress, backend], ["docker", "network", "rm", network], ["docker", "volume", "rm", data_volume, config_volume], ] try: _run(["docker", "network", "create", network]) _run(["docker", "volume", "create", data_volume]) _run(["docker", "volume", "create", config_volume]) with tempfile.TemporaryDirectory(prefix="govoplan-ingress-") as directory: root = Path(directory) backend_config = root / "backend.Caddyfile" backend_config.write_text( """{ admin off auto_https off } :8080 { respond /health "proto={http.request.header.X-Forwarded-Proto}" } """, encoding="utf-8", ) backend_config.chmod(0o644) spec = default_spec( profile="self-hosted", public_url="https://localhost:8443", ingress_mode="managed", ingress_image=args.caddy_image, ingress_https_port=8443, acme_email="operator@example.test", ) ingress_config = root / "Caddyfile" ingress_config.write_text(render_caddy_config(spec), encoding="utf-8") ingress_config.chmod(0o644) load_balancer_config = root / "haproxy.cfg" load_balancer_config.write_text( render_load_balancer_config(spec), encoding="utf-8", ) load_balancer_config.chmod(0o644) _run( [ "docker", "run", "--rm", "--read-only", "--cap-drop", "ALL", "--mount", f"type=bind,src={load_balancer_config},dst=/usr/local/etc/haproxy/haproxy.cfg,readonly", args.load_balancer_image, "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg", ] ) _run( [ "docker", "run", "--detach", "--name", backend, "--network", network, "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m", "--mount", f"type=bind,src={backend_config},dst=/etc/caddy/Caddyfile,readonly", args.caddy_image, "caddy", "run", "--config", "/etc/caddy/Caddyfile", ] ) ingress_command = [ "docker", "run", "--detach", "--name", ingress, "--network", network, "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m", "--security-opt", "no-new-privileges", "--cap-drop", "ALL", "--publish", "127.0.0.1::8080", "--publish", "127.0.0.1::8443", "--mount", f"type=bind,src={ingress_config},dst=/etc/caddy/Caddyfile,readonly", "--mount", f"type=volume,src={data_volume},dst=/data", "--mount", f"type=volume,src={config_volume},dst=/config", args.caddy_image, "caddy", "run", "--config", "/etc/caddy/Caddyfile", ] _run(ingress_command) http_port = _published_port(ingress, 8080) https_port = _published_port(ingress, 8443) body = _wait_for_https(https_port) if body.strip() != "proto=https": raise RuntimeError(f"forwarded protocol was not normalized: {body!r}") redirect = _curl(f"http://localhost:{http_port}/health", headers=True) if not redirect.startswith("HTTP/1.1 308"): raise RuntimeError(f"HTTP was not redirected to HTTPS: {redirect!r}") _run(["docker", "rm", "--force", ingress]) _run(ingress_command) https_port = _published_port(ingress, 8443) if _wait_for_https(https_port).strip() != "proto=https": raise RuntimeError( "managed ingress did not recover with persistent state" ) _run( [ "docker", "run", "--rm", "--mount", f"type=volume,src={data_volume},dst=/data,readonly", "--entrypoint", "sh", args.caddy_image, "-c", 'test -n "$(find /data -type f -print -quit)"', ] ) finally: for command in cleanup: _run(command, check=False) print("Managed ingress container drill passed.") return 0 if __name__ == "__main__": raise SystemExit(main())