Files
call-center/scripts/track9_collect_evidence.py
T

330 lines
10 KiB
Python

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 "<empty>",
"",
"STDERR:",
errors if errors else "<empty>",
"",
]
)
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())