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.
This commit is contained in:
@@ -83,6 +83,14 @@ ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「C
|
||||
5. `prod` 环境:停止本轨道的 Compose 操作,读取 [production-agent.md](references/production-agent.md),在本机 `~/code/app00` 项目创建 fresh Agent terminal 并委派正式部署;当前 Agent 不得直接执行 `sync.py`、`remote.py` 或 SSH。
|
||||
6. 只有明确点名的非正式远程 Compose 环境,才允许 `sync.py <svc>` 后调用 `remote.py <svc> recreate`;只重启调用 `remote.py <svc> restart`,并用 `remote.py <svc> ps` 验证。
|
||||
|
||||
test-preflight 的一次性部署入口为 `scripts/deploy/prepare.py`。它从 stdin 接收
|
||||
`{"operation":"prepare","environment":"test","project_root":"/abs/project","host":"...","public_host":"..."}`,
|
||||
只解析项目 `.pouch/deployer/test`,复用 `lib.service_info` 的 `_config.yaml` 继承和 node
|
||||
规则,确认 node 是本机后才按 `docker compose config`、`up -d --force-recreate`、`ps`
|
||||
顺序准备单个 test 服务。输出始终是 `{status, observed, deployment_id?, checks?}` JSON;
|
||||
prod、缺配置、非本机 node 为 `blocked`,Compose/命令失败为 `environment-fail`。诊断只写
|
||||
stderr,不输出 `.env` 或命令输出中的凭据。
|
||||
|
||||
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
|
||||
|
||||
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
|
||||
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "prepare.py"
|
||||
SPEC = importlib.util.spec_from_file_location("deployer_prepare", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
prepare = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(prepare)
|
||||
|
||||
|
||||
class PrepareTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
deployer = self.root / ".pouch" / "deployer" / "test"
|
||||
deployer.mkdir(parents=True)
|
||||
(deployer / "compose.yaml").write_text("services:\n app:\n image: example:test\n", encoding="utf-8")
|
||||
(deployer / "_config.yaml").write_text("node: devbox\nname: writes-test\n", encoding="utf-8")
|
||||
self.request = {"operation": "prepare", "environment": "test", "project_root": str(self.root), "host": "http://test.local"}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_prod_is_rejected_without_inspecting_or_executing_compose(self):
|
||||
calls = []
|
||||
result = prepare.prepare_test({**self.request, "environment": "prod"}, executor=lambda command, cwd: calls.append(command) or 0)
|
||||
self.assertEqual(result["status"], "blocked")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_missing_config_is_blocked(self):
|
||||
(self.root / ".pouch" / "deployer" / "test" / "_config.yaml").unlink()
|
||||
result = prepare.prepare_test(self.request, local_node=lambda node: True)
|
||||
self.assertEqual(result["status"], "blocked")
|
||||
self.assertIn("discoverable", result["observed"])
|
||||
|
||||
def test_mock_compose_prepare_is_ready(self):
|
||||
calls = []
|
||||
|
||||
def executor(command, cwd):
|
||||
calls.append((command, cwd))
|
||||
return 0
|
||||
|
||||
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: node == "devbox")
|
||||
self.assertEqual(result["status"], "ready")
|
||||
self.assertEqual(calls[0][0][-2:], ["config", "--quiet"])
|
||||
self.assertEqual(calls[1][0][-3:], ["up", "-d", "--force-recreate"])
|
||||
self.assertEqual(calls[2][0][-1], "ps")
|
||||
self.assertEqual(calls[0][1], self.root / ".pouch" / "deployer" / "test")
|
||||
|
||||
def test_compose_failures_are_environment_fail(self):
|
||||
for failed_step in range(3):
|
||||
calls = []
|
||||
|
||||
def executor(command, cwd):
|
||||
del cwd
|
||||
calls.append(command)
|
||||
return 1 if len(calls) == failed_step + 1 else 0
|
||||
|
||||
result = prepare.prepare_test(self.request, executor=executor, local_node=lambda node: True)
|
||||
self.assertEqual(result["status"], "environment-fail")
|
||||
self.assertNotEqual(result["status"], "ready")
|
||||
|
||||
def test_json_stdout_has_no_diagnostics(self):
|
||||
process = subprocess.run(
|
||||
[sys.executable, str(SCRIPT)],
|
||||
input=json.dumps({**self.request, "environment": "prod"}),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(process.returncode, 0)
|
||||
self.assertEqual(json.loads(process.stdout)["status"], "blocked")
|
||||
self.assertEqual(process.stderr, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user