postgres-aiops 0.2.0__tar.gz → 0.2.1__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.2.0 → postgres_aiops-0.2.1}/.coverage +0 -0
- postgres_aiops-0.2.1/.github/workflows/mcp-publish.yml +23 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/CHANGELOG.md +14 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/PKG-INFO +1 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/remediation.py +19 -5
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/decorators.py +8 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/remediation.py +30 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/secretstore.py +3 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/pyproject.toml +1 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/server.json +2 -2
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/SKILL.md +1 -0
- postgres_aiops-0.2.1/tests/test_doctor.py +184 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_governance_persistence.py +22 -0
- postgres_aiops-0.2.1/tests/test_init.py +122 -0
- postgres_aiops-0.2.1/tests/test_undo_replay.py +66 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/uv.lock +1 -1
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/.gitignore +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/LICENSE +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/README.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/RELEASE_NOTES.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/SECURITY.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/_shared.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/_common.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/query.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/remediate.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/connection.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/sanitize.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/governance/undo.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/smithery.yaml +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/conftest.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_connection.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_reads.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_smoke.py +0 -0
- {postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/tests/test_writes.py +0 -0
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: mcp-publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish-mcp:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Install mcp-publisher
|
|
18
|
+
run: |
|
|
19
|
+
curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
20
|
+
- name: Login to MCP Registry (GitHub OIDC)
|
|
21
|
+
run: ./mcp-publisher login github-oidc
|
|
22
|
+
- name: Publish server.json
|
|
23
|
+
run: ./mcp-publisher publish
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.2.1 — 2026-07-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`secrets.enc` now follows `POSTGRES_AIOPS_HOME`** (secretstore hardcoded the real
|
|
7
|
+
home directory; config/audit/undo already relocated — found in live verification).
|
|
8
|
+
- **Audit fidelity**: failures sanitized into `{"error": ...}` results by the MCP error
|
|
9
|
+
layer are now audited as `status=error` (they previously read as `ok`, hiding failed
|
|
10
|
+
attempts from exception reports), and no undo is recorded for a call that failed.
|
|
11
|
+
- Undo replay fix: `create_index` accepts a `definition` (captured `pg_get_indexdef` statement, shape-validated), making `drop_index`'s undo descriptor replayable.
|
|
12
|
+
|
|
13
|
+
### Tests
|
|
14
|
+
- `doctor` and the `init` wizard are now fully covered (previously ~10–20%); plus a
|
|
15
|
+
regression test for the sanitized-failure audit status.
|
|
16
|
+
|
|
3
17
|
## v0.2.0 — 2026-07-13
|
|
4
18
|
|
|
5
19
|
Security-hardening release from a line-wide code review.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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 <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -162,12 +162,13 @@ def run_analyze(table: str, dry_run: bool = False, target: Optional[str] = None)
|
|
|
162
162
|
@governed_tool(risk_level="medium", undo=_create_index_undo)
|
|
163
163
|
@tool_errors("dict")
|
|
164
164
|
def create_index(
|
|
165
|
-
table: str,
|
|
166
|
-
columns: list[str],
|
|
165
|
+
table: Optional[str] = None,
|
|
166
|
+
columns: Optional[list[str]] = None,
|
|
167
167
|
name: Optional[str] = None,
|
|
168
168
|
unique: bool = False,
|
|
169
169
|
concurrently: bool = False,
|
|
170
170
|
method: Optional[str] = None,
|
|
171
|
+
definition: Optional[str] = None,
|
|
171
172
|
dry_run: bool = False,
|
|
172
173
|
target: Optional[str] = None,
|
|
173
174
|
) -> dict:
|
|
@@ -175,21 +176,34 @@ def create_index(
|
|
|
175
176
|
|
|
176
177
|
Supports CONCURRENTLY (non-blocking build). The created name is returned so
|
|
177
178
|
the harness records an undo that drops exactly this index. Pass dry_run=True
|
|
178
|
-
to preview.
|
|
179
|
+
to preview. Alternatively pass ``definition`` (a captured pg_get_indexdef
|
|
180
|
+
statement — this is how drop_index's undo descriptor replays) INSTEAD of
|
|
181
|
+
table/columns.
|
|
179
182
|
|
|
180
183
|
Args:
|
|
181
|
-
table: Table to index (optionally schema-qualified).
|
|
182
|
-
|
|
184
|
+
table: Table to index (optionally schema-qualified). Required unless
|
|
185
|
+
``definition`` is given.
|
|
186
|
+
columns: Column names to index. Required unless ``definition`` is given.
|
|
183
187
|
name: Index name (auto-generated from table+columns when omitted).
|
|
184
188
|
unique: Create a UNIQUE index.
|
|
185
189
|
concurrently: Build with CONCURRENTLY (no table lock).
|
|
186
190
|
method: Index method — btree/hash/gist/gin/brin/spgist (default btree).
|
|
191
|
+
definition: A full CREATE [UNIQUE] INDEX statement to execute verbatim
|
|
192
|
+
(shape-validated). Mutually exclusive with table/columns.
|
|
187
193
|
dry_run: If True, preview without creating.
|
|
188
194
|
target: Target name from config; omit for the default.
|
|
189
195
|
"""
|
|
196
|
+
if definition and (table or columns):
|
|
197
|
+
raise ValueError("Pass either definition OR table+columns, not both.")
|
|
198
|
+
if not definition and not (table and columns):
|
|
199
|
+
raise ValueError("create_index requires table+columns (or a definition).")
|
|
190
200
|
conn = _get_connection(target)
|
|
191
201
|
if dry_run:
|
|
202
|
+
if definition:
|
|
203
|
+
return {"dryRun": True, "wouldExecute": definition}
|
|
192
204
|
return {"dryRun": True, "wouldCreate": {"table": table, "columns": columns, "name": name}}
|
|
205
|
+
if definition:
|
|
206
|
+
return ops.create_index_from_definition(conn, definition)
|
|
193
207
|
return ops.create_index(
|
|
194
208
|
conn, table, columns, name=name, unique=unique,
|
|
195
209
|
concurrently=concurrently, method=method,
|
|
@@ -317,6 +317,8 @@ def _record_undo(state: _CallState, result: Any) -> None:
|
|
|
317
317
|
"""
|
|
318
318
|
if state.undo is None:
|
|
319
319
|
return
|
|
320
|
+
if isinstance(result, dict) and result.get("error"):
|
|
321
|
+
return # sanitized failure — no change happened, so no inverse to record
|
|
320
322
|
try:
|
|
321
323
|
descriptor = state.undo(state.safe_params, result)
|
|
322
324
|
except Exception: # noqa: BLE001 — undo computation must not fail the call
|
|
@@ -356,6 +358,12 @@ def _capture_error(state: _CallState, exc: Exception) -> None:
|
|
|
356
358
|
|
|
357
359
|
def _finalize(state: _CallState) -> None:
|
|
358
360
|
"""Audit + circuit-breaker bookkeeping. Runs in the wrapper's finally."""
|
|
361
|
+
# A sanitized failure (@tool_errors converts exceptions into {"error": ...}
|
|
362
|
+
# dicts BEFORE this harness sees them) must not be audited as success —
|
|
363
|
+
# compliance exception reports are built from this status.
|
|
364
|
+
if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
|
|
365
|
+
state.status = "error"
|
|
366
|
+
|
|
359
367
|
duration = int((time.time() - state.start) * 1000)
|
|
360
368
|
|
|
361
369
|
# Accumulate wall-time toward the cumulative time budget (best-effort).
|
|
@@ -162,6 +162,36 @@ def create_index(
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
# Shape gate for replaying a captured pg_get_indexdef statement. Server-generated
|
|
166
|
+
# (never user-composed), but validated anyway: single statement, CREATE INDEX only.
|
|
167
|
+
_INDEXDEF_RE = re.compile(
|
|
168
|
+
r"^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?"
|
|
169
|
+
r'("?[A-Za-z_][A-Za-z0-9_$]*"?)\s+ON\s+',
|
|
170
|
+
re.IGNORECASE,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_index_from_definition(conn: Any, definition: str) -> dict:
|
|
175
|
+
"""[WRITE] Recreate an index from a captured ``pg_get_indexdef`` statement.
|
|
176
|
+
|
|
177
|
+
This is the replay path for ``drop_index``'s undo descriptor: the exact
|
|
178
|
+
definition captured before the drop is executed verbatim after a shape
|
|
179
|
+
check (single statement, must be CREATE [UNIQUE] INDEX ... ON ...).
|
|
180
|
+
"""
|
|
181
|
+
stmt = (definition or "").strip().rstrip(";").strip()
|
|
182
|
+
if not stmt or ";" in stmt:
|
|
183
|
+
raise ValueError("definition must be a single CREATE INDEX statement.")
|
|
184
|
+
m = _INDEXDEF_RE.match(stmt)
|
|
185
|
+
if not m:
|
|
186
|
+
raise ValueError("definition must start with CREATE [UNIQUE] INDEX ... ON ...")
|
|
187
|
+
conn.execute(stmt) # nosec B608 — shape-validated pg_get_indexdef output
|
|
188
|
+
return {
|
|
189
|
+
"action": "create_index",
|
|
190
|
+
"index": m.group(1).strip('"'),
|
|
191
|
+
"fromDefinition": True,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
165
195
|
def drop_index(conn: Any, name: str, concurrently: bool = False) -> dict:
|
|
166
196
|
"""[WRITE] Drop an index. Reversible: captures pg_get_indexdef first so undo recreates it."""
|
|
167
197
|
ident = qualify(name)
|
|
@@ -39,9 +39,11 @@ from cryptography.exceptions import InvalidKey
|
|
|
39
39
|
from cryptography.fernet import Fernet, InvalidToken
|
|
40
40
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
41
41
|
|
|
42
|
+
from postgres_aiops.governance.paths import ops_home
|
|
43
|
+
|
|
42
44
|
# ─── Tool-specific constants (change these three to vendor for another tool) ──
|
|
43
45
|
APP_NAME = "postgres-aiops"
|
|
44
|
-
CONFIG_DIR =
|
|
46
|
+
CONFIG_DIR = ops_home()
|
|
45
47
|
MASTER_PASSWORD_ENV = "POSTGRES_AIOPS_MASTER_PASSWORD" # nosec B105 — env-var name
|
|
46
48
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
47
49
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "postgres-aiops"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
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.2.
|
|
10
|
+
"version": "0.2.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "postgres-aiops",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -105,6 +105,7 @@ Pass data straight to the analysis tools — `slow_query_rca(statements=[...])`,
|
|
|
105
105
|
|
|
106
106
|
- Every tool is audited to `~/.postgres-aiops/audit.db` (relocatable via `POSTGRES_AIOPS_HOME`).
|
|
107
107
|
- High-risk ops can require a named approver: set `POSTGRES_AUDIT_APPROVED_BY` and `POSTGRES_AUDIT_RATIONALE` (the env-var names the bundled harness reads).
|
|
108
|
+
- **Secure by default (v0.2.0+)**: with no `~/.postgres-aiops/rules.yaml`, high/critical operations are denied unless `POSTGRES_AUDIT_APPROVED_BY` names an approver (set `POSTGRES_AUDIT_RATIONALE` too). `postgres-aiops init` seeds a starter rules.yaml; an operator-authored rules file is honoured as-is.
|
|
108
109
|
- Writes support `--dry-run` / `dry_run=True` and double confirmation at the CLI.
|
|
109
110
|
- Reversible writes fetch the real before-state and record an inverse descriptor; irreversible ops (terminate/cancel, vacuum/analyze, reindex, reset stats) record prior stats only.
|
|
110
111
|
- All values are bound query parameters; identifiers that cannot be parameterised are validated and quoted.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for ``run_doctor`` — environment and connectivity diagnostics.
|
|
2
|
+
|
|
3
|
+
Everything is redirected to a tmp dir (config, secret store) and the
|
|
4
|
+
connection layer is faked at the ``ConnectionManager`` boundary, so no test
|
|
5
|
+
ever touches a real PostgreSQL server or ``~/.postgres-aiops``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import yaml
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
import postgres_aiops.config as config_mod
|
|
15
|
+
import postgres_aiops.connection as connection_mod
|
|
16
|
+
import postgres_aiops.doctor as doctor_mod
|
|
17
|
+
import postgres_aiops.secretstore as ss
|
|
18
|
+
from postgres_aiops.doctor import run_doctor
|
|
19
|
+
|
|
20
|
+
MASTER_PW = "test-master-pw"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def doctor_home(tmp_path, monkeypatch):
|
|
25
|
+
"""Isolate config + secret store paths under tmp_path."""
|
|
26
|
+
config_file = tmp_path / "config.yaml"
|
|
27
|
+
env_file = tmp_path / ".env"
|
|
28
|
+
secrets_file = tmp_path / "secrets.enc"
|
|
29
|
+
monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
|
|
30
|
+
monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
|
|
31
|
+
monkeypatch.setattr(config_mod, "CONFIG_FILE", config_file)
|
|
32
|
+
monkeypatch.setattr(config_mod, "ENV_FILE", env_file)
|
|
33
|
+
monkeypatch.setattr(doctor_mod, "CONFIG_FILE", config_file)
|
|
34
|
+
monkeypatch.setattr(doctor_mod, "ENV_FILE", env_file)
|
|
35
|
+
monkeypatch.setattr(doctor_mod, "SECRETS_FILE", secrets_file)
|
|
36
|
+
monkeypatch.setattr(ss, "CONFIG_DIR", tmp_path)
|
|
37
|
+
monkeypatch.setattr(ss, "SECRETS_FILE", secrets_file)
|
|
38
|
+
monkeypatch.setattr(ss, "LEGACY_ENV_FILE", env_file)
|
|
39
|
+
monkeypatch.setattr(ss, "_cached", None)
|
|
40
|
+
# Wide console so long messages don't wrap mid-assertion.
|
|
41
|
+
monkeypatch.setattr(doctor_mod, "_console", Console(width=500))
|
|
42
|
+
monkeypatch.delenv("PG_PRIMARY_PASSWORD", raising=False)
|
|
43
|
+
return tmp_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_config(tmp_path, targets: list[dict]) -> None:
|
|
47
|
+
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"targets": targets}), "utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _seed_secret(monkeypatch, name: str = "primary", value: str = "s3cret") -> None:
|
|
51
|
+
monkeypatch.setenv("POSTGRES_AIOPS_MASTER_PASSWORD", MASTER_PW)
|
|
52
|
+
ss.SecretStore.unlock(MASTER_PW).set(name, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_TARGET = {"name": "primary", "host": "db1.example.com", "port": 5432}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _HealthyManager:
|
|
59
|
+
"""Stands in for ConnectionManager: every connect() succeeds."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, config) -> None:
|
|
62
|
+
self._config = config
|
|
63
|
+
|
|
64
|
+
def connect(self, name):
|
|
65
|
+
class _Conn:
|
|
66
|
+
def query_one(self, sql):
|
|
67
|
+
return {"version": "PostgreSQL 16.4 on x86_64-pc-linux-gnu"}
|
|
68
|
+
|
|
69
|
+
return _Conn()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _UnreachableManager:
|
|
73
|
+
"""Stands in for ConnectionManager: every connect() fails."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config) -> None:
|
|
76
|
+
self._config = config
|
|
77
|
+
|
|
78
|
+
def connect(self, name):
|
|
79
|
+
raise ConnectionError("connection to server at 'db1.example.com' refused")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.unit
|
|
83
|
+
def test_doctor_missing_config_fails_with_init_hint(doctor_home, capsys):
|
|
84
|
+
assert run_doctor() == 1
|
|
85
|
+
out = capsys.readouterr().out
|
|
86
|
+
assert "Config file missing" in out
|
|
87
|
+
assert "postgres-aiops init" in out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.unit
|
|
91
|
+
def test_doctor_config_load_failure_reported_not_raised(doctor_home, capsys):
|
|
92
|
+
(doctor_home / "config.yaml").write_text("targets: [unclosed", "utf-8")
|
|
93
|
+
assert run_doctor() == 1
|
|
94
|
+
assert "Config load failed" in capsys.readouterr().out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.unit
|
|
98
|
+
def test_doctor_no_targets_configured(doctor_home, capsys):
|
|
99
|
+
_write_config(doctor_home, [])
|
|
100
|
+
assert run_doctor() == 1
|
|
101
|
+
assert "No targets configured" in capsys.readouterr().out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.unit
|
|
105
|
+
def test_doctor_all_healthy_exit_zero(doctor_home, monkeypatch, capsys):
|
|
106
|
+
_write_config(doctor_home, [_TARGET])
|
|
107
|
+
_seed_secret(monkeypatch)
|
|
108
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _HealthyManager)
|
|
109
|
+
assert run_doctor() == 0
|
|
110
|
+
out = capsys.readouterr().out
|
|
111
|
+
assert "Config file present" in out
|
|
112
|
+
assert "1 target(s) configured" in out
|
|
113
|
+
assert "Encrypted secret store present" in out
|
|
114
|
+
assert "password present for 'primary'" in out
|
|
115
|
+
assert "Connected to 'primary' (db1.example.com:5432)" in out
|
|
116
|
+
assert "PostgreSQL 16.4" in out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.unit
|
|
120
|
+
def test_doctor_skip_auth_skips_connectivity(doctor_home, monkeypatch, capsys):
|
|
121
|
+
_write_config(doctor_home, [_TARGET])
|
|
122
|
+
_seed_secret(monkeypatch)
|
|
123
|
+
|
|
124
|
+
def _boom(config): # doctor must not even construct a manager
|
|
125
|
+
raise AssertionError("ConnectionManager should not be used with --skip-auth")
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _boom)
|
|
128
|
+
assert run_doctor(skip_auth=True) == 0
|
|
129
|
+
out = capsys.readouterr().out
|
|
130
|
+
assert "Skipping connectivity check" in out
|
|
131
|
+
assert "Connected" not in out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.unit
|
|
135
|
+
def test_doctor_unreachable_target_exit_one(doctor_home, monkeypatch, capsys):
|
|
136
|
+
_write_config(doctor_home, [_TARGET])
|
|
137
|
+
_seed_secret(monkeypatch)
|
|
138
|
+
monkeypatch.setattr(connection_mod, "ConnectionManager", _UnreachableManager)
|
|
139
|
+
assert run_doctor() == 1
|
|
140
|
+
out = capsys.readouterr().out
|
|
141
|
+
assert "Connect to 'primary' failed" in out
|
|
142
|
+
assert "refused" in out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.unit
|
|
146
|
+
def test_doctor_no_secret_store_and_no_password(doctor_home, capsys):
|
|
147
|
+
_write_config(doctor_home, [_TARGET])
|
|
148
|
+
assert run_doctor(skip_auth=True) == 1
|
|
149
|
+
out = capsys.readouterr().out
|
|
150
|
+
assert "No secret store yet" in out
|
|
151
|
+
assert "No password for target 'primary'" in out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.unit
|
|
155
|
+
def test_doctor_legacy_env_file_warns_migrate(doctor_home, monkeypatch, capsys):
|
|
156
|
+
_write_config(doctor_home, [_TARGET])
|
|
157
|
+
(doctor_home / ".env").write_text("PG_PRIMARY_PASSWORD=legacy\n", "utf-8")
|
|
158
|
+
monkeypatch.setenv("PG_PRIMARY_PASSWORD", "legacy")
|
|
159
|
+
assert run_doctor(skip_auth=True) == 0
|
|
160
|
+
out = capsys.readouterr().out
|
|
161
|
+
assert "legacy plaintext .env" in out
|
|
162
|
+
assert "secret migrate" in out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.unit
|
|
166
|
+
def test_doctor_warns_on_loose_secret_permissions(doctor_home, monkeypatch, capsys):
|
|
167
|
+
_write_config(doctor_home, [_TARGET])
|
|
168
|
+
_seed_secret(monkeypatch)
|
|
169
|
+
(doctor_home / "secrets.enc").chmod(0o644)
|
|
170
|
+
assert run_doctor(skip_auth=True) == 0
|
|
171
|
+
assert "should be 600" in capsys.readouterr().out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.unit
|
|
175
|
+
def test_cli_doctor_command_exits_with_doctor_code(doctor_home, monkeypatch):
|
|
176
|
+
from typer.testing import CliRunner
|
|
177
|
+
|
|
178
|
+
from postgres_aiops.cli import app
|
|
179
|
+
|
|
180
|
+
_write_config(doctor_home, [_TARGET])
|
|
181
|
+
_seed_secret(monkeypatch)
|
|
182
|
+
result = CliRunner().invoke(app, ["doctor", "--skip-auth"])
|
|
183
|
+
assert result.exit_code == 0
|
|
184
|
+
assert "Skipping connectivity check" in result.output
|
|
@@ -146,3 +146,25 @@ def test_real_write_tool_persists_priorstate_undo(gov_home, monkeypatch, fake_pg
|
|
|
146
146
|
audit = _rows(gov_home / "audit.db", "audit_log")
|
|
147
147
|
assert [r["tool"] for r in audit] == ["drop_index"]
|
|
148
148
|
assert audit[0]["risk_level"] == "high"
|
|
149
|
+
|
|
150
|
+
@pytest.mark.unit
|
|
151
|
+
def test_sanitized_error_result_is_audited_as_error_and_records_no_undo(gov_home):
|
|
152
|
+
"""@tool_errors converts exceptions to {"error": ...} dicts before the harness
|
|
153
|
+
sees them — those must land in the audit log as status=error (not ok), and no
|
|
154
|
+
undo may be recorded for a call that changed nothing."""
|
|
155
|
+
|
|
156
|
+
@governed_tool(
|
|
157
|
+
risk_level="low",
|
|
158
|
+
undo=lambda p, r: {"tool": "never_recorded", "params": {}},
|
|
159
|
+
)
|
|
160
|
+
def _broken_widget(target: str = "") -> dict:
|
|
161
|
+
return {"error": "boom (sanitized upstream)"}
|
|
162
|
+
|
|
163
|
+
result = _broken_widget(target="t1")
|
|
164
|
+
assert result["error"]
|
|
165
|
+
assert "_undo_id" not in result
|
|
166
|
+
|
|
167
|
+
audit = _rows(gov_home / "audit.db", "audit_log")
|
|
168
|
+
assert audit[-1]["tool"] == "_broken_widget"
|
|
169
|
+
assert audit[-1]["status"] == "error"
|
|
170
|
+
assert not (gov_home / "undo.db").exists() or not _rows(gov_home / "undo.db", "undo_log")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tests for the ``postgres-aiops init`` onboarding wizard.
|
|
2
|
+
|
|
3
|
+
The wizard is driven end-to-end through Typer's CliRunner with every path
|
|
4
|
+
(config.yaml, secrets.enc, rules.yaml) isolated under tmp_path. The master
|
|
5
|
+
password comes from POSTGRES_AIOPS_MASTER_PASSWORD (the non-interactive path)
|
|
6
|
+
and the hidden target-password prompt is patched at the getpass boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass as getpass_mod
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
import yaml
|
|
15
|
+
from typer.testing import CliRunner
|
|
16
|
+
|
|
17
|
+
import postgres_aiops.cli.init as init_mod
|
|
18
|
+
import postgres_aiops.config as config_mod
|
|
19
|
+
import postgres_aiops.doctor as doctor_mod
|
|
20
|
+
import postgres_aiops.secretstore as ss
|
|
21
|
+
|
|
22
|
+
MASTER_PW = "init-master-pw"
|
|
23
|
+
DB_PW = "db-role-pa55word"
|
|
24
|
+
|
|
25
|
+
# Wizard answers: name, host, then accept defaults for port/dbname/user/sslmode,
|
|
26
|
+
# no second target, decline the trailing doctor run.
|
|
27
|
+
WIZARD_INPUT = "primary\ndb1.example.com\n\n\n\n\nn\nn\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def init_home(tmp_path, monkeypatch):
|
|
32
|
+
"""Isolate config + secret store + governance home under tmp_path."""
|
|
33
|
+
config_file = tmp_path / "config.yaml"
|
|
34
|
+
secrets_file = tmp_path / "secrets.enc"
|
|
35
|
+
monkeypatch.setenv("POSTGRES_AIOPS_HOME", str(tmp_path))
|
|
36
|
+
monkeypatch.setenv("POSTGRES_AIOPS_MASTER_PASSWORD", MASTER_PW)
|
|
37
|
+
monkeypatch.setattr(init_mod, "CONFIG_DIR", tmp_path)
|
|
38
|
+
monkeypatch.setattr(init_mod, "CONFIG_FILE", config_file)
|
|
39
|
+
monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
|
|
40
|
+
monkeypatch.setattr(config_mod, "CONFIG_FILE", config_file)
|
|
41
|
+
monkeypatch.setattr(ss, "CONFIG_DIR", tmp_path)
|
|
42
|
+
monkeypatch.setattr(ss, "SECRETS_FILE", secrets_file)
|
|
43
|
+
monkeypatch.setattr(ss, "LEGACY_ENV_FILE", tmp_path / ".env")
|
|
44
|
+
monkeypatch.setattr(ss, "_cached", None)
|
|
45
|
+
# The hidden per-target password prompt bypasses CliRunner stdin.
|
|
46
|
+
monkeypatch.setattr(getpass_mod, "getpass", lambda prompt="": DB_PW)
|
|
47
|
+
return tmp_path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_init(input_text: str = WIZARD_INPUT):
|
|
51
|
+
from postgres_aiops.cli import app
|
|
52
|
+
|
|
53
|
+
return CliRunner().invoke(app, ["init"], input=input_text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.unit
|
|
57
|
+
def test_init_writes_config_with_entered_values(init_home):
|
|
58
|
+
result = _run_init()
|
|
59
|
+
assert result.exit_code == 0, result.output
|
|
60
|
+
raw = yaml.safe_load((init_home / "config.yaml").read_text("utf-8"))
|
|
61
|
+
assert raw["targets"] == [
|
|
62
|
+
{
|
|
63
|
+
"name": "primary",
|
|
64
|
+
"host": "db1.example.com",
|
|
65
|
+
"port": 5432,
|
|
66
|
+
"dbname": "postgres",
|
|
67
|
+
"user": "postgres",
|
|
68
|
+
"sslmode": "prefer", # accepted default must land in config
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.unit
|
|
74
|
+
def test_init_stores_secret_encrypted_not_in_config(init_home):
|
|
75
|
+
result = _run_init()
|
|
76
|
+
assert result.exit_code == 0, result.output
|
|
77
|
+
# Password is readable back through the secret store API...
|
|
78
|
+
assert ss.SecretStore.unlock(MASTER_PW).get("primary") == DB_PW
|
|
79
|
+
# ...and never lands in plaintext in config.yaml or secrets.enc.
|
|
80
|
+
assert DB_PW not in (init_home / "config.yaml").read_text("utf-8")
|
|
81
|
+
assert DB_PW not in (init_home / "secrets.enc").read_text("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.unit
|
|
85
|
+
def test_init_seeds_default_rules_with_dual_control_tier(init_home):
|
|
86
|
+
result = _run_init()
|
|
87
|
+
assert result.exit_code == 0, result.output
|
|
88
|
+
rules = yaml.safe_load((init_home / "rules.yaml").read_text("utf-8"))
|
|
89
|
+
tiers = {r["name"]: r for r in rules["risk_tiers"]}
|
|
90
|
+
assert "high-risk-requires-approver" in tiers
|
|
91
|
+
assert tiers["high-risk-requires-approver"]["tier"] == "dual"
|
|
92
|
+
assert tiers["high-risk-requires-approver"]["min_risk_level"] == "high"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.unit
|
|
96
|
+
def test_init_rerun_does_not_clobber_existing_rules(init_home):
|
|
97
|
+
sentinel = "# operator-authored rules — must survive re-init\nrisk_tiers: []\n"
|
|
98
|
+
(init_home / "rules.yaml").write_text(sentinel, "utf-8")
|
|
99
|
+
result = _run_init()
|
|
100
|
+
assert result.exit_code == 0, result.output
|
|
101
|
+
assert (init_home / "rules.yaml").read_text("utf-8") == sentinel
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.unit
|
|
105
|
+
def test_init_accepting_doctor_confirm_runs_doctor(init_home, monkeypatch):
|
|
106
|
+
calls: list[bool] = []
|
|
107
|
+
monkeypatch.setattr(doctor_mod, "run_doctor", lambda: calls.append(True) or 0)
|
|
108
|
+
# Empty last answer accepts the confirm's default=True.
|
|
109
|
+
result = _run_init("primary\ndb1.example.com\n\n\n\n\nn\n\n")
|
|
110
|
+
assert result.exit_code == 0, result.output
|
|
111
|
+
assert calls == [True]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.unit
|
|
115
|
+
def test_init_overwrite_existing_target(init_home):
|
|
116
|
+
result = _run_init()
|
|
117
|
+
assert result.exit_code == 0, result.output
|
|
118
|
+
# Same name again: confirm overwrite, new host, accept defaults.
|
|
119
|
+
result = _run_init("primary\ny\ndb2.example.com\n\n\n\n\nn\nn\n")
|
|
120
|
+
assert result.exit_code == 0, result.output
|
|
121
|
+
raw = yaml.safe_load((init_home / "config.yaml").read_text("utf-8"))
|
|
122
|
+
assert [t["host"] for t in raw["targets"]] == ["db2.example.com"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""drop_index → create_index(definition=…) undo REPLAY — the descriptor recorded
|
|
2
|
+
by _drop_index_undo must be executable by the tool it names (found broken in the
|
|
3
|
+
line-wide undo-replayability sweep: create_index had no ``definition`` param)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from mcp_server.tools import remediation as gov
|
|
10
|
+
from postgres_aiops.ops import remediation as ops
|
|
11
|
+
|
|
12
|
+
INDEXDEF = 'CREATE UNIQUE INDEX idx_orders_ts ON public.orders USING btree (ts)'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.unit
|
|
16
|
+
def test_drop_index_descriptor_replays_through_create_index(fake_pg, monkeypatch):
|
|
17
|
+
fake = fake_pg(scalars={"pg_get_indexdef": INDEXDEF})
|
|
18
|
+
monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
|
|
19
|
+
|
|
20
|
+
dropped = gov.drop_index(name="idx_orders_ts")
|
|
21
|
+
assert dropped["priorState"]["indexdef"] == INDEXDEF
|
|
22
|
+
|
|
23
|
+
descriptor = gov._drop_index_undo({"name": "idx_orders_ts"}, dropped)
|
|
24
|
+
assert descriptor["tool"] == "create_index"
|
|
25
|
+
|
|
26
|
+
replay = gov.create_index(**descriptor["params"])
|
|
27
|
+
assert replay["index"] == "idx_orders_ts"
|
|
28
|
+
assert replay["fromDefinition"] is True
|
|
29
|
+
assert (INDEXDEF, None) in fake.executed
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.unit
|
|
33
|
+
def test_create_index_definition_and_columns_are_mutually_exclusive(fake_pg, monkeypatch):
|
|
34
|
+
fake = fake_pg()
|
|
35
|
+
monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
|
|
36
|
+
result = gov.create_index(table="t", columns=["a"], definition=INDEXDEF)
|
|
37
|
+
assert "not both" in result["error"]
|
|
38
|
+
result = gov.create_index()
|
|
39
|
+
assert "requires table+columns" in result["error"]
|
|
40
|
+
assert fake.executed == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.unit
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
"bad",
|
|
46
|
+
[
|
|
47
|
+
"DROP TABLE users",
|
|
48
|
+
"CREATE INDEX a ON t (c); DROP TABLE users",
|
|
49
|
+
"SELECT 1",
|
|
50
|
+
"",
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
def test_create_index_from_definition_rejects_non_indexdef_shapes(fake_pg, bad):
|
|
54
|
+
fake = fake_pg()
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
ops.create_index_from_definition(fake, bad)
|
|
57
|
+
assert fake.executed == []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.unit
|
|
61
|
+
def test_create_index_definition_dry_run_executes_nothing(fake_pg, monkeypatch):
|
|
62
|
+
fake = fake_pg()
|
|
63
|
+
monkeypatch.setattr(gov, "_get_connection", lambda target=None: fake)
|
|
64
|
+
result = gov.create_index(definition=INDEXDEF, dry_run=True)
|
|
65
|
+
assert result["dryRun"] is True and result["wouldExecute"] == INDEXDEF
|
|
66
|
+
assert fake.executed == []
|
|
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.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/capabilities.md
RENAMED
|
File without changes
|
{postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/skills/postgres-aiops/references/cli-reference.md
RENAMED
|
File without changes
|
{postgres_aiops-0.2.0 → postgres_aiops-0.2.1}/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
|