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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +101 -0
- mcp_server/server.py +37 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/activity.py +52 -0
- mcp_server/tools/analysis.py +89 -0
- mcp_server/tools/indexes.py +65 -0
- mcp_server/tools/queries.py +60 -0
- mcp_server/tools/remediation.py +281 -0
- mcp_server/tools/replication.py +43 -0
- mcp_server/tools/server.py +86 -0
- mcp_server/tools/tables.py +46 -0
- postgres_aiops/__init__.py +9 -0
- postgres_aiops/cli/__init__.py +9 -0
- postgres_aiops/cli/_common.py +78 -0
- postgres_aiops/cli/_root.py +68 -0
- postgres_aiops/cli/activity.py +52 -0
- postgres_aiops/cli/analyze.py +53 -0
- postgres_aiops/cli/doctor.py +21 -0
- postgres_aiops/cli/index.py +55 -0
- postgres_aiops/cli/init.py +112 -0
- postgres_aiops/cli/overview.py +16 -0
- postgres_aiops/cli/query.py +70 -0
- postgres_aiops/cli/remediate.py +186 -0
- postgres_aiops/cli/replication.py +45 -0
- postgres_aiops/cli/secret.py +103 -0
- postgres_aiops/cli/server.py +69 -0
- postgres_aiops/cli/table.py +45 -0
- postgres_aiops/config.py +154 -0
- postgres_aiops/connection.py +178 -0
- postgres_aiops/doctor.py +84 -0
- postgres_aiops/governance/__init__.py +40 -0
- postgres_aiops/governance/audit.py +377 -0
- postgres_aiops/governance/budget.py +225 -0
- postgres_aiops/governance/decorators.py +474 -0
- postgres_aiops/governance/paths.py +23 -0
- postgres_aiops/governance/patterns.py +378 -0
- postgres_aiops/governance/policy.py +411 -0
- postgres_aiops/governance/sanitize.py +39 -0
- postgres_aiops/governance/undo.py +218 -0
- postgres_aiops/ops/__init__.py +1 -0
- postgres_aiops/ops/_util.py +102 -0
- postgres_aiops/ops/activity.py +193 -0
- postgres_aiops/ops/analysis.py +263 -0
- postgres_aiops/ops/indexes.py +211 -0
- postgres_aiops/ops/overview.py +51 -0
- postgres_aiops/ops/queries.py +123 -0
- postgres_aiops/ops/remediation.py +237 -0
- postgres_aiops/ops/replication.py +144 -0
- postgres_aiops/ops/server.py +151 -0
- postgres_aiops/ops/tables.py +146 -0
- postgres_aiops/secretstore.py +302 -0
- postgres_aiops-0.1.0.dist-info/METADATA +119 -0
- postgres_aiops-0.1.0.dist-info/RECORD +57 -0
- postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
- postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|