Files
.pouch/skills/deployer/scripts/deploy/prepare.py
T
laily adaa8e4edd feat: add test-preflight runner and deployer prepare adapters
agent-runtime executes fixed tester node operations over JSON stdin.
deployer prepare brings up the local test Compose service the same way.
test-preflight now passes the tester binding; tester select skips
unauthorized cases before creating a runner.
2026-10-19 09:28:50 +08:00

194 lines
8.3 KiB
Python
Executable File

#!/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 '<missing>'!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())