postgres-aiops 0.1.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.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,378 @@
1
+ """L5 auto-remediation pattern engine.
2
+
3
+ Implements the pattern matcher described in
4
+ ``docs/auto-remediation-patterns.md``. Patterns are loaded from
5
+ ``~/.postgres-aiops/auto-remediation-patterns/*.yaml`` (hot-reload on mtime change)
6
+ and consulted by the ``@governed_tool`` decorator on every call.
7
+
8
+ Scope (PoC):
9
+ - Pattern loading and signature validation
10
+ - Action matching (is the current tool+params an armed pattern's action?)
11
+ - Rate limiting (per-pattern, per-target)
12
+ - Circuit breaker (3 consecutive validation failures → 24h disable)
13
+ - In-memory state — survives the MCP server process lifetime, NOT restarts
14
+
15
+ Out of scope (future work — separate worker / daemon):
16
+ - Trigger matching against historical audit events
17
+ - Automatic action execution
18
+ - Validation post-step
19
+ - Persistent rate-limit / circuit-breaker state across restarts
20
+ - Approval channel for human signing
21
+
22
+ Failure modes are deliberately fail-open: if pattern loading or matching
23
+ errors out, the decorator falls through to normal behavior with a warning.
24
+ A broken pattern file must not block all MCP tool calls.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import threading
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from postgres_aiops.governance.paths import ops_path
38
+
39
+ _log = logging.getLogger("postgres-aiops.patterns")
40
+
41
+ _REQUIRED_TOP_LEVEL_KEYS = ("schema_version", "pattern_id", "classification", "action")
42
+ _REQUIRED_CLASSIFICATION_KEYS = ("risk", "reversible", "repeatable")
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Pattern:
47
+ """A loaded, signature-validated auto-remediation pattern."""
48
+
49
+ pattern_id: str
50
+ skill: str # action.skill
51
+ tool: str # action.tool
52
+ risk: str # classification.risk — must be "low" to be armable
53
+ reversible: bool
54
+ repeatable: bool
55
+ expires_at: str # ISO date or "" if never
56
+ rate_max_per_hour_per_target: int
57
+ rate_max_per_day_per_target: int
58
+ circuit_threshold: int # consecutive failures → disable
59
+ circuit_disable_seconds: int
60
+ raw: dict[str, Any] = field(default_factory=dict)
61
+
62
+ @property
63
+ def is_armable(self) -> bool:
64
+ """The hard preconditions per the design doc."""
65
+ return (
66
+ self.risk == "low"
67
+ and self.reversible is True
68
+ and self.repeatable is True
69
+ and not self.is_expired
70
+ )
71
+
72
+ @property
73
+ def is_expired(self) -> bool:
74
+ if not self.expires_at:
75
+ return False
76
+ try:
77
+ exp = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
78
+ if exp.tzinfo is None:
79
+ exp = exp.replace(tzinfo=UTC)
80
+ return datetime.now(tz=UTC) > exp
81
+ except (ValueError, TypeError):
82
+ _log.warning("pattern %s has malformed expires_at=%r — treating as expired",
83
+ self.pattern_id, self.expires_at)
84
+ return True
85
+
86
+
87
+ @dataclass
88
+ class PatternMatch:
89
+ """Result of consulting the pattern engine for a tool call."""
90
+
91
+ pattern: Pattern
92
+ armed: bool # True if rate limit + circuit breaker permit firing
93
+ reason: str = "" # Human-readable explanation (esp. when not armed)
94
+
95
+
96
+ @dataclass
97
+ class _Counters:
98
+ """Per-(pattern_id, target) sliding-window state."""
99
+
100
+ arm_timestamps: list[float] = field(default_factory=list) # epoch seconds
101
+ consecutive_failures: int = 0
102
+ disabled_until: float = 0.0 # epoch seconds
103
+
104
+
105
+ class PatternEngine:
106
+ """Singleton pattern matcher integrated with @governed_tool."""
107
+
108
+ def __init__(self, patterns_dir: Path | str | None = None) -> None:
109
+ self._dir = (
110
+ Path(patterns_dir).expanduser() if patterns_dir
111
+ else ops_path("auto-remediation-patterns")
112
+ )
113
+ self._patterns: dict[str, Pattern] = {}
114
+ self._mtimes: dict[Path, float] = {}
115
+ # _counters keyed by (pattern_id, target) — target may be ""
116
+ self._counters: dict[tuple[str, str], _Counters] = {}
117
+ self._lock = threading.Lock()
118
+ self._load()
119
+
120
+ # ── Pattern loading ───────────────────────────────────────────────
121
+
122
+ def _load(self) -> None:
123
+ """Load all *.yaml files from the patterns directory.
124
+
125
+ Bad files are skipped with a warning; good files replace prior state.
126
+ """
127
+ if not self._dir.exists():
128
+ self._patterns = {}
129
+ self._mtimes = {}
130
+ return
131
+
132
+ try:
133
+ import yaml
134
+ except ImportError:
135
+ _log.warning("PyYAML not installed — pattern engine disabled")
136
+ self._patterns = {}
137
+ return
138
+
139
+ new_patterns: dict[str, Pattern] = {}
140
+ new_mtimes: dict[Path, float] = {}
141
+ for path in sorted(self._dir.glob("*.yaml")):
142
+ try:
143
+ new_mtimes[path] = path.stat().st_mtime
144
+ with open(path) as fh:
145
+ raw = yaml.safe_load(fh) or {}
146
+ pat = self._validate(raw, path)
147
+ if pat is not None:
148
+ if pat.pattern_id in new_patterns:
149
+ _log.warning("duplicate pattern_id %r in %s — keeping first",
150
+ pat.pattern_id, path)
151
+ continue
152
+ new_patterns[pat.pattern_id] = pat
153
+ except Exception:
154
+ _log.warning("failed to load pattern %s", path, exc_info=True)
155
+
156
+ self._patterns = new_patterns
157
+ self._mtimes = new_mtimes
158
+
159
+ def _maybe_reload(self) -> None:
160
+ """Reload if any file's mtime changed or new files appeared."""
161
+ if not self._dir.exists():
162
+ if self._patterns:
163
+ _log.warning("patterns dir deleted — clearing %d patterns", len(self._patterns))
164
+ self._patterns = {}
165
+ self._mtimes = {}
166
+ return
167
+ try:
168
+ current_files = set(self._dir.glob("*.yaml"))
169
+ tracked_files = set(self._mtimes.keys())
170
+ if current_files != tracked_files:
171
+ self._load()
172
+ return
173
+ for path in current_files:
174
+ if path.stat().st_mtime != self._mtimes.get(path, 0):
175
+ self._load()
176
+ return
177
+ except Exception:
178
+ _log.warning("failed to check patterns dir", exc_info=True)
179
+
180
+ @staticmethod
181
+ def _validate(raw: dict[str, Any], path: Path) -> Pattern | None:
182
+ """Validate signature + return a Pattern, or None if invalid."""
183
+ for k in _REQUIRED_TOP_LEVEL_KEYS:
184
+ if k not in raw:
185
+ _log.warning("pattern %s missing required key %r — skipped", path, k)
186
+ return None
187
+
188
+ if raw.get("schema_version") != 1:
189
+ _log.warning("pattern %s has schema_version=%r (expected 1) — skipped",
190
+ path, raw.get("schema_version"))
191
+ return None
192
+
193
+ cls_block = raw.get("classification", {})
194
+ for k in _REQUIRED_CLASSIFICATION_KEYS:
195
+ if k not in cls_block:
196
+ _log.warning("pattern %s missing classification.%s — skipped", path, k)
197
+ return None
198
+
199
+ action = raw.get("action", {})
200
+ skill = action.get("skill")
201
+ tool = action.get("tool")
202
+ if not skill or not tool:
203
+ _log.warning("pattern %s missing action.skill or action.tool — skipped", path)
204
+ return None
205
+
206
+ # Patterns with status != approved are NOT armable. We still load them
207
+ # so callers can introspect, but is_armable returns False.
208
+ approval_status = (raw.get("approval", {}) or {}).get("status", "")
209
+ # Effectively makes risk=high if not approved — but cleaner to keep the
210
+ # explicit approval gate. We honor the YAML's classification.risk and let
211
+ # the approval status influence armability via signed_by check below.
212
+ signed_by = (raw.get("approval", {}) or {}).get("signed_by") or ""
213
+
214
+ rate = raw.get("rate_limit", {}) or {}
215
+ cb = raw.get("circuit_breaker", {}) or {}
216
+
217
+ # Force unsigned patterns to be non-armable by setting risk to a
218
+ # non-"low" sentinel. Loading still succeeds for inspection.
219
+ effective_risk = cls_block.get("risk", "")
220
+ # Both gates required: signed AND approved. A signed-but-rejected pattern
221
+ # must NOT be armable (yjs review 2026-05-06 — previously the OR-style
222
+ # check let signed-rejected slip through with original risk).
223
+ if not signed_by or approval_status != "approved":
224
+ effective_risk = "unsigned"
225
+
226
+ return Pattern(
227
+ pattern_id=str(raw.get("pattern_id")),
228
+ skill=str(skill),
229
+ tool=str(tool),
230
+ risk=str(effective_risk),
231
+ reversible=bool(cls_block.get("reversible", False)),
232
+ repeatable=bool(cls_block.get("repeatable", False)),
233
+ expires_at=str(raw.get("expires_at") or ""),
234
+ rate_max_per_hour_per_target=int(
235
+ rate.get("max_per_hour", rate.get("max_per_hour_per_host", 0)) or 0
236
+ ),
237
+ rate_max_per_day_per_target=int(
238
+ rate.get("max_per_day", rate.get("max_per_day_per_cluster", 0)) or 0
239
+ ),
240
+ circuit_threshold=int(cb.get("consecutive_validation_failures", 3) or 3),
241
+ circuit_disable_seconds=int(cb.get("disable_seconds", 86400) or 86400),
242
+ raw=raw,
243
+ )
244
+
245
+ # ── Public matching API ───────────────────────────────────────────
246
+
247
+ def match(self, skill: str, tool: str, target: str) -> PatternMatch | None:
248
+ """Return a PatternMatch if any loaded pattern's action matches.
249
+
250
+ Returns None if no pattern's action matches this (skill, tool).
251
+
252
+ When a pattern matches, the result indicates whether it is currently
253
+ armed — i.e., whether rate limit allows it AND circuit breaker is
254
+ not tripped. The decorator can use armed=True as a hint to skip
255
+ double-confirm; armed=False means the pattern exists but is rate-limited
256
+ or in cooldown.
257
+ """
258
+ self._maybe_reload()
259
+
260
+ # Collect every pattern whose action matches, then prefer the first
261
+ # ARMABLE one. Previously the first (skill, tool) match won even when
262
+ # not armable, shadowing a later armable pattern.
263
+ candidates = [
264
+ p for p in self._patterns.values()
265
+ if p.skill == skill and p.tool == tool
266
+ ]
267
+ if not candidates:
268
+ return None
269
+
270
+ armable = [p for p in candidates if p.is_armable]
271
+ if not armable:
272
+ pat = candidates[0]
273
+ return PatternMatch(pattern=pat, armed=False,
274
+ reason=f"pattern {pat.pattern_id} is not armable "
275
+ f"(risk={pat.risk}, expired={pat.is_expired})")
276
+
277
+ for pat in armable:
278
+ with self._lock:
279
+ key = (pat.pattern_id, target or "")
280
+ ctr = self._counters.setdefault(key, _Counters())
281
+
282
+ # Circuit breaker check
283
+ now = time.time()
284
+ if now < ctr.disabled_until:
285
+ remaining = int(ctr.disabled_until - now)
286
+ return PatternMatch(
287
+ pattern=pat, armed=False,
288
+ reason=f"pattern {pat.pattern_id} circuit-broken for "
289
+ f"~{remaining}s after {ctr.consecutive_failures} failures",
290
+ )
291
+
292
+ # Rate limit check (sliding 1h and 24h windows)
293
+ hour_ago = now - 3600
294
+ day_ago = now - 86400
295
+ ctr.arm_timestamps = [t for t in ctr.arm_timestamps if t > day_ago]
296
+ arms_last_hour = sum(1 for t in ctr.arm_timestamps if t > hour_ago)
297
+ arms_last_day = len(ctr.arm_timestamps)
298
+
299
+ if pat.rate_max_per_hour_per_target and arms_last_hour >= pat.rate_max_per_hour_per_target:
300
+ return PatternMatch(
301
+ pattern=pat, armed=False,
302
+ reason=f"pattern {pat.pattern_id} hit hourly cap "
303
+ f"({pat.rate_max_per_hour_per_target}/h) on target {target!r}",
304
+ )
305
+ if pat.rate_max_per_day_per_target and arms_last_day >= pat.rate_max_per_day_per_target:
306
+ return PatternMatch(
307
+ pattern=pat, armed=False,
308
+ reason=f"pattern {pat.pattern_id} hit daily cap "
309
+ f"({pat.rate_max_per_day_per_target}/d) on target {target!r}",
310
+ )
311
+
312
+ # Pattern is armed — record the arming timestamp
313
+ ctr.arm_timestamps.append(now)
314
+
315
+ return PatternMatch(pattern=pat, armed=True,
316
+ reason=f"pattern {pat.pattern_id} armed")
317
+
318
+ return None
319
+
320
+ def report_outcome(self, pattern_id: str, target: str, success: bool) -> None:
321
+ """Update circuit-breaker state after an armed pattern's action ran.
322
+
323
+ Called by the decorator's finally block once the underlying tool
324
+ function returns or raises. Successful runs reset the failure
325
+ counter; failures increment it and may trip the breaker.
326
+ """
327
+ with self._lock:
328
+ key = (pattern_id, target or "")
329
+ ctr = self._counters.setdefault(key, _Counters())
330
+ pat = self._patterns.get(pattern_id)
331
+ threshold = pat.circuit_threshold if pat else 3
332
+ disable_for = pat.circuit_disable_seconds if pat else 86400
333
+
334
+ if success:
335
+ ctr.consecutive_failures = 0
336
+ return
337
+
338
+ ctr.consecutive_failures += 1
339
+ if ctr.consecutive_failures >= threshold:
340
+ ctr.disabled_until = time.time() + disable_for
341
+ _log.warning(
342
+ "pattern %s circuit-broken on target %r after %d failures — "
343
+ "disabled for %ds",
344
+ pattern_id, target, ctr.consecutive_failures, disable_for,
345
+ )
346
+
347
+ # ── Introspection / testing helpers ──────────────────────────────
348
+
349
+ def loaded_patterns(self) -> list[Pattern]:
350
+ """Return a snapshot of currently loaded patterns."""
351
+ return list(self._patterns.values())
352
+
353
+ def reset_state(self) -> None:
354
+ """Clear in-memory counters. Used by tests; not for production paths."""
355
+ with self._lock:
356
+ self._counters = {}
357
+
358
+
359
+ # ── Singleton ─────────────────────────────────────────────────────────
360
+
361
+ _engine: PatternEngine | None = None
362
+ _engine_lock = threading.Lock()
363
+
364
+
365
+ def get_pattern_engine(patterns_dir: Path | str | None = None) -> PatternEngine:
366
+ """Return the global PatternEngine singleton."""
367
+ global _engine
368
+ if _engine is None:
369
+ with _engine_lock:
370
+ if _engine is None:
371
+ _engine = PatternEngine(patterns_dir)
372
+ return _engine
373
+
374
+
375
+ def reset_pattern_engine() -> None:
376
+ """Reset the singleton. Tests use this to swap directories cleanly."""
377
+ global _engine
378
+ _engine = None