Add Asterisk configuration and diagnostics support

- 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.
This commit is contained in:
2026-06-15 07:49:10 +03:00
parent 0375b20bb4
commit 9fc35e86fe
13 changed files with 773 additions and 4 deletions

View File

@@ -1,10 +1,19 @@
"""Asterisk deployment profiles and connect playbooks."""
"""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"})
@@ -12,6 +21,8 @@ ASTERISK_CATALOG_KEYS = frozenset({"asterisk", "asterisk_geneseasx", "asterisk_s
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)."""
@@ -40,10 +51,32 @@ def _docker_asterisk(command: str):
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")),
@@ -70,7 +103,6 @@ ASTERISK_PLAYBOOKS: dict[str, dict] = {
"connect": ("ssh_connect", _ssh_connect),
"diagnostics": GENESEASX_ASTERISK_DIAGNOSTICS,
},
# Legacy catalog key — same as GeneseasX
"asterisk": {
"mcp_server": "ssh-generic-mcp",
"connect": ("ssh_connect", _ssh_connect),
@@ -82,3 +114,164 @@ ASTERISK_PLAYBOOKS: dict[str, dict] = {
"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,
}

View File

@@ -17,7 +17,11 @@ from app.agent.json_utils import parse_json as _parse_json
from app.agent.playbooks import playbook_for
from app.agent.pfsense_profiles import run_pfsense_diagnostics
from app.agent.proxmox_profiles import enrich_proxmox_device, run_proxmox_diagnostics
from app.agent.asterisk_profiles import asterisk_container_name
from app.agent.asterisk_profiles import (
ASTERISK_CATALOG_KEYS,
asterisk_container_name,
run_asterisk_diagnostics,
)
from app.agent.tool_runner import invoke_connect, invoke_tool
from app.core.crypto import decrypt_secret
from app.config import settings
@@ -249,6 +253,7 @@ async def _run_device_diagnostics(
*,
title: str = "",
issue: str = "",
vessel_context: dict | None = None,
) -> list[dict]:
"""Connect to one onboard device and run read-only playbook diagnostics."""
from app.models.enums import DeviceType as DT
@@ -279,6 +284,18 @@ async def _run_device_diagnostics(
max_diagnostics=8,
)
if device.get("catalog_key") in ASTERISK_CATALOG_KEYS or dtype == DT.asterisk:
return await run_asterisk_diagnostics(
task_id,
device,
manager,
_emit,
title=title,
issue=issue,
vessel_context=vessel_context,
max_diagnostics=MAX_DIAGNOSTICS_PER_DEVICE,
)
pb = playbook_for(dtype, device.get("mcp_server"), device.get("catalog_key"))
if not pb:
@@ -395,6 +412,10 @@ async def diagnose_node(state: AgentState) -> AgentState:
manager,
title=state.get("title") or "",
issue=state.get("issue") or "",
vessel_context={
"vessel_name": state.get("vessel_name"),
"vessel_public_ip": state.get("vessel_public_ip"),
},
)
)
return {"diagnostics": diagnostics}