Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,25 @@ Design docs: [docs/](docs/) (architecture, webhook spec, runbook spec, notificat

```
agentic-node-ops/
├── docs/ # Design specs (index in hermes-agent-design-final.md)
├── src/agentic_node_ops/ # Python source (Phase 2 — notifications)
├── docs/ # Design specs (architecture, webhook, runbooks, etc.)
├── src/agentic_node_ops/ # Python source (Hermes integration & notifications)
│ ├── __init__.py
│ ├── types.py # Alert schemas, data models
│ ├── dispatcher.py # Alert routing and processing
│ ├── discord.py # Discord notification adapter
│ └── ntfy.py # ntfy.sh notification adapter
├── tests/ # Test suite (30 tests passing)
│ └── test_notifications.py
├── runbooks/ # YAML runbooks (Phase 2+)
├── webhook-receiver/ # Standalone HTTP receiver (Phase 1 — in progress)
│ ├── ntfy.py # ntfy.sh notification adapter
│ ├── database.py # SQLite WAL wrapper (sole writer for incidents)
│ ├── processor.py # Async jsonl drain, payload build, dispatch, offset update
│ └── runbooks.py # YAML runbook loading and alert_type matching
├── tests/ # Test suite (122 tests passing, 88% coverage)
│ ├── test_database.py
│ ├── test_notifications.py
│ ├── test_processor.py
│ └── test_runbooks.py
├── runbooks/ # YAML runbooks (e.g., consensus_desync.yaml)
├── webhook-receiver/ # Standalone HTTP receiver (Phase 1 complete)
│ ├── src/webhook_receiver/ # aiohttp server, schema validation, dedup, storm protection
│ └── tests/ # Webhook receiver test suite
└── .gitignore
```

Expand All @@ -40,8 +48,8 @@ agentic-node-ops/
| Phase | Scope | Status |
|---|---|---|
| 0 | Project scaffolding, packaging, CI/CD | ✅ Complete |
| 1 | Webhook receiver + alert normalization + jsonl queue | 🚧 In progress |
| 2 | Hermes integration + runbook matching + operator notifications | 🚧 Notifications implemented ✅, rest pending |
| 1 | Webhook receiver + alert normalization + dedup + storm protection + context fetch | ✅ Complete |
| 2 | Hermes integration + runbook matching + operator notifications | 🚧 In progress (Dispatcher, Processor, Runbooks, DB implemented; Hermes agent loop, full runbooks, slashing protocol pending) |
| 3 | Memory layer + feedback loop + host fingerprints | Design complete |
| 4 | Tier 2 suggested actions + approval state machine + socket-proxy migration + Discord Bot API | Design complete |
| 5 | Runbook synthesis from historical incidents | Design pending |
Expand Down
16 changes: 8 additions & 8 deletions docs/hermes-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,14 @@

**Files:**
- Create: `src/agentic_node_ops/runbooks.py`
- Create: `runbooks/consensus_desync.yaml` (first real runbook)

**Steps:**
1. Implement runbook loader (YAML parsing)
2. Implement matcher (alert_type → runbook)
3. Create first runbook: consensus_desync
4. Write tests
5. Verify matching produces correct runbook_id in payload
- Create: `tests/test_runbooks.py`
- Existing: `runbooks/consensus_desync.yaml`

[x] Complete — Implemented runbook loader and matcher:
- `runbooks.py`: YAML parsing with dataclasses (`Runbook`, `RunbookTrigger`, `RunbookAction`, `RunbookDiagnostic`) and `match_runbook()` function.
- `test_runbooks.py`: 6 tests covering loading (valid, empty, directory) and matching (found, not found, multiple triggers).
- Webhook receiver already sets `runbook_hint=alert_type`, which correctly maps to the runbook ID.
- All tests passing.

---

Expand Down
107 changes: 107 additions & 0 deletions src/agentic_node_ops/runbooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Runbook loading and matching utilities."""

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import yaml

log = logging.getLogger(__name__)


@dataclass
class RunbookTrigger:
alert_type: str
min_severity: str = "low"


@dataclass
class RunbookAction:
id: str
description: str
cmd: str
risk: str
reversible: bool
requires_approval: bool
approval_timeout: str = "30m"
pre_conditions: list[str] = field(default_factory=list)
requires_explicit_unlock: bool = False
phase: str | None = None


@dataclass
class RunbookDiagnostic:
id: str
cmd: str
timeout: str = "5s"
description: str = ""


@dataclass
class Runbook:
id: str
triggers: list[RunbookTrigger] = field(default_factory=list)
diagnostics: list[RunbookDiagnostic] = field(default_factory=list)
suggested_actions: list[RunbookAction] = field(default_factory=list)
privileged_actions: list[RunbookAction] = field(default_factory=list)


def load_runbook(file_path: str | Path) -> Runbook:
"""Load a single runbook from a YAML file."""
with open(file_path, "r") as f:
data = yaml.safe_load(f)

if not data:
raise ValueError(f"Empty or invalid runbook file: {file_path}")

triggers = [RunbookTrigger(**t) for t in data.get("triggers", [])]
diagnostics = [RunbookDiagnostic(**d) for d in data.get("diagnostics", [])]
suggested_actions = [RunbookAction(**a) for a in data.get("suggested_actions", [])]
privileged_actions = [
RunbookAction(**a) for a in data.get("privileged_actions", [])
]

return Runbook(
id=data.get("id", ""),
triggers=triggers,
diagnostics=diagnostics,
suggested_actions=suggested_actions,
privileged_actions=privileged_actions,
)


def load_runbooks(directory: str | Path) -> list[Runbook]:
"""Load all runbooks from a directory."""
runbooks = []
dir_path = Path(directory)
for file_path in dir_path.glob("*.yaml"):
try:
runbooks.append(load_runbook(file_path))
except Exception as e:
log.warning("Failed to load runbook %s: %s", file_path, e)
return runbooks


SEVERITY_LEVELS = ["low", "medium", "high", "critical"]


def match_runbook(
runbooks: list[Runbook], alert_type: str, severity: str = "low"
) -> Optional[Runbook]:
"""Return the matching runbook based on alert_type and severity, or None if not found."""
alert_severity_idx = (
SEVERITY_LEVELS.index(severity) if severity in SEVERITY_LEVELS else 0
)

for runbook in runbooks:
for trigger in runbook.triggers:
if trigger.alert_type == alert_type:
min_sev_idx = (
SEVERITY_LEVELS.index(trigger.min_severity)
if trigger.min_severity in SEVERITY_LEVELS
else 0
)
if alert_severity_idx >= min_sev_idx:
return runbook
return None
211 changes: 211 additions & 0 deletions tests/test_runbooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Tests for runbooks module."""

import os
import tempfile
from pathlib import Path

import pytest

from agentic_node_ops.runbooks import (
load_runbook,
load_runbooks,
match_runbook,
Runbook,
RunbookTrigger,
)


def test_load_runbook_valid():
"""Test load_runbook successfully parses a valid YAML file."""
yaml_content = """
id: test_runbook_1
triggers:
- alert_type: consensus_desync
min_severity: critical
diagnostics:
- id: diag_1
cmd: "echo 'check status'"
timeout: "5s"
suggested_actions:
- id: action_1
description: "Restart service"
cmd: "systemctl restart service"
risk: medium
reversible: true
requires_approval: false
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
path = f.name

try:
runbook = load_runbook(path)
assert runbook.id == "test_runbook_1"
assert len(runbook.triggers) == 1
assert runbook.triggers[0].alert_type == "consensus_desync"
assert runbook.triggers[0].min_severity == "critical"
assert len(runbook.diagnostics) == 1
assert runbook.diagnostics[0].id == "diag_1"
assert len(runbook.suggested_actions) == 1
assert runbook.suggested_actions[0].id == "action_1"
finally:
os.unlink(path)


def test_load_runbook_empty():
"""Test load_runbook raises ValueError on empty file."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write("")
path = f.name

try:
with pytest.raises(ValueError, match="Empty or invalid runbook file"):
load_runbook(path)
finally:
os.unlink(path)


def test_load_runbooks_directory():
"""Test load_runbooks loads all YAML files from a directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yaml1 = os.path.join(tmpdir, "runbook1.yaml")
yaml2 = os.path.join(tmpdir, "runbook2.yaml")
txt_file = os.path.join(tmpdir, "ignore.txt")

with open(yaml1, "w") as f:
f.write("id: rb1\ntriggers:\n - alert_type: alert_a\n")
with open(yaml2, "w") as f:
f.write("id: rb2\ntriggers:\n - alert_type: alert_b\n")
with open(txt_file, "w") as f:
f.write("not a yaml file")

runbooks = load_runbooks(tmpdir)
assert len(runbooks) == 2
ids = {rb.id for rb in runbooks}
assert ids == {"rb1", "rb2"}


def test_match_runbook_found():
"""Test match_runbook returns the correct runbook when alert_type and severity match."""
runbooks = [
Runbook(
id="rb1",
triggers=[RunbookTrigger(alert_type="alert_a", min_severity="high")],
),
Runbook(
id="rb2",
triggers=[RunbookTrigger(alert_type="alert_b", min_severity="critical")],
),
]

# Must pass severity="critical" to match the trigger's min_severity
matched = match_runbook(runbooks, "alert_b", severity="critical")
assert matched is not None
assert matched.id == "rb2"


def test_match_runbook_not_found():
"""Test match_runbook returns None when no alert_type matches."""
runbooks = [
Runbook(
id="rb1",
triggers=[RunbookTrigger(alert_type="alert_a", min_severity="high")],
),
]

matched = match_runbook(runbooks, "unknown_alert")
assert matched is None


def test_match_runbook_multiple_triggers():
"""Test match_runbook works when a runbook has multiple triggers."""
runbooks = [
Runbook(
id="rb1",
triggers=[
RunbookTrigger(alert_type="alert_a", min_severity="high"),
RunbookTrigger(alert_type="alert_c", min_severity="low"),
],
),
]

matched = match_runbook(runbooks, "alert_c")
assert matched is not None
assert matched.id == "rb1"


def test_load_runbook_with_privileged_actions():
"""Test load_runbook successfully parses privileged_actions with extra fields."""
yaml_content = """
id: test_privileged
privileged_actions:
- id: wipe_state
description: "Wipe state"
cmd: "rm -rf /data"
risk: high
reversible: false
requires_approval: true
requires_explicit_unlock: true
phase: "4_and_above_only"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
path = f.name

try:
runbook = load_runbook(path)
assert runbook.id == "test_privileged"
assert len(runbook.privileged_actions) == 1
action = runbook.privileged_actions[0]
assert action.id == "wipe_state"
assert action.requires_explicit_unlock is True
assert action.phase == "4_and_above_only"
finally:
os.unlink(path)


def test_match_runbook_severity_filtering():
"""Test match_runbook correctly filters by min_severity."""
runbooks = [
Runbook(
id="rb_high_only",
triggers=[RunbookTrigger(alert_type="alert_a", min_severity="high")],
),
]

# Should match when severity is high or critical
assert match_runbook(runbooks, "alert_a", severity="high") is not None
assert match_runbook(runbooks, "alert_a", severity="critical") is not None

# Should NOT match when severity is low or medium
assert match_runbook(runbooks, "alert_a", severity="low") is None
assert match_runbook(runbooks, "alert_a", severity="medium") is None


def test_load_actual_consensus_desync_runbook():
"""Test that the actual consensus_desync.yaml loads without TypeError."""
# This test catches schema mismatches between the YAML and dataclasses
runbook_path = Path(__file__).parent.parent / "runbooks" / "consensus_desync.yaml"
assert runbook_path.exists(), f"Runbook file not found: {runbook_path}"

runbook = load_runbook(runbook_path)
assert runbook.id == "consensus_desync"
assert len(runbook.triggers) == 1
assert runbook.triggers[0].alert_type == "consensus_desync"
assert runbook.triggers[0].min_severity == "high"

# Verify diagnostics parse correctly (including the 'description' field)
assert len(runbook.diagnostics) == 4
assert runbook.diagnostics[0].id == "fetch_sync_status"
assert runbook.diagnostics[0].description == "Check consensus client sync status"

# Verify suggested actions
assert len(runbook.suggested_actions) == 1
assert runbook.suggested_actions[0].id == "restart_consensus_client"
assert runbook.suggested_actions[0].requires_approval is True

# Verify privileged actions
assert len(runbook.privileged_actions) == 1
assert runbook.privileged_actions[0].id == "restore_from_checkpoint"
assert runbook.privileged_actions[0].requires_explicit_unlock is True
assert runbook.privileged_actions[0].phase == "4_and_above_only"
Loading