#!/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, input_text: str | None = None, ) -> subprocess.CompletedProcess[str]: try: return subprocess.run( argv, check=check, capture_output=True, input=input_text, text=True, timeout=60, ) except subprocess.CalledProcessError as exc: stderr = exc.stderr.strip() if stderr: print(stderr, file=sys.stderr) raise def _write_volume_file( *, image: str, volume: str, filename: str, content: str, ) -> None: if not re.fullmatch(r"[A-Za-z0-9_.-]+", filename): raise ValueError(f"invalid config filename: {filename!r}") _run( [ "docker", "run", "--rm", "--interactive", "--user", "0:0", "--mount", f"type=volume,src={volume},dst=/govoplan-config", "--entrypoint", "sh", image, "-c", f"umask 022; cat > /govoplan-config/{filename}", ], input_text=content, ) 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}" backend_config_volume = f"govoplan-ingress-backend-config-{suffix}" ingress_config_volume = f"govoplan-ingress-caddy-config-{suffix}" load_balancer_config_volume = f"govoplan-ingress-haproxy-config-{suffix}" cleanup = [ ["docker", "rm", "--force", ingress, backend], ["docker", "network", "rm", network], [ "docker", "volume", "rm", data_volume, config_volume, backend_config_volume, ingress_config_volume, load_balancer_config_volume, ], ] try: _run(["docker", "network", "create", network]) for volume in ( data_volume, config_volume, backend_config_volume, ingress_config_volume, load_balancer_config_volume, ): _run(["docker", "volume", "create", 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) _write_volume_file( image=args.load_balancer_image, volume=load_balancer_config_volume, filename="haproxy.cfg", content=load_balancer_config.read_text(encoding="utf-8"), ) _write_volume_file( image=args.caddy_image, volume=backend_config_volume, filename="Caddyfile", content=backend_config.read_text(encoding="utf-8"), ) _write_volume_file( image=args.caddy_image, volume=ingress_config_volume, filename="Caddyfile", content=ingress_config.read_text(encoding="utf-8"), ) _run( [ "docker", "run", "--rm", "--read-only", "--cap-drop", "ALL", "--mount", ( "type=volume," f"src={load_balancer_config_volume}," "dst=/usr/local/etc/haproxy,readonly" ), args.load_balancer_image, "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg", ] ) _run( [ "docker", "run", "--detach", "--name", backend, "--network", network, "--network-alias", "load-balancer", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m", "--mount", ( "type=volume," f"src={backend_config_volume}," "dst=/govoplan-config,readonly" ), args.caddy_image, "caddy", "run", "--config", "/govoplan-config/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", ( "type=volume," f"src={ingress_config_volume}," "dst=/govoplan-config,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", "/govoplan-config/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())