postgres-aiops 0.7.0__tar.gz → 0.9.0__tar.gz
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.
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/CHANGELOG.md +16 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/PKG-INFO +1 -1
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/docs/VERIFICATION.md +16 -2
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/remediation.py +33 -9
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/undo.py +30 -2
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/_common.py +47 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/query.py +2 -1
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/remediate.py +25 -15
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/undo.py +2 -1
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/remediation.py +39 -6
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/pyproject.toml +1 -1
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/server.json +2 -2
- postgres_aiops-0.9.0/tests/test_cli_governed_exit.py +167 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_mcp_tools.py +35 -4
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_undo_executor.py +49 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/uv.lock +1 -1
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.gitignore +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/Dockerfile +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/LICENSE +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/README.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/SECURITY.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/glama.json +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/server.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/decorators.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/outcome.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/SKILL.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/smithery.yaml +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_remediate.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection_lost.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_decorators.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_governance_persistence.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_optional_fields.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_self_lockout.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_smoke.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_writes.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.9.0 — 2026-08-10
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **An undetermined outcome no longer exits as a plain failure.** A write whose response was lost carries *both* `error` and `outcomeUnknown`, and the harness deliberately judges unknown first when writing the audit row — the change may have taken effect, so a blind retry could apply it twice. The CLI guard judged `error` first, so the audit said "may have taken effect" while the exit status told a script it had not happened. The two layers now agree (exit 2, not 1), and a test pins the ordering so it cannot silently flip back.
|
|
7
|
+
- **The CLI reported a refused or failed governed write as a success.** 10 write call sites printed the governed twin's payload and exited **0** whatever it said — and `@tool_errors` flattens every refusal, guard rejection and upstream failure into `{"error": ...}` rather than raising, so nothing downstream of a `&&` chain or a CI step could tell a blocked write from a landed one. The dry-run path already exited non-zero, which made the asymmetry worse: the preview was stricter than the write it previews. Results now route through a `checked()` helper — exit 1 on an error payload, exit 2 on an undetermined outcome, unchanged on success. This defect class had been fixed repo-by-repo several times and kept coming back; an audit across the whole line found it live in **18 of the 24 tools at once (87 call sites)**, so each tool now carries an invariant test that fails if any future CLI command prints a governed result without checking it.
|
|
8
|
+
|
|
9
|
+
## v0.8.0 — 2026-08-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **`undo apply` replays against the target the original write ran on.** It dispatched the inverse against whatever target the *caller* named — in practice the config's first entry — while the write's own target sat unused in the undo record. On a multi-target config the inverse therefore ran against the wrong host; it only looks harmless because the resource usually is not there, but two hosts holding the same name and the inverse **succeeds on the wrong one, silently**. An explicitly named target still wins. Line-wide: all 24 copies had the identical defect. Caught live in container-host-aiops, where a stop recorded against a Podman target replayed against a Portainer one.
|
|
13
|
+
- **Undoing an `ALTER SYSTEM SET` no longer leaves the parameter pinned.** The inverse wrote the prior *value* back with another `ALTER SYSTEM SET`, so a parameter that had **no** `postgresql.auto.conf` entry beforehand ended up with one. The number was right, but `postgresql.auto.conf` overrides `postgresql.conf`, so the "undo" silently shadowed the operator's config file from then on — a later edit there would have no effect. `update_setting` now records where the value came from (`pg_settings.sourcefile`), and the inverse is `ALTER SYSTEM RESET` unless the parameter was already carried by auto.conf. Live-verified on PostgreSQL 16.14 in both directions, checking `postgresql.auto.conf` itself after each undo.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `update_setting(reset=True)` / `remediate set <name> --reset` — `ALTER SYSTEM RESET` a parameter, dropping its `postgresql.auto.conf` entry so `postgresql.conf` or the built-in default applies again. This is what the corrected undo replays.
|
|
17
|
+
- `priorState` on a setting change now carries `source` and `pinnedInAutoConf`, so a caller can see whether a value was explicitly pinned or inherited.
|
|
18
|
+
|
|
3
19
|
## v0.7.0 — 2026-08-02
|
|
4
20
|
|
|
5
21
|
### Changed (BREAKING)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
5
|
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -103,8 +103,22 @@ are open — record them as gaps rather than silently passing.
|
|
|
103
103
|
- [x] ✅ `postgres-aiops undo apply <id>` → replayed correctly and recreated the
|
|
104
104
|
index from the captured definition. (A replay bug found in an earlier
|
|
105
105
|
round was fixed and is now covered by a regression test.)
|
|
106
|
-
- [
|
|
107
|
-
|
|
106
|
+
- [x] ✅ `remediate set <guc> <value>` then `undo apply` — **closed 2026-08-03,
|
|
107
|
+
and it found a bug.** Both directions were run against PostgreSQL 16.14
|
|
108
|
+
with `postgresql.auto.conf` inspected after each step:
|
|
109
|
+
- a parameter with **no** prior auto.conf entry (`source = default`) →
|
|
110
|
+
undo now issues `ALTER SYSTEM RESET` and the file is left clean. It
|
|
111
|
+
used to write the prior *value* back as a new entry: the number was
|
|
112
|
+
right, but auto.conf **overrides** postgresql.conf, so the "undo"
|
|
113
|
+
silently pinned a parameter that had been inherited, and any later edit
|
|
114
|
+
to postgresql.conf would have had no effect.
|
|
115
|
+
- a parameter that **was** already pinned (`ALTER SYSTEM SET work_mem =
|
|
116
|
+
'2MB'` first) → undo restores the value and keeps the entry.
|
|
117
|
+
The discriminator is `pg_settings.sourcefile`: `source` reads
|
|
118
|
+
`configuration file` for postgresql.conf *and* auto.conf alike, so only
|
|
119
|
+
the filename separates them. Note the pin is **latent** — until someone
|
|
120
|
+
runs `pg_reload_conf()`, `source` still reads `default`, so the damage is
|
|
121
|
+
invisible at the moment it is done.
|
|
108
122
|
|
|
109
123
|
### 5. Irreversible writes are honest about it
|
|
110
124
|
- [x] ✅ `remediate vacuum <table> --analyze` → the dead tuples were actually
|
|
@@ -50,12 +50,27 @@ def _drop_index_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
|
50
50
|
def _update_setting_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
51
51
|
if not isinstance(result, dict):
|
|
52
52
|
return None
|
|
53
|
-
prior =
|
|
54
|
-
if
|
|
53
|
+
prior = result.get("priorState") or {}
|
|
54
|
+
if not prior.get("pinnedInAutoConf"):
|
|
55
|
+
# The parameter had no postgresql.auto.conf entry, so putting the prior
|
|
56
|
+
# VALUE back would restore the number while leaving behind a pin that
|
|
57
|
+
# shadows postgresql.conf (auto.conf wins) for good. RESET removes the
|
|
58
|
+
# entry and lets the previous source apply again.
|
|
59
|
+
return {
|
|
60
|
+
"tool": "update_setting",
|
|
61
|
+
"params": {"name": params.get("name"), "reset": True},
|
|
62
|
+
"skill": "postgres-aiops",
|
|
63
|
+
"note": (
|
|
64
|
+
"Inverse of update_setting: ALTER SYSTEM RESET, because the "
|
|
65
|
+
"parameter had no postgresql.auto.conf entry beforehand."
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
value = prior.get("value")
|
|
69
|
+
if value is None or value == "":
|
|
55
70
|
return None
|
|
56
71
|
return {
|
|
57
72
|
"tool": "update_setting",
|
|
58
|
-
"params": {"name": params.get("name"), "value":
|
|
73
|
+
"params": {"name": params.get("name"), "value": value},
|
|
59
74
|
"skill": "postgres-aiops",
|
|
60
75
|
"note": "Inverse of update_setting: ALTER SYSTEM SET back to the prior value.",
|
|
61
76
|
}
|
|
@@ -282,16 +297,22 @@ def reindex(
|
|
|
282
297
|
@tool_errors("dict")
|
|
283
298
|
def update_setting(
|
|
284
299
|
name: str,
|
|
285
|
-
value: str,
|
|
300
|
+
value: Optional[str] = None,
|
|
301
|
+
reset: bool = False,
|
|
286
302
|
dry_run: bool = False,
|
|
287
303
|
target: Optional[str] = None,
|
|
288
304
|
) -> dict:
|
|
289
|
-
"""[WRITE][risk=medium] ALTER SYSTEM SET a parameter. Reversible
|
|
305
|
+
"""[WRITE][risk=medium] ALTER SYSTEM SET (or RESET) a parameter. Reversible.
|
|
290
306
|
|
|
291
307
|
Writes postgresql.auto.conf; most parameters need SELECT pg_reload_conf() (or
|
|
292
308
|
a restart for postmaster-context settings) to take effect — reported but NOT
|
|
293
|
-
performed automatically. The prior
|
|
294
|
-
undo
|
|
309
|
+
performed automatically. The prior state is captured so the harness records
|
|
310
|
+
an undo. Pass dry_run=True to preview.
|
|
311
|
+
|
|
312
|
+
reset=True issues ALTER SYSTEM RESET, dropping the parameter's auto.conf
|
|
313
|
+
entry so postgresql.conf or the built-in default applies again. That is the
|
|
314
|
+
recorded inverse when the parameter had no auto.conf entry to begin with:
|
|
315
|
+
writing the prior value back would leave a pin that shadows postgresql.conf.
|
|
295
316
|
|
|
296
317
|
Refuses the connection-affecting postmaster settings (listen_addresses,
|
|
297
318
|
port, max_connections, superuser_reserved_connections, ssl, hba_file):
|
|
@@ -300,7 +321,8 @@ def update_setting(
|
|
|
300
321
|
|
|
301
322
|
Args:
|
|
302
323
|
name: The configuration parameter name (e.g. work_mem).
|
|
303
|
-
value: The new value (as a string).
|
|
324
|
+
value: The new value (as a string). Omit when reset=True.
|
|
325
|
+
reset: If True, ALTER SYSTEM RESET the parameter instead of setting it.
|
|
304
326
|
dry_run: If True, preview without changing.
|
|
305
327
|
target: Target name from config; omit for the default.
|
|
306
328
|
"""
|
|
@@ -309,5 +331,7 @@ def update_setting(
|
|
|
309
331
|
# call — a preview of a refused setting must report the refusal.
|
|
310
332
|
ops.guard_update_setting(name)
|
|
311
333
|
if dry_run:
|
|
334
|
+
if reset:
|
|
335
|
+
return {"dryRun": True, "wouldReset": {"name": name}}
|
|
312
336
|
return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
|
|
313
|
-
return ops.update_setting(conn, name, value)
|
|
337
|
+
return ops.update_setting(conn, name, value, reset=reset)
|
|
@@ -80,6 +80,32 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _origin_target(rec: dict) -> Optional[str]:
|
|
84
|
+
"""The target the ORIGINAL write ran against, read from its recorded params.
|
|
85
|
+
|
|
86
|
+
Without this the replay goes to whatever target the caller names — in
|
|
87
|
+
practice the config's first entry — so on a multi-target config the inverse
|
|
88
|
+
runs against the wrong host. It only *looks* harmless because the resource
|
|
89
|
+
usually does not exist there; when two hosts both hold a resource with the
|
|
90
|
+
same name the inverse succeeds on the wrong one, silently.
|
|
91
|
+
|
|
92
|
+
Caught live on 2026-08-03 in container-host-aiops: a ``stop_container``
|
|
93
|
+
recorded against a Podman target replayed against a Portainer target.
|
|
94
|
+
|
|
95
|
+
A ``target`` in ``orig_params`` also means the original call accepted one,
|
|
96
|
+
so the fallback cannot hand a keyword to a tool that has no such parameter.
|
|
97
|
+
"""
|
|
98
|
+
raw = rec.get("orig_params") or ""
|
|
99
|
+
try:
|
|
100
|
+
orig = json.loads(raw) if raw else {}
|
|
101
|
+
except (ValueError, TypeError):
|
|
102
|
+
return None # unreadable: fall back to the caller/default, as before
|
|
103
|
+
if not isinstance(orig, dict):
|
|
104
|
+
return None
|
|
105
|
+
value = orig.get("target")
|
|
106
|
+
return value if isinstance(value, str) and value else None
|
|
107
|
+
|
|
108
|
+
|
|
83
109
|
@mcp.tool()
|
|
84
110
|
@governed_tool(risk_level="medium")
|
|
85
111
|
@tool_errors("dict")
|
|
@@ -143,8 +169,10 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
call_params = dict(params)
|
|
146
|
-
if
|
|
147
|
-
|
|
172
|
+
if "target" not in call_params:
|
|
173
|
+
chosen = target if target is not None else _origin_target(rec)
|
|
174
|
+
if chosen is not None:
|
|
175
|
+
call_params["target"] = chosen
|
|
148
176
|
result = fn(**call_params)
|
|
149
177
|
|
|
150
178
|
# Only mark applied when the inverse did not itself return an error dict.
|
|
@@ -103,6 +103,53 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
|
|
|
103
103
|
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
#: Exit status for a write whose outcome could not be determined. Kept distinct
|
|
107
|
+
#: from 0 (confirmed) and 1 (failed) on purpose: a write whose response was lost
|
|
108
|
+
#: is not a failure, but it is emphatically not a success either, and a script
|
|
109
|
+
#: must be able to tell all three apart.
|
|
110
|
+
EXIT_UNDETERMINED = 2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def checked(result: Any) -> Any:
|
|
114
|
+
"""Return ``result``, or abort when it reports a failed/undetermined write.
|
|
115
|
+
|
|
116
|
+
Every CLI command that calls a governed twin MUST pass the result through
|
|
117
|
+
here before printing its success line.
|
|
118
|
+
|
|
119
|
+
Governed twins are wrapped in ``@tool_errors``, which flattens any exception
|
|
120
|
+
into ``{"error": ...}`` and **returns** it. The CLI therefore never sees the
|
|
121
|
+
exception, so a command that prints its result unconditionally reports a
|
|
122
|
+
refused or failed operation exactly like a successful one — and exits 0, so
|
|
123
|
+
a script cannot tell either. The dry-run path already refused with a
|
|
124
|
+
non-zero status, which made the asymmetry worse: the preview was stricter
|
|
125
|
+
than the real call.
|
|
126
|
+
|
|
127
|
+
``outcomeUnknown`` (set by the harness when a write's response is lost) is
|
|
128
|
+
neither success nor failure — the change may still have landed. It gets its
|
|
129
|
+
own line and :data:`EXIT_UNDETERMINED`, never a silent success.
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(result, dict):
|
|
132
|
+
return result
|
|
133
|
+
error = result.get("error")
|
|
134
|
+
# ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
|
|
135
|
+
# write whose response was lost carries BOTH keys, and it is audited
|
|
136
|
+
# `unknown` precisely because it may have taken effect. Reporting that as a
|
|
137
|
+
# plain failure would tell a script the change did not happen and invite the
|
|
138
|
+
# double-apply the payload's own note warns about.
|
|
139
|
+
if result.get("outcomeUnknown"):
|
|
140
|
+
console.print(
|
|
141
|
+
f"[yellow]Outcome undetermined: {result.get('note') or ''}[/]"
|
|
142
|
+
)
|
|
143
|
+
raise typer.Exit(EXIT_UNDETERMINED)
|
|
144
|
+
if error:
|
|
145
|
+
console.print(f"[red]Error: {error}[/]")
|
|
146
|
+
hint = result.get("hint")
|
|
147
|
+
if hint:
|
|
148
|
+
console.print(f"[dim]{hint}[/]")
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
106
153
|
def dry_run_preview(
|
|
107
154
|
preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
|
|
108
155
|
) -> None:
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
checked,
|
|
13
14
|
cli_errors,
|
|
14
15
|
console,
|
|
15
16
|
double_confirm,
|
|
@@ -74,4 +75,4 @@ def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> N
|
|
|
74
75
|
operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
|
|
75
76
|
return
|
|
76
77
|
double_confirm("reset pg_stat_statements on", "this target")
|
|
77
|
-
console.print_json(json.dumps(gov.reset_query_stats(target=target)))
|
|
78
|
+
console.print_json(json.dumps(checked(gov.reset_query_stats(target=target))))
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from postgres_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
checked,
|
|
13
14
|
cli_errors,
|
|
14
15
|
console,
|
|
15
16
|
double_confirm,
|
|
@@ -42,7 +43,7 @@ def remediate_terminate(
|
|
|
42
43
|
api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
|
|
43
44
|
return
|
|
44
45
|
double_confirm("terminate backend", str(pid))
|
|
45
|
-
console.print_json(json.dumps(gov.terminate_backend(pid=pid, target=target)))
|
|
46
|
+
console.print_json(json.dumps(checked(gov.terminate_backend(pid=pid, target=target))))
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
@remediate_app.command("cancel")
|
|
@@ -62,7 +63,7 @@ def remediate_cancel(
|
|
|
62
63
|
api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
|
|
63
64
|
return
|
|
64
65
|
double_confirm("cancel query on backend", str(pid))
|
|
65
|
-
console.print_json(json.dumps(gov.cancel_query(pid=pid, target=target)))
|
|
66
|
+
console.print_json(json.dumps(checked(gov.cancel_query(pid=pid, target=target))))
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
@remediate_app.command("vacuum")
|
|
@@ -85,7 +86,7 @@ def remediate_vacuum(
|
|
|
85
86
|
return
|
|
86
87
|
double_confirm("VACUUM", table)
|
|
87
88
|
console.print_json(
|
|
88
|
-
json.dumps(gov.run_vacuum(table=table, full=full, analyze=analyze, target=target)))
|
|
89
|
+
json.dumps(checked(gov.run_vacuum(table=table, full=full, analyze=analyze, target=target))))
|
|
89
90
|
|
|
90
91
|
|
|
91
92
|
@remediate_app.command("analyze-table")
|
|
@@ -104,7 +105,7 @@ def remediate_analyze(
|
|
|
104
105
|
operation="run_analyze", api_call=f"ANALYZE {table}")
|
|
105
106
|
return
|
|
106
107
|
double_confirm("ANALYZE", table)
|
|
107
|
-
console.print_json(json.dumps(gov.run_analyze(table=table, target=target)))
|
|
108
|
+
console.print_json(json.dumps(checked(gov.run_analyze(table=table, target=target))))
|
|
108
109
|
|
|
109
110
|
|
|
110
111
|
@remediate_app.command("create-index")
|
|
@@ -131,8 +132,8 @@ def remediate_create_index(
|
|
|
131
132
|
parameters={"columns": columns, "name": name, "unique": unique})
|
|
132
133
|
return
|
|
133
134
|
double_confirm("create index on", table)
|
|
134
|
-
result = gov.create_index(table=table, columns=columns, name=name, unique=unique,
|
|
135
|
-
concurrently=concurrently, target=target)
|
|
135
|
+
result = checked(gov.create_index(table=table, columns=columns, name=name, unique=unique,
|
|
136
|
+
concurrently=concurrently, target=target))
|
|
136
137
|
console.print_json(json.dumps(result))
|
|
137
138
|
|
|
138
139
|
|
|
@@ -154,7 +155,7 @@ def remediate_drop_index(
|
|
|
154
155
|
return
|
|
155
156
|
double_confirm("drop index", name)
|
|
156
157
|
console.print_json(
|
|
157
|
-
json.dumps(gov.drop_index(name=name, concurrently=concurrently, target=target)))
|
|
158
|
+
json.dumps(checked(gov.drop_index(name=name, concurrently=concurrently, target=target))))
|
|
158
159
|
|
|
159
160
|
|
|
160
161
|
@remediate_app.command("reindex")
|
|
@@ -176,28 +177,37 @@ def remediate_reindex(
|
|
|
176
177
|
operation="reindex", api_call=f"REINDEX {kind} {target_name}")
|
|
177
178
|
return
|
|
178
179
|
double_confirm(f"REINDEX {kind}", target_name)
|
|
179
|
-
console.print_json(json.dumps(gov.reindex(target_name=target_name, kind=kind,
|
|
180
|
-
concurrently=concurrently, target=target)))
|
|
180
|
+
console.print_json(json.dumps(checked(gov.reindex(target_name=target_name, kind=kind,
|
|
181
|
+
concurrently=concurrently, target=target))))
|
|
181
182
|
|
|
182
183
|
|
|
183
184
|
@remediate_app.command("set")
|
|
184
185
|
@cli_errors
|
|
185
186
|
def remediate_set(
|
|
186
187
|
name: Annotated[str, typer.Argument(help="Parameter name (e.g. work_mem)")],
|
|
187
|
-
value: Annotated[str, typer.Argument(help="New value")],
|
|
188
|
+
value: Annotated[str | None, typer.Argument(help="New value")] = None,
|
|
189
|
+
reset: Annotated[bool, typer.Option(
|
|
190
|
+
"--reset",
|
|
191
|
+
help="ALTER SYSTEM RESET instead: drop the parameter's "
|
|
192
|
+
"postgresql.auto.conf entry so postgresql.conf / the default applies",
|
|
193
|
+
)] = False,
|
|
188
194
|
target: TargetOption = None,
|
|
189
195
|
dry_run: DryRunOption = False,
|
|
190
196
|
) -> None:
|
|
191
|
-
"""ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
|
|
197
|
+
"""ALTER SYSTEM SET a parameter, or --reset it (reversible; dry-run + confirm)."""
|
|
192
198
|
from mcp_server.tools import remediation as gov
|
|
193
199
|
|
|
200
|
+
action = f"ALTER SYSTEM RESET {name}" if reset else f"ALTER SYSTEM SET {name} ="
|
|
194
201
|
if dry_run:
|
|
195
202
|
# Through the governed call: update_setting refuses the
|
|
196
203
|
# connection-affecting postmaster settings, so a preview must report it.
|
|
197
204
|
dry_run_preview(
|
|
198
|
-
gov.update_setting(
|
|
205
|
+
gov.update_setting(
|
|
206
|
+
name=name, value=value, reset=reset, dry_run=True, target=target),
|
|
199
207
|
operation="update_setting",
|
|
200
|
-
api_call=f"
|
|
208
|
+
api_call=f"{action} ...",
|
|
209
|
+
parameters={"value": value, "reset": reset})
|
|
201
210
|
return
|
|
202
|
-
double_confirm(
|
|
203
|
-
console.print_json(json.dumps(
|
|
211
|
+
double_confirm(action, "(default)" if reset else str(value))
|
|
212
|
+
console.print_json(json.dumps(
|
|
213
|
+
checked(gov.update_setting(name=name, value=value, reset=reset, target=target))))
|
|
@@ -15,6 +15,7 @@ import typer
|
|
|
15
15
|
from postgres_aiops.cli._common import (
|
|
16
16
|
DryRunOption,
|
|
17
17
|
TargetOption,
|
|
18
|
+
checked,
|
|
18
19
|
cli_errors,
|
|
19
20
|
console,
|
|
20
21
|
double_confirm,
|
|
@@ -65,4 +66,4 @@ def undo_apply_cmd(
|
|
|
65
66
|
)
|
|
66
67
|
return
|
|
67
68
|
double_confirm("apply undo", undo_id)
|
|
68
|
-
console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
|
|
69
|
+
console.print_json(json.dumps(checked(gov.undo_apply(undo_id=undo_id, target=target))))
|
|
@@ -338,13 +338,33 @@ def guard_update_setting(name: str) -> None:
|
|
|
338
338
|
)
|
|
339
339
|
|
|
340
340
|
|
|
341
|
-
def
|
|
342
|
-
"""
|
|
341
|
+
def _pinned_in_auto_conf(prior: dict) -> bool:
|
|
342
|
+
"""Was this parameter already carried by ``postgresql.auto.conf``?
|
|
343
|
+
|
|
344
|
+
``pg_settings.source`` reads ``configuration file`` for both postgresql.conf
|
|
345
|
+
and postgresql.auto.conf, so only ``sourcefile`` separates them. A parameter
|
|
346
|
+
that has never been ALTER SYSTEM'd reads ``source = default`` with a NULL
|
|
347
|
+
sourcefile, or points at postgresql.conf. Verified on PostgreSQL 16.14.
|
|
348
|
+
"""
|
|
349
|
+
sourcefile = prior.get("sourcefile") or ""
|
|
350
|
+
return str(sourcefile).endswith("postgresql.auto.conf")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def update_setting(
|
|
354
|
+
conn: Any, name: str, value: str | None = None, reset: bool = False
|
|
355
|
+
) -> dict:
|
|
356
|
+
"""[WRITE] ALTER SYSTEM SET (or RESET) a parameter. Captures the prior state.
|
|
343
357
|
|
|
344
358
|
Writes to postgresql.auto.conf; most parameters need ``SELECT pg_reload_conf()``
|
|
345
359
|
(or a restart for ``pending_restart`` ones) to take effect — this is reported
|
|
346
360
|
but NOT performed automatically.
|
|
347
361
|
|
|
362
|
+
``reset=True`` issues ``ALTER SYSTEM RESET``, removing the parameter's
|
|
363
|
+
postgresql.auto.conf entry so whatever set it before (postgresql.conf, or the
|
|
364
|
+
built-in default) applies again. That is the correct inverse for a parameter
|
|
365
|
+
that had **no** auto.conf entry: writing the prior *value* back would restore
|
|
366
|
+
the number while leaving a pin that silently shadows postgresql.conf forever.
|
|
367
|
+
|
|
348
368
|
**Refuses the connection-affecting postmaster settings**
|
|
349
369
|
(``listen_addresses``, ``port``, ``max_connections``,
|
|
350
370
|
``superuser_reserved_connections``, ``ssl``, ``hba_file``). Those are the
|
|
@@ -353,22 +373,35 @@ def update_setting(conn: Any, name: str, value: str) -> dict:
|
|
|
353
373
|
"""
|
|
354
374
|
guard_update_setting(name)
|
|
355
375
|
setting_name = _validate_setting_name(name)
|
|
376
|
+
if not reset and value is None:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
f"No value given for {setting_name!r}. Pass a value, or reset=True "
|
|
379
|
+
f"to drop its postgresql.auto.conf entry."
|
|
380
|
+
)
|
|
356
381
|
prior = conn.query_one(
|
|
357
|
-
"SELECT setting, unit, context, pending_restart
|
|
382
|
+
"SELECT setting, unit, context, pending_restart, source, sourcefile "
|
|
383
|
+
"FROM pg_settings WHERE name = %(n)s",
|
|
358
384
|
{"n": setting_name},
|
|
359
385
|
) or {}
|
|
360
|
-
|
|
361
|
-
|
|
386
|
+
if reset:
|
|
387
|
+
sql = f"ALTER SYSTEM RESET {setting_name}" # nosec B608 — name validated
|
|
388
|
+
else:
|
|
389
|
+
literal = quote_literal(str(value))
|
|
390
|
+
sql = f"ALTER SYSTEM SET {setting_name} = {literal}" # nosec B608 — name validated, value literal-quoted
|
|
362
391
|
conn.execute(sql)
|
|
363
392
|
needs_restart = bool(prior.get("pending_restart")) or prior.get("context") == "postmaster"
|
|
364
393
|
return {
|
|
365
394
|
"action": "update_setting",
|
|
366
395
|
"setting": setting_name,
|
|
367
|
-
"newValue": str(value),
|
|
396
|
+
"newValue": None if reset else str(value),
|
|
397
|
+
"reset": reset,
|
|
368
398
|
"priorState": {
|
|
369
399
|
"value": s(prior.get("setting"), 256),
|
|
370
400
|
"unit": opt(prior.get("unit"), 32),
|
|
371
401
|
"context": s(prior.get("context"), 32),
|
|
402
|
+
# Where the value came from decides what "put it back" means.
|
|
403
|
+
"source": opt(prior.get("source"), 32),
|
|
404
|
+
"pinnedInAutoConf": _pinned_in_auto_conf(prior),
|
|
372
405
|
},
|
|
373
406
|
"reloadRequired": True,
|
|
374
407
|
"restartRequired": needs_restart,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postgres-aiops"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Postgres-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.9.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.9.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The CLI must never report a failed governed write as a success.
|
|
2
|
+
|
|
3
|
+
Two regressions are pinned here.
|
|
4
|
+
|
|
5
|
+
**The behaviour** — ``checked()`` aborts non-zero on an error payload and on an
|
|
6
|
+
undetermined outcome. Governed twins are wrapped in ``@tool_errors``, which
|
|
7
|
+
flattens every exception into ``{"error": ...}`` and *returns* it, so a
|
|
8
|
+
command that prints its result unconditionally exits 0 for an operation that
|
|
9
|
+
was refused or failed.
|
|
10
|
+
|
|
11
|
+
**The invariant** — every CLI call into a governed write twin routes its result
|
|
12
|
+
through that helper. This is the part that matters over time: the defect was
|
|
13
|
+
fixed repo-by-repo several times and kept coming back, because nothing stopped
|
|
14
|
+
a *new* command from printing a governed result bare. A structural assertion
|
|
15
|
+
does. It was introduced after an audit found the preview path guarded and the
|
|
16
|
+
real write path unguarded in 18 of the 24 tools at once — the preview was
|
|
17
|
+
stricter than the write it previews.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import pathlib
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from postgres_aiops.cli._common import checked
|
|
29
|
+
|
|
30
|
+
CLI_DIR = pathlib.Path(__file__).resolve().parents[1] / "postgres_aiops" / "cli"
|
|
31
|
+
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "mcp_server" / "tools"
|
|
32
|
+
|
|
33
|
+
#: Helpers that end the command non-zero when the payload reports a failure.
|
|
34
|
+
CHECKERS = ['checked', 'dry_run_preview']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.unit
|
|
38
|
+
def test_checked_aborts_on_error_payload():
|
|
39
|
+
with pytest.raises(typer.Exit) as exc:
|
|
40
|
+
checked({"error": "refused: that would lock this tool out"})
|
|
41
|
+
assert exc.value.exit_code == 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.unit
|
|
45
|
+
def test_checked_passes_a_successful_payload_through():
|
|
46
|
+
payload = {"action": "did_a_thing", "ok": True}
|
|
47
|
+
assert checked(payload) == payload
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _callee(node: ast.AST) -> str | None:
|
|
51
|
+
if isinstance(node, ast.Name):
|
|
52
|
+
return node.id
|
|
53
|
+
if isinstance(node, ast.Attribute):
|
|
54
|
+
return node.attr
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_tool_names() -> set[str]:
|
|
59
|
+
names: set[str] = set()
|
|
60
|
+
for path in TOOLS_DIR.glob("*.py"):
|
|
61
|
+
tree = ast.parse(path.read_text())
|
|
62
|
+
for node in ast.walk(tree):
|
|
63
|
+
if isinstance(node, ast.FunctionDef) and "[WRITE]" in (ast.get_docstring(node) or ""):
|
|
64
|
+
names.add(node.name)
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _aborts_on_failure(fn: ast.FunctionDef) -> bool:
|
|
69
|
+
"""Does this function raise ``typer.Exit`` off a failure key in the payload?
|
|
70
|
+
|
|
71
|
+
Several commands guard with a locally named helper (``_require_ok``,
|
|
72
|
+
``_emit``, ...) rather than the shared one. Those are correct guards, so the
|
|
73
|
+
invariant recognises the *shape* instead of a list of blessed names.
|
|
74
|
+
"""
|
|
75
|
+
raises_exit = any(
|
|
76
|
+
isinstance(n, ast.Call) and _callee(n.func) == "Exit" for n in ast.walk(fn)
|
|
77
|
+
)
|
|
78
|
+
reads_failure_key = any(
|
|
79
|
+
isinstance(n, ast.Constant) and n.value in ("error", "outcomeUnknown")
|
|
80
|
+
for n in ast.walk(fn)
|
|
81
|
+
)
|
|
82
|
+
return raises_exit and reads_failure_key
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _checker_names() -> set[str]:
|
|
86
|
+
"""CHECKERS, every local guard, and any wrapper delegating to one of them."""
|
|
87
|
+
funcs: dict[str, list[ast.FunctionDef]] = {}
|
|
88
|
+
for path in CLI_DIR.rglob("*.py"):
|
|
89
|
+
for node in ast.walk(ast.parse(path.read_text())):
|
|
90
|
+
if isinstance(node, ast.FunctionDef):
|
|
91
|
+
funcs.setdefault(node.name, []).append(node)
|
|
92
|
+
found = set(CHECKERS) | {
|
|
93
|
+
name for name, defs in funcs.items() if any(_aborts_on_failure(f) for f in defs)
|
|
94
|
+
}
|
|
95
|
+
changed = True
|
|
96
|
+
while changed:
|
|
97
|
+
changed = False
|
|
98
|
+
for name, defs in funcs.items():
|
|
99
|
+
if name in found:
|
|
100
|
+
continue
|
|
101
|
+
for fn in defs:
|
|
102
|
+
if any(isinstance(n, ast.Call) and _callee(n.func) in found for n in ast.walk(fn)):
|
|
103
|
+
found.add(name)
|
|
104
|
+
changed = True
|
|
105
|
+
break
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.unit
|
|
110
|
+
def test_every_cli_governed_write_result_is_checked():
|
|
111
|
+
tools = _write_tool_names()
|
|
112
|
+
assert tools, "no [WRITE] tools parsed — the invariant would vacuously pass"
|
|
113
|
+
checkers = _checker_names()
|
|
114
|
+
unchecked: list[str] = []
|
|
115
|
+
|
|
116
|
+
for path in sorted(CLI_DIR.rglob("*.py")):
|
|
117
|
+
tree = ast.parse(path.read_text())
|
|
118
|
+
for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
|
119
|
+
parents: dict[ast.AST, ast.AST] = {}
|
|
120
|
+
for node in ast.walk(func):
|
|
121
|
+
for child in ast.iter_child_nodes(node):
|
|
122
|
+
parents[child] = node
|
|
123
|
+
guarded = {
|
|
124
|
+
arg.id
|
|
125
|
+
for node in ast.walk(func)
|
|
126
|
+
if isinstance(node, ast.Call) and _callee(node.func) in checkers
|
|
127
|
+
for arg in list(node.args) + [kw.value for kw in node.keywords]
|
|
128
|
+
if isinstance(arg, ast.Name)
|
|
129
|
+
}
|
|
130
|
+
for node in ast.walk(func):
|
|
131
|
+
if not (isinstance(node, ast.Call) and _callee(node.func) in tools):
|
|
132
|
+
continue
|
|
133
|
+
parent = parents.get(node)
|
|
134
|
+
if isinstance(parent, (ast.keyword, ast.Starred)):
|
|
135
|
+
parent = parents.get(parent)
|
|
136
|
+
if isinstance(parent, ast.Call) and _callee(parent.func) in checkers:
|
|
137
|
+
continue
|
|
138
|
+
if isinstance(parent, ast.Assign) and any(
|
|
139
|
+
isinstance(t, ast.Name) and t.id in guarded for t in parent.targets
|
|
140
|
+
):
|
|
141
|
+
continue
|
|
142
|
+
unchecked.append(f"{path.name}:{node.lineno}:{_callee(node.func)}")
|
|
143
|
+
|
|
144
|
+
assert not unchecked, (
|
|
145
|
+
"these CLI call sites print a governed write's result without routing it "
|
|
146
|
+
f"through {sorted(checkers)}, so a refusal exits 0: {unchecked}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@pytest.mark.unit
|
|
150
|
+
def test_undetermined_outcome_beats_error_and_is_not_a_plain_failure():
|
|
151
|
+
"""A lost response carries BOTH keys — it must exit 2, not 1.
|
|
152
|
+
|
|
153
|
+
``@tool_errors`` flattens the exception into ``{"error": ...}`` and, when
|
|
154
|
+
the failure is one where the write may still have landed, also sets
|
|
155
|
+
``outcomeUnknown``. The harness deliberately judges unknown *before* error
|
|
156
|
+
when writing the audit row, for the reason its own note gives: the change
|
|
157
|
+
may have taken effect, so a blind retry could apply it twice. The CLI has to
|
|
158
|
+
agree, or the audit says "may have taken effect" while the exit status tells
|
|
159
|
+
a script it did not happen.
|
|
160
|
+
"""
|
|
161
|
+
with pytest.raises(typer.Exit) as exc:
|
|
162
|
+
checked({
|
|
163
|
+
"error": "connection reset while awaiting the response",
|
|
164
|
+
"outcomeUnknown": True,
|
|
165
|
+
"note": "may have taken effect",
|
|
166
|
+
})
|
|
167
|
+
assert exc.value.exit_code == 2
|
|
@@ -77,17 +77,48 @@ def test_drop_index_undo_none_without_definition():
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
@pytest.mark.unit
|
|
80
|
-
def
|
|
80
|
+
def test_update_setting_undo_sets_back_prior_value_when_it_was_pinned():
|
|
81
|
+
"""Only a parameter already carried by auto.conf is restored by value."""
|
|
81
82
|
from mcp_server.tools.remediation import _update_setting_undo
|
|
82
83
|
|
|
83
|
-
desc = _update_setting_undo(
|
|
84
|
+
desc = _update_setting_undo(
|
|
85
|
+
{"name": "work_mem"},
|
|
86
|
+
{"priorState": {"value": "4MB", "source": "configuration file",
|
|
87
|
+
"pinnedInAutoConf": True}},
|
|
88
|
+
)
|
|
84
89
|
assert desc["tool"] == "update_setting"
|
|
85
90
|
assert desc["params"] == {"name": "work_mem", "value": "4MB"}
|
|
86
91
|
|
|
87
92
|
|
|
88
93
|
@pytest.mark.unit
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
def test_update_setting_undo_resets_when_there_was_no_auto_conf_entry():
|
|
95
|
+
"""Restoring the VALUE would leave a pin that shadows postgresql.conf.
|
|
96
|
+
|
|
97
|
+
Shape from a live PostgreSQL 16.14: an untouched parameter reads
|
|
98
|
+
``source = default`` with a NULL sourcefile, so the inverse of setting it
|
|
99
|
+
must be ALTER SYSTEM RESET, not ALTER SYSTEM SET back to the old number.
|
|
100
|
+
"""
|
|
101
|
+
from mcp_server.tools.remediation import _update_setting_undo
|
|
102
|
+
|
|
103
|
+
desc = _update_setting_undo(
|
|
104
|
+
{"name": "work_mem"},
|
|
105
|
+
{"priorState": {"value": "4096", "unit": "kB", "context": "user",
|
|
106
|
+
"source": "default", "pinnedInAutoConf": False}},
|
|
107
|
+
)
|
|
108
|
+
assert desc["params"] == {"name": "work_mem", "reset": True}
|
|
109
|
+
assert "RESET" in desc["note"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.unit
|
|
113
|
+
@pytest.mark.parametrize(
|
|
114
|
+
"result",
|
|
115
|
+
[
|
|
116
|
+
{"priorState": {"value": "", "pinnedInAutoConf": True}},
|
|
117
|
+
{"priorState": {"pinnedInAutoConf": True}},
|
|
118
|
+
"x",
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
def test_update_setting_undo_none_when_pinned_but_no_prior_value(result):
|
|
91
122
|
from mcp_server.tools.remediation import _update_setting_undo
|
|
92
123
|
|
|
93
124
|
assert _update_setting_undo({"name": "work_mem"}, result) is None
|
|
@@ -21,18 +21,21 @@ from mcp_server.tools import undo as gov
|
|
|
21
21
|
from postgres_aiops.governance import governed_tool
|
|
22
22
|
|
|
23
23
|
_CALLS: list[dict] = []
|
|
24
|
+
_TARGETS: list = []
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
@governed_tool(risk_level="low")
|
|
27
28
|
def _undo_probe(value: str = "", target=None) -> dict:
|
|
28
29
|
"""Synthetic inverse target used only by the undo-executor tests."""
|
|
29
30
|
_CALLS.append({"value": value})
|
|
31
|
+
_TARGETS.append(target)
|
|
30
32
|
return {"ok": True, "value": value}
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
@pytest.fixture
|
|
34
36
|
def gov_home(tmp_path, monkeypatch):
|
|
35
37
|
_CALLS.clear()
|
|
38
|
+
_TARGETS.clear()
|
|
36
39
|
# Register the probe only for the duration of these tests so it never
|
|
37
40
|
# pollutes the real tool registry (which exact-count smoke tests assert on).
|
|
38
41
|
mcp.add_tool(_undo_probe, name="_undo_probe")
|
|
@@ -77,6 +80,52 @@ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
|
|
|
77
80
|
assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
|
|
78
81
|
|
|
79
82
|
|
|
83
|
+
@pytest.mark.unit
|
|
84
|
+
def test_undo_apply_replays_against_the_target_the_write_ran_on(gov_home):
|
|
85
|
+
"""A replay must go to the host the original write went to.
|
|
86
|
+
|
|
87
|
+
Without this the inverse ran against whatever target the caller named — in
|
|
88
|
+
practice the config's first entry — so on a multi-target config it hit the
|
|
89
|
+
wrong host. It only looked harmless because the resource usually does not
|
|
90
|
+
exist there; two hosts holding the same name and the inverse succeeds on the
|
|
91
|
+
wrong one, silently. Caught live on 2026-08-03 in container-host-aiops: a
|
|
92
|
+
stop recorded against a Podman target replayed against a Portainer target.
|
|
93
|
+
"""
|
|
94
|
+
uid = undo_mod.get_undo_store().record(
|
|
95
|
+
skill="probe",
|
|
96
|
+
tool="orig_op",
|
|
97
|
+
undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
|
|
98
|
+
orig_params={"value": "v1", "target": "second-host"},
|
|
99
|
+
)
|
|
100
|
+
result = gov.undo_apply(undo_id=uid)
|
|
101
|
+
assert result["applied"] is True
|
|
102
|
+
assert _TARGETS == ["second-host"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.unit
|
|
106
|
+
def test_undo_apply_prefers_an_explicit_caller_target(gov_home):
|
|
107
|
+
"""An explicitly named target still wins — the fallback is only a default."""
|
|
108
|
+
uid = undo_mod.get_undo_store().record(
|
|
109
|
+
skill="probe",
|
|
110
|
+
tool="orig_op",
|
|
111
|
+
undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
|
|
112
|
+
orig_params={"value": "v1", "target": "second-host"},
|
|
113
|
+
)
|
|
114
|
+
gov.undo_apply(undo_id=uid, target="chosen-host")
|
|
115
|
+
assert _TARGETS == ["chosen-host"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.unit
|
|
119
|
+
def test_undo_apply_survives_unreadable_orig_params(gov_home):
|
|
120
|
+
"""Corrupt orig_params must not break the replay — just lose the fallback."""
|
|
121
|
+
uid = _record(params={"value": "v1"})
|
|
122
|
+
with sqlite3.connect(gov_home / "undo.db") as db:
|
|
123
|
+
db.execute("UPDATE undo_log SET orig_params = ? WHERE undo_id = ?",
|
|
124
|
+
("{not json", uid))
|
|
125
|
+
assert gov.undo_apply(undo_id=uid)["applied"] is True
|
|
126
|
+
assert _TARGETS == [None]
|
|
127
|
+
|
|
128
|
+
|
|
80
129
|
@pytest.mark.unit
|
|
81
130
|
def test_undo_apply_dry_run_previews_without_running(gov_home):
|
|
82
131
|
uid = _record()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/agent-guardrails.md
RENAMED
|
File without changes
|
{postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/capabilities.md
RENAMED
|
File without changes
|
{postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/cli-reference.md
RENAMED
|
File without changes
|
{postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/setup-guide.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|