from __future__ import annotations import argparse import asyncio from dataclasses import dataclass from datetime import datetime, timezone import json import os from pathlib import Path import shutil import subprocess import sys import time from typing import Any import httpx ROOT = Path(__file__).resolve().parents[1] DATA_ROOT = ROOT / ".data_uat_preflight" DEFAULT_EVIDENCE_DIR = ROOT / "docs" / "uat" / "evidence" SERVICE_SPECS = [ {"name": "auth", "module": "services.auth_service.app:app", "port": 48001}, {"name": "audit", "module": "services.audit_service.app:app", "port": 48002}, {"name": "customer", "module": "services.customer_service.app:app", "port": 48003}, {"name": "interaction", "module": "services.interaction_service.app:app", "port": 48004}, {"name": "routing", "module": "services.routing_service.app:app", "port": 48005}, {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 48006}, {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 48007}, {"name": "kb", "module": "services.kb_service.app:app", "port": 48008}, {"name": "reporting", "module": "services.reporting_service.app:app", "port": 48009}, {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 48010}, {"name": "gateway", "module": "gateway.app:app", "port": 48080}, ] REQUIRED_SERVICES = [ "auth", "audit", "customer", "interaction", "routing", "voice", "telegram", "kb", "reporting", "supervisor", ] @dataclass class CheckResult: name: str ok: bool details: str def utc_now() -> datetime: return datetime.now(timezone.utc) def now_compact() -> str: return utc_now().strftime("%Y%m%d_%H%M%S") def iso_now() -> str: return utc_now().isoformat() async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.2) -> None: async with httpx.AsyncClient(timeout=2) as client: for _ in range(retries): try: response = await client.get(f"{base_url}/health") if response.status_code == 200: return except Exception: pass await asyncio.sleep(delay) raise RuntimeError(f"Service not healthy: {base_url}/health") def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen: env = os.environ.copy() env["CC_DATA_DIR"] = str(data_dir) if spec["name"] == "gateway": env["AUTH_SERVICE_URL"] = "http://127.0.0.1:48001" env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:48002" env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:48003" env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:48004" env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:48005" env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48006" env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48007" env["KB_SERVICE_URL"] = "http://127.0.0.1:48008" env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:48009" env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:48010" cmd = [ sys.executable, "-m", "uvicorn", spec["module"], "--host", "127.0.0.1", "--port", str(spec["port"]), ] return subprocess.Popen( cmd, cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]: data_dir.mkdir(parents=True, exist_ok=True) processes: list[subprocess.Popen] = [] for spec in SERVICE_SPECS: proc = start_one_service(spec, data_dir) processes.append(proc) await wait_for_health(f"http://127.0.0.1:{spec['port']}") return processes def stop_services(processes: list[subprocess.Popen]) -> None: for proc in processes: if proc.poll() is not None: continue try: proc.terminate() except Exception: pass for proc in processes: try: proc.wait(timeout=2) except Exception: try: proc.kill() except Exception: pass async def run_checks(base_url: str) -> tuple[list[CheckResult], dict[str, Any]]: checks: list[CheckResult] = [] artifacts: dict[str, Any] = {} admin = {"X-User": "admin", "X-Role": "admin"} supervisor = {"X-User": "supervisor", "X-Role": "supervisor"} operator = {"X-User": "operator", "X-Role": "operator"} analyst = {"X-User": "analyst", "X-Role": "analyst"} async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: try: health = await client.get("/health") ok = health.status_code == 200 checks.append(CheckResult("Gateway health", ok, f"status={health.status_code}")) if not ok: return checks, artifacts except Exception as exc: checks.append(CheckResult("Gateway health", False, f"exception={exc}")) return checks, artifacts try: registry = await client.get("/registry") data = registry.json() if registry.status_code == 200 else {} services = data.get("services", {}) missing = [name for name in REQUIRED_SERVICES if name not in services] ok = registry.status_code == 200 and not missing details = f"status={registry.status_code}" if missing: details += f", missing={missing}" checks.append(CheckResult("Service registry", ok, details)) if not ok: return checks, artifacts except Exception as exc: checks.append(CheckResult("Service registry", False, f"exception={exc}")) return checks, artifacts try: login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"}) ok = login.status_code == 200 and "access_token" in login.json() checks.append(CheckResult("Auth login", ok, f"status={login.status_code}")) except Exception as exc: checks.append(CheckResult("Auth login", False, f"exception={exc}")) return checks, artifacts try: queue = await client.post( "/proxy/routing/queues", headers=admin, json={ "name": f"UAT Queue {now_compact()}", "description": "UAT preflight queue", "rules": [ {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}, {"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45}, ], }, ) queue_ok = queue.status_code == 200 queue_id = queue.json().get("queue_id") if queue_ok else "" artifacts["queue_id"] = queue_id checks.append(CheckResult("Queue create", queue_ok, f"status={queue.status_code}, queue_id={queue_id}")) except Exception as exc: checks.append(CheckResult("Queue create", False, f"exception={exc}")) return checks, artifacts try: customer = await client.post( "/proxy/customer/customers", json={ "display_name": "UAT Preflight Customer", "phones": ["+77019990000"], "preferred_phone": "+77019990000", "tags": ["uat", "preflight"], }, ) customer_ok = customer.status_code == 200 customer_id = customer.json().get("customer_id") if customer_ok else "" artifacts["customer_id"] = customer_id checks.append( CheckResult("Customer create", customer_ok, f"status={customer.status_code}, customer_id={customer_id}") ) except Exception as exc: checks.append(CheckResult("Customer create", False, f"exception={exc}")) return checks, artifacts interaction_id = "" try: interaction = await client.post( "/proxy/interaction/interactions", headers=operator, json={ "channel": "voice", "subject": "UAT preflight voice interaction", "customer_id": artifacts["customer_id"], "queue_id": artifacts["queue_id"], "priority": 3, }, ) ok = interaction.status_code == 200 interaction_id = interaction.json().get("interaction_id") if ok else "" artifacts["voice_interaction_id"] = interaction_id checks.append(CheckResult("Voice interaction create", ok, f"status={interaction.status_code}")) if not ok: return checks, artifacts assign = await client.patch( f"/proxy/interaction/interactions/{interaction_id}/assign", headers=supervisor, json={"assignee": "operator_a"}, ) assign_ok = assign.status_code == 200 and assign.json().get("status") == "in_progress" checks.append(CheckResult("Voice interaction assign", assign_ok, f"status={assign.status_code}")) close = await client.patch( f"/proxy/interaction/interactions/{interaction_id}/status", headers=operator, json={"status": "closed"}, ) close_ok = close.status_code == 200 and close.json().get("status") == "closed" checks.append(CheckResult("Voice interaction close", close_ok, f"status={close.status_code}")) except Exception as exc: checks.append(CheckResult("Voice interaction lifecycle", False, f"exception={exc}")) return checks, artifacts try: tg = await client.post( "/proxy/telegram/integrations/telegram/webhook", json={ "chat_id": f"uat_chat_{now_compact()}", "text": "UAT preflight telegram", "customer_external_id": None, "payload": {"source": "uat_preflight"}, }, ) ok = tg.status_code == 200 and tg.json().get("message_id") artifacts["telegram_message_id"] = tg.json().get("message_id") if tg.status_code == 200 else "" checks.append(CheckResult("Telegram webhook", bool(ok), f"status={tg.status_code}")) except Exception as exc: checks.append(CheckResult("Telegram webhook", False, f"exception={exc}")) return checks, artifacts try: cat = await client.post( "/proxy/kb/knowledge/categories", headers=analyst, json={"name": f"UAT {now_compact()}", "description": "UAT preflight"}, ) cat_ok = cat.status_code == 200 category_id = cat.json().get("category_id") if cat_ok else "" if not cat_ok: checks.append(CheckResult("KB category", False, f"status={cat.status_code}")) return checks, artifacts art = await client.post( "/proxy/kb/knowledge/articles", headers=analyst, json={ "category_id": category_id, "title": "UAT preflight article", "body": "Use this article during UAT flow.", "tags": ["uat", "kb"], }, ) art_ok = art.status_code == 200 checks.append(CheckResult("KB article", art_ok, f"status={art.status_code}")) if not art_ok: return checks, artifacts search = await client.get("/proxy/kb/knowledge/search?q=preflight") search_ok = search.status_code == 200 and len(search.json()) > 0 checks.append(CheckResult("KB search", search_ok, f"status={search.status_code}, count={len(search.json())}")) except Exception as exc: checks.append(CheckResult("KB checks", False, f"exception={exc}")) return checks, artifacts try: ingest = await client.post( "/proxy/reporting/reports/events", json={ "queue_id": artifacts["queue_id"], "answered": True, "wait_seconds": 18, "handle_seconds": 105, "abandoned": False, "resolved_first_contact": True, }, ) ingest_ok = ingest.status_code == 200 checks.append(CheckResult("KPI event ingest", ingest_ok, f"status={ingest.status_code}")) if not ingest_ok: return checks, artifacts kpi = await client.get(f"/proxy/reporting/reports/kpi?queue_id={artifacts['queue_id']}") kpi_ok = kpi.status_code == 200 and "kpi" in kpi.json() checks.append(CheckResult("KPI report", kpi_ok, f"status={kpi.status_code}")) except Exception as exc: checks.append(CheckResult("KPI checks", False, f"exception={exc}")) return checks, artifacts try: up1 = await client.post( "/proxy/supervisor/supervisor/agent-states", json={"agent_id": "uat_agent_1", "state": "READY", "queue_id": artifacts["queue_id"]}, ) up2 = await client.post( "/proxy/supervisor/supervisor/agent-states", json={"agent_id": "uat_agent_2", "state": "BUSY", "queue_id": artifacts["queue_id"]}, ) rt = await client.get("/proxy/supervisor/supervisor/realtime") realtime_ok = up1.status_code == 200 and up2.status_code == 200 and rt.status_code == 200 checks.append(CheckResult("Supervisor realtime", realtime_ok, f"status={rt.status_code}")) except Exception as exc: checks.append(CheckResult("Supervisor realtime", False, f"exception={exc}")) return checks, artifacts try: audit = await client.get("/proxy/audit/audit/events?limit=5") ok = audit.status_code == 200 checks.append(CheckResult("Audit availability", ok, f"status={audit.status_code}")) except Exception as exc: checks.append(CheckResult("Audit availability", False, f"exception={exc}")) artifacts["completed_at"] = iso_now() return checks, artifacts def write_report( output_path: Path, base_url: str, checks: list[CheckResult], artifacts: dict[str, Any], auto_start: bool, ) -> None: ok_count = sum(1 for c in checks if c.ok) total = len(checks) overall = "PASS" if ok_count == total else "FAIL" lines: list[str] = [] lines.append("# UAT Preflight Report") lines.append("") lines.append(f"- Timestamp (UTC): {iso_now()}") lines.append(f"- Base URL: {base_url}") lines.append(f"- Auto-start mode: {str(auto_start).lower()}") lines.append(f"- Overall: {overall} ({ok_count}/{total})") lines.append("") lines.append("## Scope Baseline") lines.append("- Pilot scope freeze: `docs/gates/mvp-pilot-baseline.md`") lines.append("- Deferred scope backlog: `docs/roadmap/05-wave2-backlog.md`") lines.append("") lines.append("## Checks") for item in checks: marker = "PASS" if item.ok else "FAIL" lines.append(f"- [{marker}] {item.name}: {item.details}") lines.append("") lines.append("## Artifacts") if artifacts: for key, value in artifacts.items(): lines.append(f"- {key}: {value}") else: lines.append("- none") lines.append("") lines.append("## Next Action") lines.append("- If all checks passed, run `scripts/uat_dry_run.py` and prepare the manual UAT session.") lines.append("") output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text("\n".join(lines), encoding="utf-8") async def main() -> None: parser = argparse.ArgumentParser(description="UAT preflight checker for pilot session") parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for preflight") parser.add_argument( "--output", default="", help="Report output path (default: docs/uat/evidence/preflight_.md)", ) parser.add_argument("--keep-data", action="store_true", help="Keep temporary auto-start data directory") args = parser.parse_args() output = Path(args.output) if args.output else DEFAULT_EVIDENCE_DIR / f"preflight_{now_compact()}.md" output = output if output.is_absolute() else (ROOT / output).resolve() processes: list[subprocess.Popen] = [] run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" base_url = args.base_url checks: list[CheckResult] = [] artifacts: dict[str, Any] = {} try: if args.auto_start: processes = await start_services_sequential(run_data_dir) base_url = "http://127.0.0.1:48080" checks, artifacts = await run_checks(base_url) write_report(output, base_url, checks, artifacts, args.auto_start) ok = all(item.ok for item in checks) and len(checks) > 0 print(f"UAT preflight report: {output}") print(f"Result: {'PASS' if ok else 'FAIL'}") sys.exit(0 if ok else 1) finally: stop_services(processes) if args.auto_start and not args.keep_data: try: if run_data_dir.exists(): shutil.rmtree(run_data_dir) except Exception: pass if __name__ == "__main__": asyncio.run(main())