from __future__ import annotations import argparse import json import os import subprocess import sys from datetime import datetime from pathlib import Path import httpx from sqlalchemy import create_engine, text ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from services.shared.security import issue_app_token def _load_env_file(path: str) -> None: env_path = Path(path).expanduser().resolve() if not env_path.exists() or not env_path.is_file(): return for line in env_path.read_text(encoding="utf-8").splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#") or "=" not in stripped: continue key, value = stripped.split("=", 1) key = key.strip() if key and key not in os.environ: os.environ[key] = value.strip() def _bool_env(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} def _ops_headers() -> dict[str, str]: if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): return {"X-User": "admin", "X-Role": "admin"} token = issue_app_token( subject="ops:track9-evidence", username="track9-evidence", role="admin", auth_source="service", provider="track9-script", ttl_seconds=300, ) return {"Authorization": f"Bearer {token}"} def _get_json(url: str) -> dict | list: with httpx.Client(timeout=15, trust_env=False) as client: response = client.get(url, headers=_ops_headers()) response.raise_for_status() return response.json() def _write_json(path: Path, payload: dict | list) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def _query_rows(conn, sql: str, limit: int = 20) -> list[dict]: rows = conn.execute(text(sql), {"limit": limit}).mappings().all() return [dict(item) for item in rows] def _run_command(command: list[str], output_path: Path) -> int: result = subprocess.run( # noqa: S603 command, capture_output=True, text=True, check=False, ) output = (result.stdout or "").strip() errors = (result.stderr or "").strip() rendered = "\n".join( [ f"$ {' '.join(command)}", f"exit_code={result.returncode}", "", "STDOUT:", output if output else "", "", "STDERR:", errors if errors else "", "", ] ) output_path.write_text(rendered, encoding="utf-8") return int(result.returncode) def _render_summary( *, base_url: str, db_url: str, out_dir: Path, bridge_status: dict, failed_events: list[dict], started_rows: list[dict], ended_rows: list[dict], recording_rows: list[dict], preflight_exit_code: int | None, smoke_exit_code: int | None, track9_check_exit_code: int | None, ) -> str: now = datetime.now().isoformat(timespec="seconds") lines = [ "# Track 9 Acceptance Summary", "", f"- Generated at: `{now}`", f"- Gateway: `{base_url}`", f"- Database: `{db_url}`", f"- Evidence directory: `{out_dir.as_posix()}`", "", "## Bridge status", "", f"- `status`: `{bridge_status.get('status')}`", f"- `ami_connected`: `{bridge_status.get('ami_connected')}`", f"- `queue_codes_loaded`: `{len(bridge_status.get('queue_codes_loaded') or [])}`", f"- `sftp_enabled`: `{bridge_status.get('sftp_enabled')}`", "", "## Event evidence counts", "", f"- `failed bridge events`: `{len(failed_events)}`", f"- `call.started (asterisk source)`: `{len(started_rows)}`", f"- `call.ended`: `{len(ended_rows)}`", f"- `recording-ready with recording_id`: `{len(recording_rows)}`", "", "## Files", "", "- `bridge_health.json`", "- `bridge_status.json`", "- `bridge_failed_events.json`", "- `db_voice_call_started.json`", "- `db_voice_call_ended.json`", "- `db_recording_ready_links.json`", "- `playback-proof.md`", ] if preflight_exit_code is not None: lines.extend( [ "- `preflight_output.txt`", f" status: `{'PASS' if preflight_exit_code == 0 else 'FAIL'}`", ] ) if smoke_exit_code is not None: lines.extend( [ "- `smoke_output.txt`", f" status: `{'PASS' if smoke_exit_code == 0 else 'FAIL'}`", ] ) if track9_check_exit_code is not None: lines.extend( [ "- `track9_check_output.txt`", f" status: `{'PASS' if track9_check_exit_code == 0 else 'FAIL'}`", ] ) lines.extend( [ "", "## Manual attachments", "", "- Add supervisor playback proof (screenshot or note) in `playback-proof.md`.", "", "## Acceptance decision", "", "- [ ] GO", "- [ ] NO-GO", "", "## Notes", "", "- Fill environment details and any deviations from runbook.", ] ) return "\n".join(lines) + "\n" def main() -> int: parser = argparse.ArgumentParser(description="Collect Track 9 acceptance evidence package") parser.add_argument("--base-url", default="http://127.0.0.1:8080") parser.add_argument("--env-file", default=".env.production") parser.add_argument("--database-url", required=True) parser.add_argument("--out-dir", default="") parser.add_argument("--limit", type=int, default=20) parser.add_argument("--run-checks", action="store_true") parser.add_argument("--require-recording", action="store_true") parser.add_argument("--python-exe", default=sys.executable) args = parser.parse_args() _load_env_file(args.env_file) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") out_dir = ( Path(args.out_dir) if args.out_dir.strip() else Path("docs/acceptance/track9") / timestamp ) out_dir.mkdir(parents=True, exist_ok=True) base_url = args.base_url.rstrip("/") health = _get_json(f"{base_url}/proxy/asterisk-bridge/health") status = _get_json(f"{base_url}/proxy/asterisk-bridge/asterisk/status") failed_events = _get_json(f"{base_url}/proxy/asterisk-bridge/asterisk/events?status=failed") _write_json(out_dir / "bridge_health.json", health) # type: ignore[arg-type] _write_json(out_dir / "bridge_status.json", status) # type: ignore[arg-type] _write_json(out_dir / "bridge_failed_events.json", failed_events) # type: ignore[arg-type] engine = create_engine(args.database_url, future=True) with engine.begin() as conn: started_rows = _query_rows( conn, """ SELECT event_id, call_id, interaction_id, payload_json, created_at FROM voice_events WHERE event_type = 'call.started' AND payload_json LIKE '%"source": "asterisk"%' ORDER BY id DESC LIMIT :limit """, limit=args.limit, ) ended_rows = _query_rows( conn, """ SELECT event_id, call_id, interaction_id, payload_json, created_at FROM voice_events WHERE event_type = 'call.ended' ORDER BY id DESC LIMIT :limit """, limit=args.limit, ) recording_rows = _query_rows( conn, """ SELECT bridge_event_id, call_id, interaction_id, recording_id, forward_status, updated_at FROM asterisk_event_log WHERE ami_event_name = 'MVPCCRecordingReady' AND recording_id IS NOT NULL ORDER BY id DESC LIMIT :limit """, limit=args.limit, ) _write_json(out_dir / "db_voice_call_started.json", started_rows) _write_json(out_dir / "db_voice_call_ended.json", ended_rows) _write_json(out_dir / "db_recording_ready_links.json", recording_rows) (out_dir / "playback-proof.md").write_text( "\n".join( [ "# Playback Proof", "", "- Date/time:", "- Supervisor user:", "- Recording ID:", "- Playback URL:", "- Result (played/downloaded):", "- Evidence link/screenshot path:", "", ] ), encoding="utf-8", ) preflight_exit_code: int | None = None smoke_exit_code: int | None = None track9_check_exit_code: int | None = None if args.run_checks: preflight_cmd = [ args.python_exe, "scripts/track9_preflight.py", "--base-url", base_url, "--check-sftp", ] smoke_cmd = [ args.python_exe, "scripts/asterisk_lab_smoke.py", "--base-url", base_url, "--database-url", args.database_url, ] check_cmd = [ args.python_exe, "scripts/track9_check.py", "--base-url", base_url, "--database-url", args.database_url, ] if args.require_recording: smoke_cmd.append("--require-recording") check_cmd.append("--require-recording") preflight_exit_code = _run_command(preflight_cmd, out_dir / "preflight_output.txt") smoke_exit_code = _run_command(smoke_cmd, out_dir / "smoke_output.txt") track9_check_exit_code = _run_command(check_cmd, out_dir / "track9_check_output.txt") summary = _render_summary( base_url=base_url, db_url=args.database_url, out_dir=out_dir, bridge_status=status if isinstance(status, dict) else {}, failed_events=failed_events if isinstance(failed_events, list) else [], started_rows=started_rows, ended_rows=ended_rows, recording_rows=recording_rows, preflight_exit_code=preflight_exit_code, smoke_exit_code=smoke_exit_code, track9_check_exit_code=track9_check_exit_code, ) (out_dir / "track9-acceptance.md").write_text(summary, encoding="utf-8") print("[PASS] Track 9 evidence package created") print(f"- output_dir: {out_dir.as_posix()}") return 0 if __name__ == "__main__": raise SystemExit(main())