Updated by fix-consistency on 2026-03-27: - update .custodian-brief.md for the-custodian
132 lines
3.6 KiB
Python
132 lines
3.6 KiB
Python
"""
|
|
Full e2e lifecycle: up → health-wait → test → down → result.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .sandbox import Sandbox
|
|
from .schema import E2EConfig
|
|
|
|
|
|
@dataclass
|
|
class RunResult:
|
|
sandbox_id: str
|
|
repo: str
|
|
passed: bool
|
|
exit_code: int
|
|
duration_s: float
|
|
output: str
|
|
error: str = ""
|
|
|
|
|
|
def run_e2e(
|
|
repo_path: Path,
|
|
host: str,
|
|
ssh_user: str = "root",
|
|
ssh_key: str | None = None,
|
|
keep: bool = False,
|
|
) -> RunResult:
|
|
config = E2EConfig.load(repo_path)
|
|
sandbox = Sandbox(
|
|
host=host,
|
|
repo_path=repo_path,
|
|
ssh_user=ssh_user,
|
|
ssh_key=ssh_key,
|
|
)
|
|
project_name = f"e2e-{config.name}-{sandbox.sandbox_id}"
|
|
compose_path = f"{sandbox.remote_dir}/{config.compose_file}"
|
|
started = time.time()
|
|
output_lines: list[str] = []
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg)
|
|
output_lines.append(msg)
|
|
|
|
log(f"\n{'='*60}")
|
|
log(f"E2E run: {config.name} sandbox={sandbox.sandbox_id}")
|
|
log(f"Host: {host} project: {project_name}")
|
|
log(f"{'='*60}\n")
|
|
|
|
try:
|
|
# 1. Provision
|
|
sandbox.provision()
|
|
|
|
# 2. docker compose up
|
|
env_flags = " ".join(f"-e {k}={v}" for k, v in config.env.items())
|
|
up_cmd = (
|
|
f"cd {sandbox.remote_dir} && "
|
|
f"docker compose -p {project_name} -f {compose_path} up -d"
|
|
)
|
|
log(f"[runner] docker compose up ({project_name})")
|
|
rc, out = sandbox.run(up_cmd, timeout=180, stream=False)
|
|
output_lines.append(out)
|
|
if rc != 0:
|
|
raise RuntimeError(f"docker compose up failed (exit {rc}):\n{out}")
|
|
|
|
# 3. Health checks
|
|
for hc in config.health_checks:
|
|
ok = sandbox.wait_for_url(hc.url, timeout=hc.timeout)
|
|
if not ok:
|
|
raise RuntimeError(f"Health check failed: {hc.name} ({hc.url})")
|
|
|
|
# 4. Run tests
|
|
log(f"\n[runner] running: {config.test_command}")
|
|
test_cmd = f"cd {sandbox.remote_dir} && {config.test_command}"
|
|
rc, test_out = sandbox.run(test_cmd, timeout=config.timeout, stream=False)
|
|
output_lines.append(test_out)
|
|
print(test_out)
|
|
|
|
passed = rc == 0
|
|
duration = time.time() - started
|
|
log(f"\n[runner] {'PASSED' if passed else 'FAILED'} (exit={rc}, {duration:.1f}s)")
|
|
|
|
return RunResult(
|
|
sandbox_id=sandbox.sandbox_id,
|
|
repo=config.name,
|
|
passed=passed,
|
|
exit_code=rc,
|
|
duration_s=duration,
|
|
output="\n".join(output_lines),
|
|
)
|
|
|
|
except Exception as exc:
|
|
duration = time.time() - started
|
|
log(f"\n[runner] ERROR: {exc}")
|
|
return RunResult(
|
|
sandbox_id=sandbox.sandbox_id,
|
|
repo=config.name,
|
|
passed=False,
|
|
exit_code=-1,
|
|
duration_s=duration,
|
|
output="\n".join(output_lines),
|
|
error=str(exc),
|
|
)
|
|
|
|
finally:
|
|
_compose_down(sandbox, project_name, compose_path, config, keep)
|
|
|
|
|
|
def _compose_down(
|
|
sandbox: Sandbox,
|
|
project_name: str,
|
|
compose_path: str,
|
|
config: E2EConfig,
|
|
keep: bool,
|
|
) -> None:
|
|
if keep or config.cleanup == "never":
|
|
print(f"[runner] skipping cleanup (keep={keep}, cleanup={config.cleanup})")
|
|
return
|
|
|
|
print(f"[runner] docker compose down ({project_name})")
|
|
down_cmd = (
|
|
f"cd {sandbox.remote_dir} && "
|
|
f"docker compose -p {project_name} -f {compose_path} down -v --remove-orphans 2>&1 || true"
|
|
)
|
|
sandbox.run(down_cmd, timeout=60)
|
|
|
|
if not keep:
|
|
sandbox.teardown()
|