Files
Agentic-OS/backend/app/agent/asterisk_profiles.py
nearxos 9fc35e86fe 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.
2026-06-15 07:49:10 +03:00

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,
}