"""Asterisk deployment profiles, connect playbooks, and diagnostics.""" from __future__ import annotations import json import os import shlex from typing import Awaitable, Callable from app.agent.device_connect import ssh_connect_args from app.agent.tool_runner import invoke_connect, invoke_tool from app.services.asterisk_baseline import ( compare_against_baseline, config_paths_for_profile, config_root_for_profile, profile_for_catalog, ) # Only one Asterisk slot allowed per vessel. ASTERISK_CATALOG_KEYS = frozenset({"asterisk", "asterisk_geneseasx", "asterisk_satbox"}) GENESEASX_KEYS = frozenset({"asterisk", "asterisk_geneseasx"}) SATBOX_KEYS = frozenset({"asterisk_satbox"}) EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]] def asterisk_container_name(catalog_key: str | None) -> str: """Docker container name for GeneseasX Asterisk (default: voip).""" if catalog_key not in GENESEASX_KEYS: return "" return ( os.environ.get("DEVICE_ASTERISK_GENESEASX_CONTAINER", "").strip() or os.environ.get("DEVICE_ASTERISK_CONTAINER", "").strip() or "voip" ) def _ssh_connect(ctx: dict) -> dict: port = ctx.get("port") if port is None: port_raw = os.environ.get("DEVICE_ASTERISK_GENESEASX_PORT", "").strip() port = int(port_raw) if port_raw.isdigit() else 6022 return ssh_connect_args({**ctx, "port": port}) def _docker_asterisk(command: str): def builder(c: dict) -> dict: container = c.get("asterisk_container") or asterisk_container_name(c.get("catalog_key")) or "voip" return {"command": f"docker exec {container} asterisk -rx {shlex.quote(command)}"} return builder def _docker_cat_config(relative_path: str, *, config_root: str = "/etc/asterisk"): def builder(c: dict) -> dict: container = c.get("asterisk_container") or asterisk_container_name(c.get("catalog_key")) or "voip" full = f"{config_root.rstrip('/')}/{relative_path.lstrip('/')}" return { "command": ( f"docker exec {shlex.quote(container)} cat {shlex.quote(full)} 2>/dev/null " f"|| echo '__MISSING__'" ) } return builder def _asterisk_cli(command: str): return lambda c: {"command": f'asterisk -rx "{command}"'} def _baseline_device_ctx(device: dict, vessel_context: dict | None) -> dict: ctx = dict(vessel_context or {}) meta = device.get("metadata") or device.get("baseline_vars") or {} if isinstance(meta, dict): ctx.update({k: v for k, v in meta.items() if v is not None}) return ctx # Native Debian host diagnostics (TMGeneseas / satbox) via SSH. SATBOX_ASTERISK_DIAGNOSTICS = [ ("ssh_run", _asterisk_cli("pjsip show registrations")), ("ssh_run", _asterisk_cli("pjsip show endpoints")), ("ssh_run", _asterisk_cli("core show channels")), ("ssh_run", lambda c: {"command": "tail -n 100 /var/log/asterisk/full 2>/dev/null || journalctl -u asterisk -n 100 --no-pager 2>/dev/null || true"}), ] # GeneseasX: SSH to Docker VM, docker exec into voip (password auth via ssh-generic-mcp). GENESEASX_ASTERISK_DIAGNOSTICS = [ ("ssh_run", _docker_asterisk("pjsip show registrations")), ("ssh_run", _docker_asterisk("pjsip show endpoints")), ("ssh_run", _docker_asterisk("core show channels")), ("ssh_run", lambda c: { "command": ( f"docker logs --tail 100 {shlex.quote(c.get('asterisk_container') or 'voip')} 2>&1 | tail -100" ) }), ] ASTERISK_PLAYBOOKS: dict[str, dict] = { "asterisk_geneseasx": { "mcp_server": "ssh-generic-mcp", "connect": ("ssh_connect", _ssh_connect), "diagnostics": GENESEASX_ASTERISK_DIAGNOSTICS, }, "asterisk": { "mcp_server": "ssh-generic-mcp", "connect": ("ssh_connect", _ssh_connect), "diagnostics": GENESEASX_ASTERISK_DIAGNOSTICS, }, "asterisk_satbox": { "mcp_server": "ssh-generic-mcp", "connect": ("ssh_connect", _ssh_connect), "diagnostics": SATBOX_ASTERISK_DIAGNOSTICS, }, } async def run_asterisk_diagnostics( task_id: int, device: dict, manager, emit: EmitFn, *, title: str = "", issue: str = "", vessel_context: dict | None = None, max_diagnostics: int = 8, ) -> list[dict]: """Connect to Asterisk (GeneseasX or satbox) and run CLI + config baseline checks.""" catalog_key = device.get("catalog_key") or "" pb = ASTERISK_PLAYBOOKS.get(catalog_key) if not pb: pb = ASTERISK_PLAYBOOKS.get("asterisk_geneseasx") if not pb: return [] results: list[dict] = [] label = catalog_key or device.get("name") or "asterisk" server = pb["mcp_server"] server_state = manager.servers.get(server or "") if not server or not server_state or server_state.status != "loaded": await emit( task_id, "diagnose", f"MCP '{server}' not available for {label}.", {"device": label, "server": server}, ) return results profile = profile_for_catalog(catalog_key) intent = "voip + config baseline" if profile else "voip health" await emit( task_id, "connect", f"Connecting to {label} via {server} ({intent})...", {"device": label, "intent": intent, "baseline_profile": profile}, ) connect = pb.get("connect") if connect: tool, builder = connect ok = await invoke_connect(task_id, label, server, tool, builder(device), manager, emit) if not ok: return results for tool, builder in pb["diagnostics"][:max_diagnostics]: args = builder(device) res, tool_ok = await invoke_tool( task_id, label, server, tool, args, manager, emit, task_title=title, task_issue=issue, ) results.append( { "device": label, "tool": tool, "ok": tool_ok, "output": res.get("text", ""), "intent": intent, } ) if profile: baseline_result = await _run_config_baseline( task_id, device, manager, emit, profile=profile, label=label, server=server, title=title, issue=issue, vessel_context=vessel_context, ) if baseline_result: results.append(baseline_result) return results async def _run_config_baseline( task_id: int, device: dict, manager, emit: EmitFn, *, profile: str, label: str, server: str, title: str, issue: str, vessel_context: dict | None, ) -> dict | None: paths = config_paths_for_profile(profile) if not paths: return None config_root = config_root_for_profile(profile) live_files: dict[str, str] = {} await emit( task_id, "diagnose", f"Fetching Asterisk config files for baseline ({profile})…", {"device": label, "profile": profile, "files": paths}, ) for path in paths: args = _docker_cat_config(path, config_root=config_root)(device) res, ok = await invoke_tool( task_id, label, server, "ssh_run", args, manager, emit, task_title=title, task_issue=issue, ) live_files[path] = res.get("text", "") if ok else "__MISSING__" report = compare_against_baseline( live_files, profile=profile, device_ctx=_baseline_device_ctx(device, vessel_context), ) if not report: return None payload = report.as_dict() summary = report.summary await emit( task_id, "diagnose", summary, {"device": label, "baseline": payload}, ) return { "device": label, "tool": "asterisk_config_baseline", "ok": report.ok, "output": json.dumps(payload, indent=2), "intent": "config baseline", "baseline": payload, }