#!/usr/bin/env python3 """One-shot JSON adapter for preparing the project's test Compose service. The adapter owns no deployment policy of its own: service discovery and ``_config.yaml`` inheritance stay in ``lib.service_info``. The command runner is deliberately injectable so unit tests never need Docker, SSH, or a network. """ from __future__ import annotations import json import os import socket import subprocess import sys from pathlib import Path from typing import Any, Callable _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) import lib # noqa: E402 STATUSES = {"ready", "blocked", "environment-fail"} LOCAL_NODE_NAMES = {"local", "localhost", "127.0.0.1", "::1"} CommandRunner = Callable[[list[str], Path], int] NodeChecker = Callable[[str], bool] def response(status: str, observed: str, *, deployment_id: str = "", checks: dict[str, Any] | None = None, remediation: str = "") -> dict[str, Any]: if status not in STATUSES: raise ValueError(f"invalid status: {status}") value: dict[str, Any] = {"status": status, "observed": observed} if deployment_id: value["deployment_id"] = deployment_id if checks: value["checks"] = checks if remediation: value["remediation"] = remediation return value def _check(status: str, observed: str, expected: str, remediation: str = "") -> dict[str, str]: value = {"status": status, "observed": observed, "expected": expected} if remediation: value["remediation"] = remediation return value def run_compose(command: list[str], cwd: Path) -> int: """Run adapter-owned Compose argv without a shell or protocol pollution.""" try: process = subprocess.run( command, cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) except OSError: return 127 return process.returncode def execute_compose(executor: CommandRunner, command: list[str], cwd: Path) -> int: """Normalize mock/OS command failures into an environment-fail exit code.""" try: return int(executor(command, cwd)) except (OSError, TypeError, ValueError): return 127 def node_is_local(node: str) -> bool: """Accept only a local node; ``ssh -G`` is config inspection, never SSH.""" if node in LOCAL_NODE_NAMES: return True local_names = {socket.gethostname(), socket.getfqdn(), os.environ.get("HOSTNAME", "")} if node in {name for name in local_names if name}: return True try: process = subprocess.run( ["ssh", "-G", node], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False, ) except OSError: return False if process.returncode != 0: return False configured_host = "" for line in (process.stdout or "").splitlines(): key, _, value = line.partition(" ") if key == "hostname": configured_host = value.strip() break return configured_host in LOCAL_NODE_NAMES or configured_host in {name for name in local_names if name} def _project_root(value: Any) -> Path | None: if not isinstance(value, str) or not value.strip(): return None root = Path(value) if not root.is_absolute() or ".." in root.parts or os.path.normpath(value) != value: return None return root def prepare_test(request: Any, *, executor: CommandRunner = run_compose, local_node: NodeChecker = node_is_local) -> dict[str, Any]: if not isinstance(request, dict): return response("blocked", "prepare request must be a JSON object", remediation="Send one JSON object on stdin") if request.get("operation") != "prepare": return response("blocked", "only operation=prepare is supported", remediation="Use the test deployer prepare contract") environment = request.get("environment") if environment != "test": return response("blocked", "only the test environment is permitted", remediation="Use environment: test; production is never accepted by this adapter") root = _project_root(request.get("project_root")) if root is None or not root.is_dir(): return response("blocked", "project_root must be an existing absolute project directory", remediation="Provide the project root containing .pouch/deployer/test") host = request.get("host") if not isinstance(host, str) or not host.strip(): return response("blocked", "host is required", remediation="Provide the test service host used by preflight") public_host = request.get("public_host") if public_host is not None and (not isinstance(public_host, str) or not public_host.strip()): return response("blocked", "public_host must be non-empty text when provided") deploy_root = root / ".pouch" / "deployer" test_dir = deploy_root / "test" if not deploy_root.is_dir() or not test_dir.is_dir(): return response("blocked", "test deployer directory is missing", remediation="Create .pouch/deployer/test with its Compose files and _config.yaml") lib.PROJECT_ROOT = deploy_root try: info = lib.service_info("test", strict=False) except (OSError, ValueError, SystemExit): info = None if not info: return response("blocked", "test Compose service is not discoverable", remediation="Ensure test/compose.yaml and inherited _config.yaml resolve a node") node = str(info.get("node") or "") if not node or not local_node(node): return response("blocked", "test Compose node is not confirmed local", remediation=f"Resolve node {node or ''!r} to this machine; this adapter never falls back to SSH") compose_file = test_dir / "compose.yaml" if not compose_file.is_file(): return response("blocked", "test compose.yaml is missing", remediation="Provide .pouch/deployer/test/compose.yaml") deployment_id = f"test:{info['name']}" base = ["docker", "compose", "-f", "compose.yaml"] checks: dict[str, Any] = {} code = execute_compose(executor, [*base, "config", "--quiet"], test_dir) if code != 0: checks["compose_config"] = _check("environment-fail", f"docker compose config exited {code}", "valid test Compose configuration", "Fix the test Compose file or its local environment") return response("environment-fail", "test Compose configuration failed", deployment_id=deployment_id, checks=checks) checks["compose_config"] = _check("ready", "validated", "valid test Compose configuration") code = execute_compose(executor, [*base, "up", "-d", "--force-recreate"], test_dir) if code != 0: checks["compose_up"] = _check("environment-fail", f"docker compose up exited {code}", "test services started", "Fix the local Docker/image/network failure and retry") return response("environment-fail", "test Compose prepare failed", deployment_id=deployment_id, checks=checks) checks["compose_up"] = _check("ready", "completed", "test services started") code = execute_compose(executor, [*base, "ps"], test_dir) if code != 0: checks["compose_ps"] = _check("environment-fail", f"docker compose ps exited {code}", "test services report status", "Inspect local test service health") return response("environment-fail", "test Compose status check failed", deployment_id=deployment_id, checks=checks) checks["compose_ps"] = _check("ready", "completed", "test services report status") return response("ready", "test Compose service prepared", deployment_id=deployment_id, checks=checks) def main() -> int: try: request = json.load(sys.stdin) except (json.JSONDecodeError, OSError): result = response("blocked", "prepare stdin is not one valid JSON object", remediation="Send exactly one JSON object on stdin") else: try: result = prepare_test(request) except Exception: print("deployer prepare: unexpected adapter failure", file=sys.stderr) result = response("environment-fail", "prepare adapter failed before producing evidence", remediation="Inspect the test deployer configuration") print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main())