- Updated .env.example to include Asterisk templates path. - Modified docker-compose.yml to mount the templates directory. - Enhanced backend Dockerfile to copy templates into the container. - Introduced Asterisk diagnostics functionality in asterisk_profiles.py, allowing for baseline checks and diagnostics reporting. - Integrated Asterisk diagnostics into the device diagnostics workflow in graph.py. - Added formatting for Asterisk baseline drift reports in diagnostic_format.py. - Updated SKILL.md to document new config baseline drift feature for Asterisk. This commit enhances the system's capabilities for managing Asterisk configurations and diagnostics, improving overall troubleshooting processes.
278 lines
8.1 KiB
Python
278 lines
8.1 KiB
Python
"""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,
|
|
}
|