330 lines
10 KiB
Python
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())
|