postgres-aiops 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,53 @@
1
+ """``postgres-aiops analyze`` — the three flagship analyses (pull live)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ analyze_app = typer.Typer(
13
+ name="analyze",
14
+ help="Flagship analyses: slow-query RCA, bloat/vacuum, blocking chains.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @analyze_app.command("slow-query")
20
+ @cli_errors
21
+ def analyze_slow_query(
22
+ explain_sql: Annotated[str | None, typer.Option("--explain", help="SQL to EXPLAIN")] = None,
23
+ target: TargetOption = None,
24
+ ) -> None:
25
+ """Root-cause the worst pg_stat_statements entry."""
26
+ from postgres_aiops.ops import analysis, queries
27
+
28
+ conn, _ = get_connection(target)
29
+ statements = queries.top_queries(conn, order_by="total_time")["statements"]
30
+ explain = queries.explain_query(conn, explain_sql) if explain_sql else None
31
+ console.print_json(json.dumps(analysis.slow_query_rca(statements, explain=explain)))
32
+
33
+
34
+ @analyze_app.command("bloat-vacuum")
35
+ @cli_errors
36
+ def analyze_bloat_vacuum(target: TargetOption = None) -> None:
37
+ """Rank tables needing vacuum from dead-tuple ratio + autovacuum recency."""
38
+ from postgres_aiops.ops import analysis, tables
39
+
40
+ conn, _ = get_connection(target)
41
+ rows = tables.table_bloat(conn)["tables"]
42
+ console.print_json(json.dumps(analysis.bloat_and_vacuum_analysis(rows)))
43
+
44
+
45
+ @analyze_app.command("blocking")
46
+ @cli_errors
47
+ def analyze_blocking(target: TargetOption = None) -> None:
48
+ """Build the blocking-lock chain and name the root blocker."""
49
+ from postgres_aiops.ops import activity, analysis
50
+
51
+ conn, _ = get_connection(target)
52
+ pairs = activity.blocking_pairs(conn)
53
+ console.print_json(json.dumps(analysis.blocking_lock_chain_rca(pairs)))
@@ -0,0 +1,21 @@
1
+ """Doctor top-level command: environment and connectivity check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from postgres_aiops.cli._common import cli_errors
10
+
11
+
12
+ @cli_errors
13
+ def doctor_cmd(
14
+ skip_auth: Annotated[
15
+ bool, typer.Option("--skip-auth", help="Skip connectivity check (faster)")
16
+ ] = False,
17
+ ) -> None:
18
+ """Check environment, config, secrets, and connectivity."""
19
+ from postgres_aiops.doctor import run_doctor
20
+
21
+ raise typer.Exit(run_doctor(skip_auth=skip_auth))
@@ -0,0 +1,55 @@
1
+ """``postgres-aiops index`` — index-health reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
10
+
11
+ index_app = typer.Typer(
12
+ name="index",
13
+ help="Index health: unused, missing, bloat, invalid.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @index_app.command("unused")
19
+ @cli_errors
20
+ def index_unused(target: TargetOption = None) -> None:
21
+ """Non-unique, non-primary indexes with zero scans."""
22
+ from postgres_aiops.ops import indexes as ops
23
+
24
+ conn, _ = get_connection(target)
25
+ console.print_json(json.dumps(ops.unused_indexes(conn)))
26
+
27
+
28
+ @index_app.command("missing")
29
+ @cli_errors
30
+ def index_missing(target: TargetOption = None) -> None:
31
+ """Tables with heavy sequential scans (missing-index hints)."""
32
+ from postgres_aiops.ops import indexes as ops
33
+
34
+ conn, _ = get_connection(target)
35
+ console.print_json(json.dumps(ops.missing_index_hints(conn)))
36
+
37
+
38
+ @index_app.command("bloat")
39
+ @cli_errors
40
+ def index_bloat(target: TargetOption = None) -> None:
41
+ """Coarse index-bloat estimate."""
42
+ from postgres_aiops.ops import indexes as ops
43
+
44
+ conn, _ = get_connection(target)
45
+ console.print_json(json.dumps(ops.index_bloat(conn)))
46
+
47
+
48
+ @index_app.command("invalid")
49
+ @cli_errors
50
+ def index_invalid(target: TargetOption = None) -> None:
51
+ """Invalid and duplicate indexes."""
52
+ from postgres_aiops.ops import indexes as ops
53
+
54
+ conn, _ = get_connection(target)
55
+ console.print_json(json.dumps(ops.invalid_indexes(conn)))
@@ -0,0 +1,112 @@
1
+ """``postgres-aiops init`` — a friendly, interactive onboarding wizard.
2
+
3
+ Walks a new user through connecting their first PostgreSQL target: collects the
4
+ non-secret connection details into ``config.yaml`` and the role password into the
5
+ *encrypted* store (never plaintext on disk). Designed to be run on a terminal;
6
+ everything it needs is prompted with sensible defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import getpass
12
+
13
+ import typer
14
+ import yaml
15
+
16
+ from postgres_aiops.cli._common import cli_errors, console
17
+ from postgres_aiops.config import (
18
+ CONFIG_DIR,
19
+ CONFIG_FILE,
20
+ DEFAULT_DBNAME,
21
+ DEFAULT_PORT,
22
+ DEFAULT_SSLMODE,
23
+ DEFAULT_USER,
24
+ )
25
+ from postgres_aiops.secretstore import SecretStore, resolve_master_password
26
+
27
+
28
+ def _load_existing_targets() -> list[dict]:
29
+ if not CONFIG_FILE.exists():
30
+ return []
31
+ raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
32
+ return list(raw.get("targets", []))
33
+
34
+
35
+ def _write_targets(targets: list[dict]) -> None:
36
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
37
+ try:
38
+ CONFIG_DIR.chmod(0o700)
39
+ except OSError:
40
+ pass
41
+ CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
42
+
43
+
44
+ @cli_errors
45
+ def init_cmd() -> None:
46
+ """Interactively set up your first PostgreSQL connection."""
47
+ console.print("[bold cyan]Postgres AIops — setup wizard[/]")
48
+ console.print(
49
+ "This collects connection details (saved to config.yaml) and your "
50
+ "PostgreSQL password (saved [bold]encrypted[/] to secrets.enc).\n"
51
+ )
52
+
53
+ console.print("[bold]Step 1 — master password[/]")
54
+ console.print(
55
+ "[dim]Encrypts secrets.enc. You'll set it via the "
56
+ "POSTGRES_AIOPS_MASTER_PASSWORD env var for non-interactive/MCP use.[/]"
57
+ )
58
+ password = resolve_master_password(confirm_if_new=True)
59
+ store = SecretStore.unlock(password)
60
+
61
+ targets = _load_existing_targets()
62
+ existing_names = {t.get("name") for t in targets}
63
+
64
+ while True:
65
+ console.print("\n[bold]Step 2 — add a target[/]")
66
+ name = typer.prompt("Target name (e.g. primary)").strip()
67
+ if name in existing_names:
68
+ if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
69
+ continue
70
+ targets = [t for t in targets if t.get("name") != name]
71
+
72
+ host = typer.prompt("Host (IP or FQDN of the PostgreSQL server)").strip()
73
+ port = typer.prompt("Port", default=DEFAULT_PORT, type=int)
74
+ dbname = typer.prompt("Database name", default=DEFAULT_DBNAME).strip()
75
+ user = typer.prompt("Role/user", default=DEFAULT_USER).strip()
76
+ sslmode = typer.prompt(
77
+ "sslmode (disable/allow/prefer/require/verify-ca/verify-full)",
78
+ default=DEFAULT_SSLMODE,
79
+ ).strip()
80
+
81
+ console.print(
82
+ "[dim]The role should have pg_monitor for read visibility. "
83
+ "Enter its password below (input hidden).[/]"
84
+ )
85
+ secret = getpass.getpass(f"Password for '{user}'@'{name}' (hidden): ")
86
+ store = store.set(name, secret)
87
+
88
+ entry = {
89
+ "name": name,
90
+ "host": host,
91
+ "port": port,
92
+ "dbname": dbname,
93
+ "user": user,
94
+ "sslmode": sslmode,
95
+ }
96
+ targets.append(entry)
97
+ existing_names.add(name)
98
+ _write_targets(targets)
99
+ console.print(f"[green]✓ Saved target '{name}' (password stored encrypted).[/]")
100
+
101
+ if not typer.confirm("\nAdd another target?", default=False):
102
+ break
103
+
104
+ console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
105
+ console.print(
106
+ "[dim]Tip: export POSTGRES_AIOPS_MASTER_PASSWORD=... in your shell profile "
107
+ "so the MCP server and CLI can unlock secrets non-interactively.[/]"
108
+ )
109
+ if typer.confirm("Run a connectivity check now (postgres-aiops doctor)?", default=True):
110
+ from postgres_aiops.doctor import run_doctor
111
+
112
+ raise typer.Exit(run_doctor())
@@ -0,0 +1,16 @@
1
+ """``postgres-aiops overview`` — one-shot cluster health snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
8
+
9
+
10
+ @cli_errors
11
+ def overview_cmd(target: TargetOption = None) -> None:
12
+ """One-shot cluster health: version, connections, long queries, bloat, replication."""
13
+ from postgres_aiops.ops import overview as ops
14
+
15
+ conn, _ = get_connection(target)
16
+ console.print_json(json.dumps(ops.snapshot(conn)))
@@ -0,0 +1,70 @@
1
+ """``postgres-aiops query`` — pg_stat_statements top-N, EXPLAIN, stats reset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from postgres_aiops.cli._common import (
11
+ DryRunOption,
12
+ TargetOption,
13
+ cli_errors,
14
+ console,
15
+ double_confirm,
16
+ dry_run_print,
17
+ get_connection,
18
+ )
19
+
20
+ query_app = typer.Typer(
21
+ name="query",
22
+ help="Query stats: top-N, EXPLAIN, reset.",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ @query_app.command("top")
28
+ @cli_errors
29
+ def query_top(
30
+ order_by: Annotated[
31
+ str, typer.Option("--order-by", help="total_time|mean_time|calls|rows|io")
32
+ ] = "total_time",
33
+ limit: Annotated[int, typer.Option("--limit", help="Rows to return")] = 20,
34
+ target: TargetOption = None,
35
+ ) -> None:
36
+ """Top statements from pg_stat_statements."""
37
+ from postgres_aiops.ops import queries as ops
38
+
39
+ conn, _ = get_connection(target)
40
+ console.print_json(json.dumps(ops.top_queries(conn, order_by=order_by, limit=limit)))
41
+
42
+
43
+ @query_app.command("explain")
44
+ @cli_errors
45
+ def query_explain(
46
+ sql: Annotated[str, typer.Argument(help="A single SQL statement to EXPLAIN")],
47
+ analyze: Annotated[
48
+ bool, typer.Option("--analyze", help="Execute to gather real timing")
49
+ ] = False,
50
+ target: TargetOption = None,
51
+ ) -> None:
52
+ """Return the JSON execution plan for a statement."""
53
+ from postgres_aiops.ops import queries as ops
54
+
55
+ conn, _ = get_connection(target)
56
+ console.print_json(json.dumps(ops.explain_query(conn, sql, analyze=analyze)))
57
+
58
+
59
+ @query_app.command("reset")
60
+ @cli_errors
61
+ def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> None:
62
+ """Reset pg_stat_statements accumulators (irreversible; dry-run + confirm)."""
63
+ from postgres_aiops.ops import queries as ops
64
+
65
+ if dry_run:
66
+ dry_run_print(operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
67
+ return
68
+ double_confirm("reset pg_stat_statements on", "this target")
69
+ conn, _ = get_connection(target)
70
+ console.print_json(json.dumps(ops.reset_query_stats(conn)))
@@ -0,0 +1,186 @@
1
+ """``postgres-aiops remediate`` — guarded maintenance writes (dry-run + confirm)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from postgres_aiops.cli._common import (
11
+ DryRunOption,
12
+ TargetOption,
13
+ cli_errors,
14
+ console,
15
+ double_confirm,
16
+ dry_run_print,
17
+ get_connection,
18
+ )
19
+
20
+ remediate_app = typer.Typer(
21
+ name="remediate",
22
+ help="Guarded writes: terminate/cancel, vacuum/analyze, index ops, ALTER SYSTEM.",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ @remediate_app.command("terminate")
28
+ @cli_errors
29
+ def remediate_terminate(
30
+ pid: Annotated[int, typer.Argument(help="Backend pid to terminate")],
31
+ target: TargetOption = None,
32
+ dry_run: DryRunOption = False,
33
+ ) -> None:
34
+ """Terminate a backend (no undo; dry-run + confirm)."""
35
+ from postgres_aiops.ops import remediation as ops
36
+
37
+ if dry_run:
38
+ dry_run_print(operation="terminate_backend",
39
+ api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
40
+ return
41
+ double_confirm("terminate backend", str(pid))
42
+ conn, _ = get_connection(target)
43
+ console.print_json(json.dumps(ops.terminate_backend(conn, pid)))
44
+
45
+
46
+ @remediate_app.command("cancel")
47
+ @cli_errors
48
+ def remediate_cancel(
49
+ pid: Annotated[int, typer.Argument(help="Backend pid whose query to cancel")],
50
+ target: TargetOption = None,
51
+ dry_run: DryRunOption = False,
52
+ ) -> None:
53
+ """Cancel a backend's running query (no undo; dry-run + confirm)."""
54
+ from postgres_aiops.ops import remediation as ops
55
+
56
+ if dry_run:
57
+ dry_run_print(operation="cancel_query",
58
+ api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
59
+ return
60
+ double_confirm("cancel query on backend", str(pid))
61
+ conn, _ = get_connection(target)
62
+ console.print_json(json.dumps(ops.cancel_query(conn, pid)))
63
+
64
+
65
+ @remediate_app.command("vacuum")
66
+ @cli_errors
67
+ def remediate_vacuum(
68
+ table: Annotated[str, typer.Argument(help="Table (optionally schema-qualified)")],
69
+ full: Annotated[bool, typer.Option("--full", help="VACUUM FULL (exclusive lock)")] = False,
70
+ analyze: Annotated[bool, typer.Option("--analyze", help="Also ANALYZE")] = False,
71
+ target: TargetOption = None,
72
+ dry_run: DryRunOption = False,
73
+ ) -> None:
74
+ """VACUUM a table (dry-run + confirm)."""
75
+ from postgres_aiops.ops import remediation as ops
76
+
77
+ if dry_run:
78
+ dry_run_print(operation="run_vacuum", api_call=f"VACUUM {table}",
79
+ parameters={"full": full, "analyze": analyze})
80
+ return
81
+ double_confirm("VACUUM", table)
82
+ conn, _ = get_connection(target)
83
+ console.print_json(json.dumps(ops.run_vacuum(conn, table, full=full, analyze=analyze)))
84
+
85
+
86
+ @remediate_app.command("analyze-table")
87
+ @cli_errors
88
+ def remediate_analyze(
89
+ table: Annotated[str, typer.Argument(help="Table (optionally schema-qualified)")],
90
+ target: TargetOption = None,
91
+ dry_run: DryRunOption = False,
92
+ ) -> None:
93
+ """ANALYZE a table (dry-run + confirm)."""
94
+ from postgres_aiops.ops import remediation as ops
95
+
96
+ if dry_run:
97
+ dry_run_print(operation="run_analyze", api_call=f"ANALYZE {table}")
98
+ return
99
+ double_confirm("ANALYZE", table)
100
+ conn, _ = get_connection(target)
101
+ console.print_json(json.dumps(ops.run_analyze(conn, table)))
102
+
103
+
104
+ @remediate_app.command("create-index")
105
+ @cli_errors
106
+ def remediate_create_index(
107
+ table: Annotated[str, typer.Argument(help="Table to index")],
108
+ columns: Annotated[list[str], typer.Argument(help="Column(s) to index")],
109
+ name: Annotated[str | None, typer.Option("--name", help="Index name")] = None,
110
+ unique: Annotated[bool, typer.Option("--unique", help="UNIQUE index")] = False,
111
+ concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
112
+ target: TargetOption = None,
113
+ dry_run: DryRunOption = False,
114
+ ) -> None:
115
+ """Create an index (reversible; dry-run + confirm)."""
116
+ from postgres_aiops.ops import remediation as ops
117
+
118
+ if dry_run:
119
+ dry_run_print(operation="create_index", api_call=f"CREATE INDEX ON {table}",
120
+ parameters={"columns": columns, "name": name, "unique": unique})
121
+ return
122
+ double_confirm("create index on", table)
123
+ conn, _ = get_connection(target)
124
+ result = ops.create_index(conn, table, columns, name=name, unique=unique,
125
+ concurrently=concurrently)
126
+ console.print_json(json.dumps(result))
127
+
128
+
129
+ @remediate_app.command("drop-index")
130
+ @cli_errors
131
+ def remediate_drop_index(
132
+ name: Annotated[str, typer.Argument(help="Index name to drop")],
133
+ concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
134
+ target: TargetOption = None,
135
+ dry_run: DryRunOption = False,
136
+ ) -> None:
137
+ """Drop an index (reversible; captures the definition first; dry-run + confirm)."""
138
+ from postgres_aiops.ops import remediation as ops
139
+
140
+ if dry_run:
141
+ dry_run_print(operation="drop_index", api_call=f"DROP INDEX {name}")
142
+ return
143
+ double_confirm("drop index", name)
144
+ conn, _ = get_connection(target)
145
+ console.print_json(json.dumps(ops.drop_index(conn, name, concurrently=concurrently)))
146
+
147
+
148
+ @remediate_app.command("reindex")
149
+ @cli_errors
150
+ def remediate_reindex(
151
+ target_name: Annotated[str, typer.Argument(help="Index/table/schema name")],
152
+ kind: Annotated[str, typer.Option("--kind", help="INDEX|TABLE|SCHEMA")] = "INDEX",
153
+ concurrently: Annotated[bool, typer.Option("--concurrently", help="CONCURRENTLY")] = False,
154
+ target: TargetOption = None,
155
+ dry_run: DryRunOption = False,
156
+ ) -> None:
157
+ """REINDEX an index/table/schema (dry-run + confirm)."""
158
+ from postgres_aiops.ops import remediation as ops
159
+
160
+ if dry_run:
161
+ dry_run_print(operation="reindex", api_call=f"REINDEX {kind} {target_name}")
162
+ return
163
+ double_confirm(f"REINDEX {kind}", target_name)
164
+ conn, _ = get_connection(target)
165
+ console.print_json(json.dumps(ops.reindex(conn, target_name, kind=kind,
166
+ concurrently=concurrently)))
167
+
168
+
169
+ @remediate_app.command("set")
170
+ @cli_errors
171
+ def remediate_set(
172
+ name: Annotated[str, typer.Argument(help="Parameter name (e.g. work_mem)")],
173
+ value: Annotated[str, typer.Argument(help="New value")],
174
+ target: TargetOption = None,
175
+ dry_run: DryRunOption = False,
176
+ ) -> None:
177
+ """ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
178
+ from postgres_aiops.ops import remediation as ops
179
+
180
+ if dry_run:
181
+ dry_run_print(operation="update_setting",
182
+ api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
183
+ return
184
+ double_confirm(f"ALTER SYSTEM SET {name} =", value)
185
+ conn, _ = get_connection(target)
186
+ console.print_json(json.dumps(ops.update_setting(conn, name, value)))
@@ -0,0 +1,45 @@
1
+ """``postgres-aiops repl`` — replication reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from postgres_aiops.cli._common import TargetOption, cli_errors, console, get_connection
10
+
11
+ repl_app = typer.Typer(
12
+ name="repl",
13
+ help="Replication: status, slots, WAL.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @repl_app.command("status")
19
+ @cli_errors
20
+ def repl_status(target: TargetOption = None) -> None:
21
+ """Connected standbys and replay lag."""
22
+ from postgres_aiops.ops import replication as ops
23
+
24
+ conn, _ = get_connection(target)
25
+ console.print_json(json.dumps(ops.replication_status(conn)))
26
+
27
+
28
+ @repl_app.command("slots")
29
+ @cli_errors
30
+ def repl_slots(target: TargetOption = None) -> None:
31
+ """Replication slots (inactive slots flagged)."""
32
+ from postgres_aiops.ops import replication as ops
33
+
34
+ conn, _ = get_connection(target)
35
+ console.print_json(json.dumps(ops.replication_slots(conn)))
36
+
37
+
38
+ @repl_app.command("wal")
39
+ @cli_errors
40
+ def repl_wal(target: TargetOption = None) -> None:
41
+ """WAL position, level and archiver health."""
42
+ from postgres_aiops.ops import replication as ops
43
+
44
+ conn, _ = get_connection(target)
45
+ console.print_json(json.dumps(ops.wal_status(conn)))
@@ -0,0 +1,103 @@
1
+ """``postgres-aiops secret`` — manage the encrypted credential store.
2
+
3
+ Secrets (PostgreSQL role passwords) are stored in ``~/.postgres-aiops/secrets.enc``
4
+ (Fernet, key derived from a master password via scrypt). Nothing here ever
5
+ prints a secret value.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from postgres_aiops.cli._common import cli_errors, console
16
+ from postgres_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
17
+ from postgres_aiops.secretstore import (
18
+ SECRETS_FILE,
19
+ SecretStore,
20
+ check_permissions,
21
+ migrate_legacy_env,
22
+ resolve_master_password,
23
+ )
24
+
25
+ secret_app = typer.Typer(
26
+ name="secret",
27
+ help="Manage the encrypted credential store (secrets.enc).",
28
+ no_args_is_help=True,
29
+ )
30
+
31
+ NameArg = Annotated[str, typer.Argument(help="Target name the password belongs to")]
32
+
33
+
34
+ @secret_app.command("set")
35
+ @cli_errors
36
+ def secret_set(
37
+ name: NameArg,
38
+ value: Annotated[
39
+ str | None,
40
+ typer.Option("--value", help="Password value (omit to be prompted, hidden)"),
41
+ ] = None,
42
+ ) -> None:
43
+ """Store (or replace) the password for a target — value is read hidden."""
44
+ password = resolve_master_password(confirm_if_new=True)
45
+ if value is None:
46
+ value = getpass.getpass(f"Password for '{name}' (hidden): ")
47
+ store = SecretStore.unlock(password)
48
+ store.set(name, value)
49
+ console.print(f"[green]✓ Stored encrypted password for '{name}' in {SECRETS_FILE}[/]")
50
+
51
+
52
+ @secret_app.command("list")
53
+ @cli_errors
54
+ def secret_list() -> None:
55
+ """List target names that have a stored password (values never shown)."""
56
+ store = SecretStore.unlock()
57
+ names = store.names()
58
+ if not names:
59
+ console.print("[yellow]No secrets stored yet. Add one: postgres-aiops secret set <name>[/]")
60
+ return
61
+ console.print("[bold]Stored secrets:[/]")
62
+ for n in names:
63
+ console.print(f" • {n}")
64
+ warning = check_permissions()
65
+ if warning:
66
+ console.print(f"[yellow]! {warning}[/]")
67
+
68
+
69
+ @secret_app.command("rm")
70
+ @cli_errors
71
+ def secret_rm(name: NameArg) -> None:
72
+ """Delete a stored password."""
73
+ store = SecretStore.unlock()
74
+ store.delete(name)
75
+ console.print(f"[green]✓ Deleted password for '{name}'[/]")
76
+
77
+
78
+ @secret_app.command("migrate")
79
+ @cli_errors
80
+ def secret_migrate() -> None:
81
+ """Import passwords from a legacy plaintext .env into the encrypted store."""
82
+ password = resolve_master_password(confirm_if_new=True)
83
+ imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
84
+ if not imported:
85
+ console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
86
+ return
87
+ console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
88
+ console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
89
+
90
+
91
+ @secret_app.command("rotate-password")
92
+ @cli_errors
93
+ def secret_rotate_password() -> None:
94
+ """Re-encrypt the whole store under a new master password."""
95
+ console.print("[bold]Unlock with the current master password:[/]")
96
+ store = SecretStore.unlock()
97
+ new_pw = getpass.getpass("New master password: ")
98
+ confirm = getpass.getpass("Confirm new master password: ")
99
+ if new_pw != confirm:
100
+ console.print("[red]Passwords did not match. Aborted.[/]")
101
+ raise typer.Exit(1)
102
+ store.with_password(new_pw)
103
+ console.print("[green]✓ Master password rotated. Update POSTGRES_AIOPS_MASTER_PASSWORD.[/]")