apache-aiops 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ """Undo-token primitive — record the inverse of a write operation.
2
+
3
+ Reversibility is a 2026 agentic-ops requirement: an operator (or an external orchestrator)
4
+ needs to roll a change back to a known-good state. The inverse of an operation
5
+ is domain-specific (power-off ↔ power-on, create-snapshot ↔ delete-snapshot,
6
+ and some ops — like delete-vm — have NO safe inverse), so a tool *declares* its
7
+ undo via ``@governed_tool(undo=...)``.
8
+
9
+ This module only RECORDS the inverse descriptor (a replayable
10
+ ``{skill, tool, params}``) into ``~/.apache-aiops/undo.db`` and hands back an
11
+ ``undo_id``. It deliberately does NOT execute the inverse — coordinated,
12
+ multi-step rollback is an external orchestrator's job. Keeping recording and execution
13
+ separate matches the "separate the deterministic parts from the parts where the
14
+ LLM reasons" guardrail.
15
+
16
+ A tool's ``undo`` is a callable ``(params, result) -> dict | None``:
17
+
18
+ @governed_tool(risk_level="medium", undo=lambda p, r: {
19
+ "tool": "vm_power_on", "params": {"vm_name": p["vm_name"]}})
20
+ def vm_power_off(vm_name: str, target: str = "") -> dict: ...
21
+
22
+ Returning ``None`` means "no inverse for this call" (nothing is recorded).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import sqlite3
31
+ import threading
32
+ import uuid
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from apache_aiops.governance.paths import ops_path
38
+
39
+ _log = logging.getLogger("apache-aiops.undo")
40
+
41
+ _BUSY_TIMEOUT_MS = 5000
42
+
43
+ _CREATE_TABLE = """\
44
+ CREATE TABLE IF NOT EXISTS undo_log (
45
+ undo_id TEXT PRIMARY KEY,
46
+ ts TEXT NOT NULL,
47
+ skill TEXT NOT NULL DEFAULT '',
48
+ tool TEXT NOT NULL DEFAULT '',
49
+ undo_skill TEXT NOT NULL DEFAULT '',
50
+ undo_tool TEXT NOT NULL DEFAULT '',
51
+ undo_params TEXT NOT NULL DEFAULT '{}',
52
+ orig_params TEXT NOT NULL DEFAULT '{}',
53
+ status TEXT NOT NULL DEFAULT 'recorded',
54
+ workflow_id TEXT NOT NULL DEFAULT '',
55
+ note TEXT NOT NULL DEFAULT '',
56
+ effect_verified INTEGER NOT NULL DEFAULT 1
57
+ )
58
+ """
59
+
60
+ _CREATE_INDEX = "CREATE INDEX IF NOT EXISTS idx_undo_status ON undo_log(status)"
61
+
62
+ # Columns added after the first release. CREATE TABLE IF NOT EXISTS leaves an
63
+ # existing DB untouched, so each one needs an explicit ALTER. The default must
64
+ # describe the rows already there: every pre-existing token was recorded on the
65
+ # confirmed path, so effect_verified=1 is accurate for them, not just convenient.
66
+ _MIGRATIONS = (
67
+ (
68
+ "effect_verified",
69
+ "ALTER TABLE undo_log ADD COLUMN effect_verified INTEGER NOT NULL DEFAULT 1",
70
+ ),
71
+ )
72
+
73
+
74
+ class UndoStore:
75
+ """Append-and-update store for recorded undo descriptors (SQLite/WAL)."""
76
+
77
+ def __init__(self, db_path: Path | str | None = None) -> None:
78
+ self._path = Path(db_path).expanduser() if db_path else ops_path("undo.db")
79
+ self._ok = False
80
+ try:
81
+ self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
82
+ conn = self._connect()
83
+ conn.execute(_CREATE_TABLE)
84
+ conn.execute(_CREATE_INDEX)
85
+ _migrate(conn)
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.commit()
88
+ conn.close()
89
+ self._harden()
90
+ self._ok = True
91
+ except Exception:
92
+ _log.warning("Cannot initialize undo DB at %s", self._path, exc_info=True)
93
+
94
+ def _connect(self) -> sqlite3.Connection:
95
+ conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
96
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
97
+ return conn
98
+
99
+ def _harden(self) -> None:
100
+ try:
101
+ os.chmod(self._path.parent, 0o700)
102
+ except OSError:
103
+ pass
104
+ for suffix in ("", "-wal", "-shm"):
105
+ candidate = self._path.with_name(self._path.name + suffix)
106
+ try:
107
+ if candidate.exists():
108
+ os.chmod(candidate, 0o600)
109
+ except OSError:
110
+ pass
111
+
112
+ def record(
113
+ self,
114
+ *,
115
+ skill: str,
116
+ tool: str,
117
+ undo_descriptor: dict[str, Any],
118
+ orig_params: dict[str, Any] | None = None,
119
+ workflow_id: str = "",
120
+ effect_verified: bool = True,
121
+ ) -> str | None:
122
+ """Persist an inverse descriptor; return its undo_id (or None on failure).
123
+
124
+ ``undo_descriptor`` must carry at least ``tool`` (the inverse tool) and
125
+ ``params``; ``skill`` defaults to the original skill. Never raises —
126
+ recording must not break the underlying tool call.
127
+
128
+ ``effect_verified=False`` marks a token whose *original* write lost
129
+ its response: the change is probably live but unconfirmed. The token
130
+ stays listable and appliable — an unconfirmed change is exactly when
131
+ a rollback is most wanted — but the flag travels with it so an
132
+ operator is never told a state was restored that was never reached.
133
+ """
134
+ if not self._ok:
135
+ return None
136
+ if not isinstance(undo_descriptor, dict) or not undo_descriptor.get("tool"):
137
+ _log.debug("undo descriptor for %s.%s missing 'tool' — not recorded", skill, tool)
138
+ return None
139
+ undo_id = uuid.uuid4().hex[:16]
140
+ try:
141
+ conn = self._connect()
142
+ conn.execute(
143
+ "INSERT INTO undo_log (undo_id, ts, skill, tool, undo_skill, undo_tool, "
144
+ "undo_params, orig_params, status, workflow_id, note, "
145
+ "effect_verified) "
146
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'recorded', ?, ?, ?)",
147
+ (
148
+ undo_id,
149
+ datetime.now(tz=UTC).isoformat(),
150
+ skill,
151
+ tool,
152
+ str(undo_descriptor.get("skill", skill)),
153
+ str(undo_descriptor["tool"]),
154
+ _safe_json(undo_descriptor.get("params", {})),
155
+ _safe_json(orig_params or {}),
156
+ workflow_id,
157
+ str(undo_descriptor.get("note", "")),
158
+ 1 if effect_verified else 0,
159
+ ),
160
+ )
161
+ conn.commit()
162
+ conn.close()
163
+ return undo_id
164
+ except Exception:
165
+ _log.warning("Failed to record undo for %s.%s", skill, tool, exc_info=True)
166
+ return None
167
+
168
+ def get(self, undo_id: str) -> dict[str, Any] | None:
169
+ if not self._ok:
170
+ return None
171
+ conn = self._connect()
172
+ try:
173
+ conn.row_factory = sqlite3.Row
174
+ row = conn.execute("SELECT * FROM undo_log WHERE undo_id = ?", (undo_id,)).fetchone()
175
+ return dict(row) if row else None
176
+ finally:
177
+ conn.close()
178
+
179
+ def list(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
180
+ if not self._ok:
181
+ return []
182
+ conn = self._connect()
183
+ try:
184
+ conn.row_factory = sqlite3.Row
185
+ if status:
186
+ rows = conn.execute(
187
+ "SELECT * FROM undo_log WHERE status = ? ORDER BY ts DESC LIMIT ?",
188
+ (status, limit),
189
+ ).fetchall()
190
+ else:
191
+ rows = conn.execute(
192
+ "SELECT * FROM undo_log ORDER BY ts DESC LIMIT ?", (limit,)
193
+ ).fetchall()
194
+ return [dict(r) for r in rows]
195
+ finally:
196
+ conn.close()
197
+
198
+ def mark(self, undo_id: str, status: str) -> bool:
199
+ """Update an undo record's status (e.g. 'applied' / 'expired')."""
200
+ if not self._ok:
201
+ return False
202
+ conn = self._connect()
203
+ try:
204
+ cur = conn.execute(
205
+ "UPDATE undo_log SET status = ? WHERE undo_id = ?", (status, undo_id)
206
+ )
207
+ conn.commit()
208
+ return cur.rowcount > 0
209
+ finally:
210
+ conn.close()
211
+
212
+
213
+ def _migrate(conn: sqlite3.Connection) -> None:
214
+ """Apply additive schema migrations to an already-created undo_log.
215
+
216
+ Idempotent and best-effort: a store that cannot be migrated still records
217
+ and lists tokens, which matters more than any single column.
218
+ """
219
+ try:
220
+ existing = {row[1] for row in conn.execute("PRAGMA table_info(undo_log)")}
221
+ except sqlite3.Error:
222
+ return
223
+ for column, statement in _MIGRATIONS:
224
+ if column in existing:
225
+ continue
226
+ try:
227
+ conn.execute(statement)
228
+ except sqlite3.Error:
229
+ _log.warning("undo schema migration for '%s' failed", column, exc_info=True)
230
+
231
+
232
+ def _safe_json(obj: Any) -> str:
233
+ try:
234
+ return json.dumps(obj, ensure_ascii=False, default=str)
235
+ except Exception:
236
+ return json.dumps({"_raw": str(obj)})
237
+
238
+
239
+ # ── Singleton ──────────────────────────────────────────────────────────
240
+
241
+ _store: UndoStore | None = None
242
+ _store_lock = threading.Lock()
243
+
244
+
245
+ def get_undo_store(db_path: Path | str | None = None) -> UndoStore:
246
+ """Return the global UndoStore singleton (lazy, lock-guarded)."""
247
+ global _store
248
+ if _store is None:
249
+ with _store_lock:
250
+ if _store is None:
251
+ _store = UndoStore(db_path)
252
+ return _store
253
+
254
+
255
+ def reset_undo_store() -> None:
256
+ """Reset the singleton. Tests use this between cases."""
257
+ global _store
258
+ with _store_lock:
259
+ _store = None
@@ -0,0 +1 @@
1
+ """MCP server package for apache-aiops."""
@@ -0,0 +1,66 @@
1
+ """Derive MCP tool annotations from the governance risk level.
2
+
3
+ Every tool already declares ``risk_level`` on ``@governed_tool``; this module
4
+ publishes it to MCP clients as the standard ``ToolAnnotations`` hints plus the
5
+ exact level under ``_meta``, so a client can gate approval without reading our
6
+ docstrings.
7
+
8
+ Only the safe direction is ever asserted:
9
+
10
+ - ``low`` → ``readOnlyHint: true``. Backed by the smoke-test invariant that
11
+ ``risk_level == "low"`` exactly when the docstring says ``[READ]``.
12
+ - anything else → ``readOnlyHint: false, destructiveHint: true``. The
13
+ ``medium`` tier is NOT non-destructive — it holds deletes, hard power-offs,
14
+ state kills and stat resets — so claiming ``destructiveHint: false`` from the
15
+ tier would tell a client it may skip approval for a delete. Over-warning
16
+ costs a confirmation prompt; under-warning costs data.
17
+ - ``idempotentHint`` only echoes an explicit ``@governed_tool(idempotent=True)``
18
+ declaration; it is never inferred.
19
+ - ``openWorldHint`` is left unset (spec default ``true``): every tool talks to
20
+ an external system.
21
+
22
+ This file is identical across the AIops line; keep it that way.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from mcp.types import ToolAnnotations
28
+
29
+ RISK_META_KEY = "io.github.AIops-tools/riskLevel"
30
+ RISK_LEVELS = frozenset({"low", "medium", "high", "critical"})
31
+
32
+
33
+ def annotations_for(risk_level: str, idempotent: bool) -> ToolAnnotations:
34
+ """Return the MCP hints that a tool of ``risk_level`` may honestly claim."""
35
+ if risk_level not in RISK_LEVELS:
36
+ raise ValueError(
37
+ f"unknown risk_level {risk_level!r}; expected one of {sorted(RISK_LEVELS)}"
38
+ )
39
+ if risk_level == "low":
40
+ return ToolAnnotations(read_only_hint=True)
41
+ return ToolAnnotations(
42
+ read_only_hint=False,
43
+ destructive_hint=True,
44
+ idempotent_hint=idempotent,
45
+ )
46
+
47
+
48
+ def apply_risk_annotations(server: Any) -> None:
49
+ """Replace every registered tool with a copy carrying its risk annotations.
50
+
51
+ Fails fast on a tool without ``@governed_tool`` metadata: an unannotated
52
+ tool would be advertised with the spec defaults, i.e. as a destructive
53
+ write with no stated risk level — wrong for a read, silent for a write.
54
+ """
55
+ tools = server._tool_manager._tools # same private registry undo.py relies on
56
+ for name, tool in list(tools.items()):
57
+ risk_level = getattr(tool.fn, "_risk_level", None)
58
+ if risk_level is None:
59
+ raise RuntimeError(f"tool {name!r} has no @governed_tool risk_level")
60
+ idempotent = bool(getattr(tool.fn, "_idempotent", False))
61
+ tools[name] = tool.model_copy(
62
+ update={
63
+ "annotations": annotations_for(risk_level, idempotent),
64
+ "meta": {**(tool.meta or {}), RISK_META_KEY: risk_level},
65
+ }
66
+ )
@@ -0,0 +1,67 @@
1
+ """Shared Apache MCP server and sanitized errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import threading
8
+ from collections.abc import Callable
9
+ from typing import Any, Optional
10
+
11
+ from mcp.server import MCPServer
12
+
13
+ from apache_aiops import __version__
14
+ from apache_aiops.connection import ApacheCommandError, ApacheWriteUncertainError, ConnectionManager
15
+ from apache_aiops.governance import mark_unknown, sanitize
16
+
17
+ logger = logging.getLogger(__name__)
18
+ _ERROR_MAX = 800
19
+
20
+
21
+ def tool_errors(shape: str = "dict") -> Callable:
22
+ def decorator(func: Callable) -> Callable:
23
+ @functools.wraps(func)
24
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
25
+ try:
26
+ return func(*args, **kwargs)
27
+ except Exception as exc:
28
+ logger.error("Tool %s failed", func.__name__, exc_info=True)
29
+ message = (
30
+ sanitize(str(exc), _ERROR_MAX)
31
+ if isinstance(
32
+ exc, (ApacheCommandError, ValueError, KeyError, FileNotFoundError, OSError)
33
+ )
34
+ else f"{type(exc).__name__}: operation failed."
35
+ )
36
+ payload = {
37
+ "error": message,
38
+ "hint": "Run 'apache-aiops doctor' and check local command permissions.",
39
+ }
40
+ if isinstance(exc, ApacheWriteUncertainError):
41
+ return mark_unknown(payload)
42
+ return payload
43
+
44
+ return wrapper
45
+
46
+ return decorator
47
+
48
+
49
+ mcp = MCPServer(
50
+ "apache-aiops",
51
+ version=__version__,
52
+ instructions=(
53
+ "Governed Apache HTTP Server config, vhost, module and HTTP diagnosis. "
54
+ "Every tool is audited and budgeted."
55
+ ),
56
+ )
57
+ _manager: Optional[ConnectionManager] = None
58
+ _lock = threading.Lock()
59
+
60
+
61
+ def _get_connection(target: Optional[str] = None) -> Any:
62
+ global _manager
63
+ with _lock:
64
+ if _manager is None:
65
+ _manager = ConnectionManager()
66
+ manager = _manager
67
+ return manager.connect(target)
@@ -0,0 +1,14 @@
1
+ """Apache AIops MCP entry point (stdio)."""
2
+
3
+ import logging
4
+
5
+ from apache_aiops.mcp_server._annotations import apply_risk_annotations
6
+ from apache_aiops.mcp_server._shared import mcp
7
+ from apache_aiops.mcp_server.tools import httpd, undo # noqa: F401 — registration
8
+
9
+ apply_risk_annotations(mcp)
10
+
11
+
12
+ def main() -> None:
13
+ logging.basicConfig(level=logging.INFO)
14
+ mcp.run(transport="stdio")
@@ -0,0 +1 @@
1
+ """MCP tool modules. Importing each registers its @mcp.tool() functions."""
@@ -0,0 +1,143 @@
1
+ """Governed Apache HTTP Server read tools."""
2
+
3
+ from typing import Optional
4
+
5
+ from apache_aiops.governance import governed_tool, mark_unknown
6
+ from apache_aiops.mcp_server._shared import _get_connection, mcp, tool_errors
7
+ from apache_aiops.ops import httpd
8
+
9
+
10
+ @mcp.tool()
11
+ @governed_tool(risk_level="low")
12
+ @tool_errors()
13
+ def apache_config_includes(target: Optional[str] = None) -> dict:
14
+ """[READ] Show Apache's parsed config include tree with bounded output."""
15
+ return httpd.config_includes(_get_connection(target))
16
+
17
+
18
+ @mcp.tool()
19
+ @governed_tool(risk_level="low")
20
+ @tool_errors()
21
+ def apache_compiled_modules(target: Optional[str] = None) -> dict:
22
+ """[READ] List modules compiled into Apache, excluding dynamic modules."""
23
+ return httpd.compiled_modules(_get_connection(target))
24
+
25
+
26
+ @mcp.tool()
27
+ @governed_tool(risk_level="low")
28
+ @tool_errors()
29
+ def apache_static_directives(target: Optional[str] = None) -> dict:
30
+ """[READ] List directives from Apache's static modules with bounded output."""
31
+ return httpd.static_directives(_get_connection(target))
32
+
33
+
34
+ @mcp.tool()
35
+ @governed_tool(risk_level="low")
36
+ @tool_errors()
37
+ def apache_build(target: Optional[str] = None) -> dict:
38
+ """[READ] Show Apache build options and compiled paths."""
39
+ return httpd.build(_get_connection(target))
40
+
41
+
42
+ @mcp.tool()
43
+ @governed_tool(risk_level="low")
44
+ @tool_errors()
45
+ def apache_runtime_config(target: Optional[str] = None) -> dict:
46
+ """[READ] Dump Apache's parsed runtime configuration."""
47
+ return httpd.runtime_config(_get_connection(target))
48
+
49
+
50
+ @mcp.tool()
51
+ @governed_tool(risk_level="low")
52
+ @tool_errors()
53
+ def apache_server_status(target: Optional[str] = None) -> dict:
54
+ """[READ] Get bounded machine-readable mod_status metrics from configured URL."""
55
+ return httpd.server_status(_get_connection(target))
56
+
57
+
58
+ @mcp.tool()
59
+ @governed_tool(risk_level="low")
60
+ @tool_errors()
61
+ def apache_error_log(limit: int = 50, target: Optional[str] = None) -> dict:
62
+ """[READ] Tail an operator-configured Apache error log, with explicit truncation."""
63
+ return httpd.error_log(_get_connection(target), limit)
64
+
65
+
66
+ @mcp.tool()
67
+ @governed_tool(risk_level="low")
68
+ @tool_errors()
69
+ def apache_access_status(limit: int = 200, target: Optional[str] = None) -> dict:
70
+ """[READ] Count HTTP statuses in recent common/combined access logs, without raw requests."""
71
+ return httpd.access_status(_get_connection(target), limit)
72
+
73
+
74
+ @mcp.tool()
75
+ @governed_tool(risk_level="medium")
76
+ @tool_errors()
77
+ def apache_graceful_reload(dry_run: bool = False, target: Optional[str] = None) -> dict:
78
+ """[WRITE] Config-check then request a graceful reload; outcome needs operator check."""
79
+ conn = _get_connection(target)
80
+ check = httpd.config_check(conn)
81
+ before = httpd.probe(conn) if conn.target.probe_url else None
82
+ if dry_run:
83
+ return {"dryRun": True, "configCheck": check, "probeBefore": before,
84
+ "wouldRun": "apachectl graceful"}
85
+ response = conn.graceful()
86
+ try:
87
+ after = httpd.probe(conn) if conn.target.probe_url else None
88
+ except Exception as exc:
89
+ return mark_unknown({"accepted": True, "response": response, "probeAfter": None,
90
+ "probeError": f"Probe failed after reload: {type(exc).__name__}"})
91
+ return mark_unknown({
92
+ "accepted": True, "response": response, "probeBefore": before,
93
+ "probeAfter": after,
94
+ "note": "A successful command/probe does not prove all workers loaded new config.",
95
+ })
96
+
97
+
98
+ @mcp.tool()
99
+ @governed_tool(risk_level="low")
100
+ @tool_errors()
101
+ def apache_version(target: Optional[str] = None) -> dict:
102
+ """[READ] Get Apache HTTP Server version from apachectl -v."""
103
+ return httpd.version(_get_connection(target))
104
+
105
+
106
+ @mcp.tool()
107
+ @governed_tool(risk_level="low")
108
+ @tool_errors()
109
+ def apache_config_check(target: Optional[str] = None) -> dict:
110
+ """[READ] Run Apache's native config syntax test."""
111
+ return httpd.config_check(_get_connection(target))
112
+
113
+
114
+ @mcp.tool()
115
+ @governed_tool(risk_level="low")
116
+ @tool_errors()
117
+ def apache_vhosts(target: Optional[str] = None) -> dict:
118
+ """[READ] Show Apache's parsed virtual-host mapping."""
119
+ return httpd.vhosts(_get_connection(target))
120
+
121
+
122
+ @mcp.tool()
123
+ @governed_tool(risk_level="low")
124
+ @tool_errors()
125
+ def apache_modules(target: Optional[str] = None) -> dict:
126
+ """[READ] List Apache's loaded modules."""
127
+ return httpd.modules(_get_connection(target))
128
+
129
+
130
+ @mcp.tool()
131
+ @governed_tool(risk_level="low")
132
+ @tool_errors()
133
+ def apache_probe(target: Optional[str] = None) -> dict:
134
+ """[READ] GET the configured health URL without downloading its body."""
135
+ return httpd.probe(_get_connection(target))
136
+
137
+
138
+ @mcp.tool()
139
+ @governed_tool(risk_level="low")
140
+ @tool_errors()
141
+ def apache_diagnose(target: Optional[str] = None) -> dict:
142
+ """[READ] Correlate version, config, vhosts, modules and optional HTTP probe."""
143
+ return httpd.diagnose(_get_connection(target))