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:
@@ -123,6 +123,13 @@ SKILLS_PATH=/app/skills
|
||||
SKILLS_MAX_MATCHED=2
|
||||
SKILLS_MAX_BODY_CHARS=8000
|
||||
|
||||
# Asterisk golden config templates (templates/asterisk/<profile>/manifest.yaml)
|
||||
ASTERISK_TEMPLATES_PATH=/app/templates/asterisk
|
||||
# Per-vessel overrides when not using inventory metadata:
|
||||
# ASTERISK_BASELINE_ASTERISK_IP=10.20.30.222
|
||||
# ASTERISK_BASELINE_PHONE_SUBNET=192.168.0.0/24
|
||||
# ASTERISK_BASELINE_VOIP_SUBNET=10.20.30.0/24
|
||||
|
||||
# ---- Frontend ---------------------------------------------------------
|
||||
# Public URL the browser uses to reach the backend API
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
@@ -18,6 +18,7 @@ RUN pip install -r requirements.txt
|
||||
COPY backend/ .
|
||||
COPY rules/ /app/rules/
|
||||
COPY skills/ /app/skills/
|
||||
COPY templates/ /app/templates/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
|
||||
skills_max_matched: int = 2
|
||||
skills_max_body_chars: int = 8000
|
||||
|
||||
# Asterisk golden config templates (templates/asterisk/<profile>/manifest.yaml)
|
||||
asterisk_templates_path: str = "/app/templates/asterisk"
|
||||
|
||||
# Runtime paths (inside container)
|
||||
runtime_dir: str = "/runtime"
|
||||
|
||||
|
||||
293
backend/app/services/asterisk_baseline.py
Normal file
293
backend/app/services/asterisk_baseline.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Compare live Asterisk config against golden templates on disk."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_manifest_cache: dict[str, dict] = {}
|
||||
_manifest_mtime: float = -1.0
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineCheck:
|
||||
kind: str # required_line | required_substring
|
||||
expected: str
|
||||
found: bool
|
||||
ok: bool
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"expected": self.expected,
|
||||
"found": self.found,
|
||||
"ok": self.ok,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineFileResult:
|
||||
path: str
|
||||
description: str
|
||||
ok: bool
|
||||
missing: bool = False
|
||||
checks: list[BaselineCheck] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"path": self.path,
|
||||
"description": self.description,
|
||||
"ok": self.ok,
|
||||
"missing": self.missing,
|
||||
"checks": [c.as_dict() for c in self.checks],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineReport:
|
||||
profile: str
|
||||
ok: bool
|
||||
variables: dict[str, str]
|
||||
files: list[BaselineFileResult]
|
||||
summary: str
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"profile": self.profile,
|
||||
"ok": self.ok,
|
||||
"variables": self.variables,
|
||||
"files": [f.as_dict() for f in self.files],
|
||||
"summary": self.summary,
|
||||
}
|
||||
|
||||
|
||||
def templates_root() -> str:
|
||||
return os.environ.get("ASTERISK_TEMPLATES_PATH", settings.asterisk_templates_path)
|
||||
|
||||
|
||||
def manifest_path(profile: str = "geneseasx") -> str:
|
||||
return os.path.join(templates_root(), profile, "manifest.yaml")
|
||||
|
||||
|
||||
def profile_for_catalog(catalog_key: str | None) -> str | None:
|
||||
root = templates_root()
|
||||
if not os.path.isdir(root):
|
||||
return None
|
||||
key = (catalog_key or "").lower()
|
||||
for name in sorted(os.listdir(root)):
|
||||
path = os.path.join(root, name, "manifest.yaml")
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
continue
|
||||
keys = [str(k).lower() for k in (data.get("catalog_keys") or [])]
|
||||
if key in keys:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(profile: str, *, force: bool = False) -> dict | None:
|
||||
global _manifest_cache, _manifest_mtime
|
||||
path = manifest_path(profile)
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not force and _manifest_cache.get(profile) and _manifest_mtime == mtime:
|
||||
return _manifest_cache[profile]
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
logger.warning("invalid asterisk manifest %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
_manifest_cache[profile] = data
|
||||
_manifest_mtime = mtime
|
||||
return data
|
||||
|
||||
|
||||
def invalidate_manifest_cache() -> None:
|
||||
global _manifest_cache, _manifest_mtime
|
||||
_manifest_cache = {}
|
||||
_manifest_mtime = -1.0
|
||||
|
||||
|
||||
def substitute(text: str, variables: dict[str, str]) -> str:
|
||||
out = text
|
||||
for name, value in variables.items():
|
||||
out = out.replace(f"{{{{{name}}}}}", value)
|
||||
out = out.replace(f"__{name}__", value)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_variables(manifest: dict, device_ctx: dict | None = None) -> dict[str, str]:
|
||||
"""Merge manifest defaults, env overrides, and optional device context."""
|
||||
device_ctx = device_ctx or {}
|
||||
variables: dict[str, str] = {
|
||||
str(k): str(v) for k, v in (manifest.get("variables") or {}).items()
|
||||
}
|
||||
|
||||
for name in list(variables.keys()):
|
||||
env_key = f"ASTERISK_BASELINE_{name}"
|
||||
env_val = os.environ.get(env_key, "").strip()
|
||||
if env_val:
|
||||
variables[name] = env_val
|
||||
|
||||
# Optional per-device overrides (inventory metadata or env-derived)
|
||||
mapping = {
|
||||
"ASTERISK_IP": device_ctx.get("asterisk_ip"),
|
||||
"PHONE_SUBNET": device_ctx.get("phone_subnet"),
|
||||
"VOIP_SUBNET": device_ctx.get("voip_subnet"),
|
||||
"RTP_START": device_ctx.get("rtp_start"),
|
||||
"RTP_END": device_ctx.get("rtp_end"),
|
||||
}
|
||||
for name, val in mapping.items():
|
||||
if val:
|
||||
variables[name] = str(val)
|
||||
|
||||
return variables
|
||||
|
||||
|
||||
def _normalize_ini_line(line: str) -> str:
|
||||
line = line.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
return ""
|
||||
line = line.split(";", 1)[0].strip()
|
||||
line = re.sub(r"\s+", "", line)
|
||||
return line.lower()
|
||||
|
||||
|
||||
def _content_normalized_lines(content: str) -> list[str]:
|
||||
return [n for n in (_normalize_ini_line(ln) for ln in content.splitlines()) if n]
|
||||
|
||||
|
||||
def _line_present(content: str, required: str) -> bool:
|
||||
req = _normalize_ini_line(required)
|
||||
if not req:
|
||||
return True
|
||||
norm_lines = _content_normalized_lines(content)
|
||||
if req in norm_lines:
|
||||
return True
|
||||
# Allow key=value match when live file has extra whitespace/casing
|
||||
if "=" in req:
|
||||
key, _, val = req.partition("=")
|
||||
for line in norm_lines:
|
||||
if line.startswith(key + "=") and val in line:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _substring_present(content: str, needle: str) -> bool:
|
||||
return needle.strip().lower() in content.lower()
|
||||
|
||||
|
||||
def compare_file(
|
||||
live_content: str | None,
|
||||
file_spec: dict,
|
||||
variables: dict[str, str],
|
||||
) -> BaselineFileResult:
|
||||
path = str(file_spec.get("path") or "")
|
||||
description = str(file_spec.get("description") or "")
|
||||
checks: list[BaselineCheck] = []
|
||||
|
||||
if live_content is None or live_content.strip() == "__MISSING__":
|
||||
return BaselineFileResult(
|
||||
path=path,
|
||||
description=description,
|
||||
ok=False,
|
||||
missing=True,
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
for raw in file_spec.get("required_lines") or []:
|
||||
expected = substitute(str(raw), variables)
|
||||
found = _line_present(live_content, expected)
|
||||
checks.append(
|
||||
BaselineCheck(
|
||||
kind="required_line",
|
||||
expected=expected,
|
||||
found=found,
|
||||
ok=found,
|
||||
)
|
||||
)
|
||||
|
||||
for raw in file_spec.get("required_substrings") or []:
|
||||
expected = substitute(str(raw), variables)
|
||||
found = _substring_present(live_content, expected)
|
||||
checks.append(
|
||||
BaselineCheck(
|
||||
kind="required_substring",
|
||||
expected=expected,
|
||||
found=found,
|
||||
ok=found,
|
||||
)
|
||||
)
|
||||
|
||||
ok = all(c.ok for c in checks) if checks else True
|
||||
return BaselineFileResult(path=path, description=description, ok=ok, checks=checks)
|
||||
|
||||
|
||||
def compare_against_baseline(
|
||||
live_files: dict[str, str],
|
||||
*,
|
||||
profile: str,
|
||||
device_ctx: dict | None = None,
|
||||
) -> BaselineReport | None:
|
||||
manifest = load_manifest(profile)
|
||||
if not manifest:
|
||||
return None
|
||||
|
||||
variables = resolve_variables(manifest, device_ctx)
|
||||
file_results: list[BaselineFileResult] = []
|
||||
|
||||
for file_spec in manifest.get("files") or []:
|
||||
path = str(file_spec.get("path") or "")
|
||||
live = live_files.get(path)
|
||||
file_results.append(compare_file(live, file_spec, variables))
|
||||
|
||||
total_checks = sum(len(f.checks) for f in file_results)
|
||||
passed = sum(1 for f in file_results for c in f.checks if c.ok)
|
||||
files_ok = all(f.ok for f in file_results) if file_results else True
|
||||
|
||||
if any(f.missing for f in file_results):
|
||||
summary = f"Config baseline: {passed}/{total_checks} checks passed; some files missing"
|
||||
elif total_checks == 0:
|
||||
summary = "Config baseline: no checks defined"
|
||||
else:
|
||||
summary = f"Config baseline: {passed}/{total_checks} checks passed"
|
||||
|
||||
return BaselineReport(
|
||||
profile=profile,
|
||||
ok=files_ok and passed == total_checks,
|
||||
variables=variables,
|
||||
files=file_results,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def config_paths_for_profile(profile: str) -> list[str]:
|
||||
manifest = load_manifest(profile)
|
||||
if not manifest:
|
||||
return []
|
||||
return [str(f.get("path")) for f in (manifest.get("files") or []) if f.get("path")]
|
||||
|
||||
|
||||
def config_root_for_profile(profile: str) -> str:
|
||||
manifest = load_manifest(profile) or {}
|
||||
return str(manifest.get("config_root") or "/etc/asterisk").rstrip("/")
|
||||
@@ -273,8 +273,41 @@ def _qm_config_text_block(text: str) -> str:
|
||||
return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```"
|
||||
|
||||
|
||||
def _baseline_drift_table(payload: dict) -> str:
|
||||
lines = [
|
||||
f"**Profile:** `{payload.get('profile', '?')}` — {payload.get('summary', '')}",
|
||||
"",
|
||||
"| File | Check | Expected | Status |",
|
||||
"|---|---|---|---|",
|
||||
]
|
||||
for file_entry in payload.get("files") or []:
|
||||
path = file_entry.get("path") or "?"
|
||||
if file_entry.get("missing"):
|
||||
lines.append(f"| `{path}` | file | present | **MISSING** |")
|
||||
continue
|
||||
checks = file_entry.get("checks") or []
|
||||
if not checks:
|
||||
lines.append(f"| `{path}` | — | — | ok |")
|
||||
continue
|
||||
for i, check in enumerate(checks):
|
||||
path_col = f"`{path}`" if i == 0 else ""
|
||||
kind = check.get("kind") or "check"
|
||||
expected = _cell(check.get("expected") or "")
|
||||
status = "ok" if check.get("ok") else "**DRIFT**"
|
||||
lines.append(f"| {path_col} | {kind} | {expected} | {status} |")
|
||||
vars_used = payload.get("variables") or {}
|
||||
if vars_used:
|
||||
lines.append("")
|
||||
lines.append("Variables: " + ", ".join(f"`{k}={v}`" for k, v in vars_used.items()))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_tool_output_markdown(tool: str, output: str) -> str:
|
||||
"""Turn raw MCP JSON/text into readable markdown when possible."""
|
||||
if tool == "asterisk_config_baseline":
|
||||
parsed = _parse_json_payload(output)
|
||||
if isinstance(parsed, dict):
|
||||
return _baseline_drift_table(parsed)
|
||||
parsed = _parse_json_payload(output)
|
||||
if tool == "proxmox_list_qemu":
|
||||
if isinstance(parsed, (dict, list)):
|
||||
@@ -325,7 +358,7 @@ def prepare_report_diagnostics(diagnostics: list[dict]) -> list[dict]:
|
||||
formatted = ""
|
||||
tool = d.get("tool")
|
||||
if text:
|
||||
if tool in _TABLE_TOOLS:
|
||||
if tool in _TABLE_TOOLS or tool == "asterisk_config_baseline":
|
||||
formatted = format_tool_output_markdown(tool, text)
|
||||
elif tool == "ssh_run":
|
||||
if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", text, re.MULTILINE):
|
||||
|
||||
138
backend/tests/test_asterisk_baseline.py
Normal file
138
backend/tests/test_asterisk_baseline.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Tests for Asterisk golden config baseline."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
_test_dir = Path(__file__).resolve().parent
|
||||
_repo_root = _test_dir.parent.parent
|
||||
_templates_root = _repo_root / "templates" / "asterisk"
|
||||
if not _templates_root.is_dir():
|
||||
_templates_root = _test_dir.parent / "templates" / "asterisk"
|
||||
os.environ["ASTERISK_TEMPLATES_PATH"] = str(_templates_root)
|
||||
|
||||
from app.services.asterisk_baseline import ( # noqa: E402
|
||||
compare_against_baseline,
|
||||
compare_file,
|
||||
invalidate_manifest_cache,
|
||||
profile_for_catalog,
|
||||
resolve_variables,
|
||||
substitute,
|
||||
)
|
||||
from app.services.diagnostic_format import format_tool_output_markdown # noqa: E402
|
||||
|
||||
|
||||
def test_profile_for_geneseasx_catalog():
|
||||
invalidate_manifest_cache()
|
||||
assert profile_for_catalog("asterisk_geneseasx") == "geneseasx"
|
||||
assert profile_for_catalog("asterisk") == "geneseasx"
|
||||
assert profile_for_catalog("asterisk_satbox") is None
|
||||
|
||||
|
||||
def test_substitute_variables():
|
||||
assert substitute("local_net={{PHONE_SUBNET}}", {"PHONE_SUBNET": "192.168.0.0/24"}) == (
|
||||
"local_net=192.168.0.0/24"
|
||||
)
|
||||
|
||||
|
||||
def test_compare_file_detects_missing_line():
|
||||
live = """
|
||||
[transport-udp]
|
||||
type=transport
|
||||
local_net=10.20.30.0/24
|
||||
external_media_address=10.20.30.222
|
||||
"""
|
||||
spec = {
|
||||
"path": "pjsip.conf",
|
||||
"description": "test",
|
||||
"required_lines": [
|
||||
"local_net={{PHONE_SUBNET}}",
|
||||
"local_net={{VOIP_SUBNET}}",
|
||||
],
|
||||
}
|
||||
variables = {"PHONE_SUBNET": "192.168.0.0/24", "VOIP_SUBNET": "10.20.30.0/24"}
|
||||
result = compare_file(live, spec, variables)
|
||||
assert result.ok is False
|
||||
assert any(not c.ok for c in result.checks)
|
||||
|
||||
|
||||
def test_compare_file_passes_golden_pjsip():
|
||||
live = """
|
||||
[transport-udp]
|
||||
type=transport
|
||||
protocol=udp
|
||||
local_net=192.168.0.0/24
|
||||
local_net=10.20.30.0/24
|
||||
external_media_address=10.20.30.222
|
||||
external_signaling_address=10.20.30.222
|
||||
"""
|
||||
spec = {
|
||||
"path": "pjsip.conf",
|
||||
"required_lines": [
|
||||
"local_net={{PHONE_SUBNET}}",
|
||||
"local_net={{VOIP_SUBNET}}",
|
||||
"external_media_address={{ASTERISK_IP}}",
|
||||
"external_signaling_address={{ASTERISK_IP}}",
|
||||
],
|
||||
}
|
||||
variables = {
|
||||
"PHONE_SUBNET": "192.168.0.0/24",
|
||||
"VOIP_SUBNET": "10.20.30.0/24",
|
||||
"ASTERISK_IP": "10.20.30.222",
|
||||
}
|
||||
result = compare_file(live, spec, variables)
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
def test_compare_against_baseline_full_report():
|
||||
invalidate_manifest_cache()
|
||||
live_files = {
|
||||
"pjsip.conf": """
|
||||
local_net=192.168.0.0/24
|
||||
local_net=10.20.30.0/24
|
||||
external_media_address=10.20.30.222
|
||||
external_signaling_address=10.20.30.222
|
||||
""",
|
||||
"rtp.conf": "rtpstart=10000\nrtpend=10029\n",
|
||||
"extensions.conf": "[public]\nexten => _7XXXX,1,NoOp(trunk)\n",
|
||||
}
|
||||
report = compare_against_baseline(live_files, profile="geneseasx")
|
||||
assert report is not None
|
||||
assert report.ok is True
|
||||
assert "6/6" in report.summary or "checks passed" in report.summary
|
||||
|
||||
|
||||
def test_env_override_variables(monkeypatch):
|
||||
invalidate_manifest_cache()
|
||||
from app.services.asterisk_baseline import load_manifest
|
||||
|
||||
manifest = load_manifest("geneseasx", force=True)
|
||||
monkeypatch.setenv("ASTERISK_BASELINE_ASTERISK_IP", "10.99.99.99")
|
||||
vars = resolve_variables(manifest or {}, {})
|
||||
assert vars["ASTERISK_IP"] == "10.99.99.99"
|
||||
|
||||
|
||||
def test_baseline_markdown_formatting():
|
||||
payload = {
|
||||
"profile": "geneseasx",
|
||||
"summary": "Config baseline: 5/6 checks passed",
|
||||
"variables": {"ASTERISK_IP": "10.20.30.222"},
|
||||
"files": [
|
||||
{
|
||||
"path": "pjsip.conf",
|
||||
"missing": False,
|
||||
"checks": [
|
||||
{
|
||||
"kind": "required_line",
|
||||
"expected": "local_net=192.168.0.0/24",
|
||||
"ok": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
md = format_tool_output_markdown("asterisk_config_baseline", json.dumps(payload))
|
||||
assert "DRIFT" in md
|
||||
assert "pjsip.conf" in md
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
- ./rules:/app/rules
|
||||
- ./skills:/app/skills
|
||||
- ./templates:/app/templates
|
||||
ports:
|
||||
- "8000:8000"
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -94,6 +95,7 @@ services:
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
- ./rules:/app/rules
|
||||
- ./skills:/app/skills
|
||||
- ./templates:/app/templates
|
||||
command: ["python", "-m", "app.worker"]
|
||||
|
||||
frontend:
|
||||
|
||||
22
docs/README.md
Normal file
22
docs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Agentic OS Documentation
|
||||
|
||||
Agentic OS is a self-hosted troubleshooting platform for vessel and homelab infrastructure. It combines an operator UI, a FastAPI backend, a LangGraph diagnostic agent, MCP tool integrations, local/cloud LLM routing, and durable reporting to project memory and Obsidian.
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- [Project Overview](./project-overview.md) — purpose, core capabilities, users, and supported infrastructure.
|
||||
- [Architecture](./architecture.md) — services, data flow, task lifecycle, persistence, and integration boundaries.
|
||||
- [Operator Guide](./operator-guide.md) — how to use the dashboard, inventory, tasks, approvals, reports, and artifacts.
|
||||
- [Developer and Operations Guide](./developer-operations.md) — local stack, configuration, tests, deployment notes, and extension points.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- Frontend: `frontend/app/`
|
||||
- Backend API: `backend/app/api/`
|
||||
- Agent workflow: `backend/app/agent/graph.py`
|
||||
- MCP manager: `backend/app/mcp_manager/`
|
||||
- Rules: `rules/troubleshooting.yaml`
|
||||
- Skills: `skills/*/SKILL.md`
|
||||
- Templates: `templates/`
|
||||
- Runtime stack: `docker-compose.yml`
|
||||
|
||||
@@ -88,6 +88,7 @@ Interface map (verify on device): `vtnet0`=WAN1, `vtnet1`=WAN2, `vtnet2`=CREW, `
|
||||
|
||||
- pfSense rule tracker IDs, filter.log lines for blocked RTP/SIP
|
||||
- Asterisk CLI: endpoint config, channel stats, `core show channels`
|
||||
- **Config baseline drift** — task report includes `asterisk_config_baseline` when GeneseasX Asterisk is diagnosed; fix any **DRIFT** on `local_net`, RTP range, dialplan before chasing firewall
|
||||
- Do not propose firewall writes without backup — see pfsense-change-safe skill
|
||||
|
||||
## During investigation
|
||||
|
||||
42
templates/asterisk/geneseasx/manifest.yaml
Normal file
42
templates/asterisk/geneseasx/manifest.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# GeneseasX Asterisk golden config baseline
|
||||
# Used by asterisk_baseline.py to compare live container config on task runs.
|
||||
#
|
||||
# Variables use {{NAME}} placeholders — resolved from:
|
||||
# 1. This file (defaults)
|
||||
# 2. Env ASTERISK_BASELINE_<NAME> (e.g. ASTERISK_BASELINE_ASTERISK_IP)
|
||||
# 3. Per-vessel device metadata (future UI fields)
|
||||
|
||||
profile: geneseasx
|
||||
catalog_keys:
|
||||
- asterisk_geneseasx
|
||||
- asterisk
|
||||
|
||||
variables:
|
||||
ASTERISK_IP: "10.20.30.222"
|
||||
PHONE_SUBNET: "192.168.0.0/24"
|
||||
VOIP_SUBNET: "10.20.30.0/24"
|
||||
RTP_START: "10000"
|
||||
RTP_END: "10029"
|
||||
|
||||
config_root: /etc/asterisk
|
||||
|
||||
files:
|
||||
- path: pjsip.conf
|
||||
description: PJSIP NAT / SDP — critical for registration and one-way audio
|
||||
required_lines:
|
||||
- "local_net={{PHONE_SUBNET}}"
|
||||
- "local_net={{VOIP_SUBNET}}"
|
||||
- "external_media_address={{ASTERISK_IP}}"
|
||||
- "external_signaling_address={{ASTERISK_IP}}"
|
||||
|
||||
- path: rtp.conf
|
||||
description: RTP port range — must align with pfSense pass rules (UDP 10000-10029)
|
||||
required_lines:
|
||||
- "rtpstart={{RTP_START}}"
|
||||
- "rtpend={{RTP_END}}"
|
||||
|
||||
- path: extensions.conf
|
||||
description: Dialplan — trunk patterns and internal routing
|
||||
required_substrings:
|
||||
- "[public]"
|
||||
- "exten => _7"
|
||||
13
templates/asterisk/geneseasx/pjsip.conf.snippet
Normal file
13
templates/asterisk/geneseasx/pjsip.conf.snippet
Normal file
@@ -0,0 +1,13 @@
|
||||
; Golden reference snippet — GeneseasX Asterisk PJSIP (not executed; for human review).
|
||||
; Live checks use manifest.yaml required_lines against /etc/asterisk/pjsip.conf in container.
|
||||
|
||||
[transport-udp]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=0.0.0.0:5060
|
||||
local_net={{PHONE_SUBNET}}
|
||||
local_net={{VOIP_SUBNET}}
|
||||
external_media_address={{ASTERISK_IP}}
|
||||
external_signaling_address={{ASTERISK_IP}}
|
||||
|
||||
; Endpoint includes vary by vessel — verify local_net on transport and per-endpoint if used.
|
||||
Reference in New Issue
Block a user