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

@@ -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("/")