queue-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 (48) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +107 -0
  3. mcp_server/server.py +38 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/analysis.py +148 -0
  6. mcp_server/tools/overview.py +23 -0
  7. mcp_server/tools/rabbit_reads.py +95 -0
  8. mcp_server/tools/redis_reads.py +98 -0
  9. mcp_server/tools/undo.py +121 -0
  10. mcp_server/tools/writes.py +355 -0
  11. queue_aiops/__init__.py +14 -0
  12. queue_aiops/cli/__init__.py +9 -0
  13. queue_aiops/cli/_common.py +78 -0
  14. queue_aiops/cli/_root.py +62 -0
  15. queue_aiops/cli/analyze.py +85 -0
  16. queue_aiops/cli/doctor.py +21 -0
  17. queue_aiops/cli/init.py +176 -0
  18. queue_aiops/cli/overview.py +16 -0
  19. queue_aiops/cli/rabbit_cmds.py +234 -0
  20. queue_aiops/cli/redis_cmds.py +147 -0
  21. queue_aiops/cli/secret.py +105 -0
  22. queue_aiops/cli/undo.py +62 -0
  23. queue_aiops/config.py +175 -0
  24. queue_aiops/connection.py +277 -0
  25. queue_aiops/doctor.py +98 -0
  26. queue_aiops/governance/__init__.py +40 -0
  27. queue_aiops/governance/audit.py +377 -0
  28. queue_aiops/governance/budget.py +225 -0
  29. queue_aiops/governance/decorators.py +482 -0
  30. queue_aiops/governance/paths.py +23 -0
  31. queue_aiops/governance/patterns.py +378 -0
  32. queue_aiops/governance/policy.py +430 -0
  33. queue_aiops/governance/sanitize.py +45 -0
  34. queue_aiops/governance/undo.py +218 -0
  35. queue_aiops/ops/__init__.py +1 -0
  36. queue_aiops/ops/_util.py +55 -0
  37. queue_aiops/ops/analysis.py +591 -0
  38. queue_aiops/ops/overview.py +90 -0
  39. queue_aiops/ops/rabbit_reads.py +218 -0
  40. queue_aiops/ops/redis_reads.py +237 -0
  41. queue_aiops/ops/writes.py +284 -0
  42. queue_aiops/platform.py +217 -0
  43. queue_aiops/secretstore.py +304 -0
  44. queue_aiops-0.1.0.dist-info/METADATA +168 -0
  45. queue_aiops-0.1.0.dist-info/RECORD +48 -0
  46. queue_aiops-0.1.0.dist-info/WHEEL +4 -0
  47. queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  48. queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,176 @@
1
+ """``queue-aiops init`` — a friendly, interactive onboarding wizard.
2
+
3
+ Walks a new user through connecting their first broker target: collects the
4
+ non-secret connection details into ``config.yaml`` and the secret into the
5
+ *encrypted* store (never plaintext on disk). For a redis target the password is
6
+ optional — an auth-less lab instance is a legitimate setup — while a rabbitmq
7
+ target always needs the management user's password. Designed to be run on a
8
+ terminal; everything it needs is prompted with sensible defaults.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import getpass
14
+
15
+ import typer
16
+ import yaml
17
+
18
+ from queue_aiops.cli._common import cli_errors, console
19
+ from queue_aiops.config import CONFIG_DIR, CONFIG_FILE
20
+ from queue_aiops.governance.paths import ops_path
21
+ from queue_aiops.platform import RABBITMQ, REDIS, get_platform
22
+
23
+ # Starter policy: keeps the secure-by-default gate (high/critical writes need a
24
+ # named approver) explicit and editable, and shows the other rule kinds.
25
+ DEFAULT_RULES_YAML = """\
26
+ # queue-aiops policy rules — hot-reloaded on change (no restart needed).
27
+ # Kinds: deny rules, maintenance_window, risk_tiers (graduated autonomy).
28
+
29
+ risk_tiers:
30
+ - name: high-risk-requires-approver
31
+ tier: dual
32
+ min_risk_level: high
33
+ reason: >-
34
+ High/critical writes need a named human approver — set
35
+ QUEUE_AUDIT_APPROVED_BY (and QUEUE_AUDIT_RATIONALE) before the call.
36
+
37
+ # deny:
38
+ # - name: no-prod-purges
39
+ # operations: ["purge_queue", "delete_queue"]
40
+ # environments: ["production"]
41
+ # reason: "Destroying queued messages in production goes through change management."
42
+
43
+ # maintenance_window:
44
+ # start: "22:00"
45
+ # end: "06:00"
46
+ """
47
+
48
+
49
+ def _write_default_rules() -> None:
50
+ """Seed a starter rules.yaml (only when none exists) so the policy layer
51
+ is explicit from day one; never overwrites an operator-authored file."""
52
+ rules_path = ops_path("rules.yaml")
53
+ if rules_path.exists():
54
+ return
55
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
56
+ rules_path.write_text(DEFAULT_RULES_YAML, "utf-8")
57
+ console.print(f"[green]✓ Wrote default policy rules:[/] {rules_path}")
58
+
59
+
60
+ def _load_existing_targets() -> list[dict]:
61
+ if not CONFIG_FILE.exists():
62
+ return []
63
+ raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
64
+ return list(raw.get("targets", []))
65
+
66
+
67
+ def _write_targets(targets: list[dict]) -> None:
68
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
69
+ try:
70
+ CONFIG_DIR.chmod(0o700)
71
+ except OSError:
72
+ pass
73
+ CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
74
+
75
+
76
+ @cli_errors
77
+ def init_cmd() -> None:
78
+ """Interactively set up your first broker connection."""
79
+ from queue_aiops.secretstore import SecretStore, resolve_master_password
80
+
81
+ console.print("[bold cyan]Queue AIops — setup wizard[/]")
82
+ console.print(
83
+ "This collects redis or rabbitmq connection details (saved to "
84
+ "config.yaml) and your secret (saved [bold]encrypted[/] to "
85
+ "secrets.enc).\n"
86
+ )
87
+
88
+ console.print("[bold]Step 1 — master password[/]")
89
+ console.print(
90
+ "[dim]Encrypts secrets.enc. You'll set it via the "
91
+ "QUEUE_AIOPS_MASTER_PASSWORD env var for non-interactive/MCP use.[/]"
92
+ )
93
+ password = resolve_master_password(confirm_if_new=True)
94
+ store = SecretStore.unlock(password)
95
+
96
+ targets = _load_existing_targets()
97
+ existing_names = {t.get("name") for t in targets}
98
+
99
+ while True:
100
+ console.print("\n[bold]Step 2 — add a target[/]")
101
+ name = typer.prompt("Target name (e.g. cache1 or broker1)").strip()
102
+ if name in existing_names:
103
+ if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
104
+ continue
105
+ targets = [t for t in targets if t.get("name") != name]
106
+
107
+ platform = typer.prompt(
108
+ f"Platform ({REDIS} / {RABBITMQ})", default=REDIS
109
+ ).strip().lower()
110
+ if platform not in (REDIS, RABBITMQ):
111
+ console.print("[red]Platform must be 'redis' or 'rabbitmq'.[/]")
112
+ continue
113
+
114
+ host = typer.prompt("Host (IP or FQDN)").strip()
115
+ port = typer.prompt("Port", default=get_platform(platform).default_port, type=int)
116
+ db = 0
117
+ if platform == REDIS:
118
+ db = typer.prompt("Redis logical db index", default=0, type=int)
119
+ use_tls = typer.confirm("Use TLS (rediss://)?", default=False)
120
+ else:
121
+ use_tls = typer.confirm("Use HTTPS for the management API?", default=False)
122
+ verify_ssl = True
123
+ if use_tls:
124
+ console.print("[dim]Lab/self-signed setups can answer No here.[/]")
125
+ verify_ssl = typer.confirm(
126
+ "Verify TLS certificate? (No for self-signed lab certs)", default=True
127
+ )
128
+
129
+ username = ""
130
+ if platform == RABBITMQ:
131
+ username = typer.prompt("Management user (needs 'monitoring'/'management' tag)")
132
+ username = username.strip()
133
+ secret = getpass.getpass(f"Password for '{username}' on '{name}' (hidden): ")
134
+ if not secret:
135
+ console.print("[red]rabbitmq needs the management user's password.[/]")
136
+ continue
137
+ store = store.set(name, secret)
138
+ secret_note = "secret encrypted" # nosec B105 — a UI label, not a secret
139
+ else:
140
+ secret = getpass.getpass(
141
+ f"Redis password for '{name}' (hidden; empty for no AUTH): "
142
+ )
143
+ if secret:
144
+ store = store.set(name, secret)
145
+ secret_note = "secret encrypted" # nosec B105 — a UI label, not a secret
146
+ else:
147
+ secret_note = "no AUTH — lab mode" # nosec B105 — a UI label, not a secret
148
+
149
+ entry = {
150
+ "name": name,
151
+ "platform": platform,
152
+ "host": host,
153
+ "port": port,
154
+ "username": username,
155
+ "db": db,
156
+ "use_tls": use_tls,
157
+ "verify_ssl": verify_ssl,
158
+ }
159
+ targets.append(entry)
160
+ existing_names.add(name)
161
+ _write_targets(targets)
162
+ console.print(f"[green]✓ Saved target '{name}' ({platform}, {secret_note}).[/]")
163
+
164
+ if not typer.confirm("\nAdd another target?", default=False):
165
+ break
166
+
167
+ _write_default_rules()
168
+ console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
169
+ console.print(
170
+ "[dim]Tip: export QUEUE_AIOPS_MASTER_PASSWORD=... in your shell profile "
171
+ "so the MCP server and CLI can unlock secrets non-interactively.[/]"
172
+ )
173
+ if typer.confirm("Run a connectivity check now (queue-aiops doctor)?", default=True):
174
+ from queue_aiops.doctor import run_doctor
175
+
176
+ raise typer.Exit(run_doctor())
@@ -0,0 +1,16 @@
1
+ """``queue-aiops overview`` — one-shot broker health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from queue_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 summary: platform + version + backlog/memory/client health."""
13
+ from queue_aiops.ops import overview as ops
14
+
15
+ conn, _ = get_connection(target)
16
+ console.print_json(json.dumps(ops.queue_overview(conn)))
@@ -0,0 +1,234 @@
1
+ """``queue-aiops rabbitmq`` — rabbitmq reads + governed writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from queue_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
+ rabbitmq_app = typer.Typer(
21
+ name="rabbitmq",
22
+ help="rabbitmq: overview/queues/connections/channels/policies/nodes reads, "
23
+ "plus governed purge/delete/declare queue and set/delete policy.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ VhostOption = Annotated[
28
+ str, typer.Option("--vhost", help="Vhost (the default vhost is '/')")
29
+ ]
30
+
31
+
32
+ @rabbitmq_app.command("overview")
33
+ @cli_errors
34
+ def rabbit_overview(target: TargetOption = None) -> None:
35
+ """Broker identity + totals (queues, messages, connections, rates)."""
36
+ from queue_aiops.ops import rabbit_reads as ops
37
+
38
+ conn, _ = get_connection(target)
39
+ console.print_json(json.dumps(ops.broker_overview(conn)))
40
+
41
+
42
+ @rabbitmq_app.command("queues")
43
+ @cli_errors
44
+ def rabbit_queues(
45
+ vhost: Annotated[
46
+ str | None, typer.Option("--vhost", help="Restrict to one vhost")
47
+ ] = None,
48
+ target: TargetOption = None,
49
+ ) -> None:
50
+ """Queues, deepest backlog first."""
51
+ from queue_aiops.ops import rabbit_reads as ops
52
+
53
+ conn, _ = get_connection(target)
54
+ console.print_json(json.dumps(ops.list_queues(conn, vhost)))
55
+
56
+
57
+ @rabbitmq_app.command("queue")
58
+ @cli_errors
59
+ def rabbit_queue(
60
+ name: Annotated[str, typer.Argument(help="Queue name (from 'queues')")],
61
+ vhost: VhostOption = "/",
62
+ target: TargetOption = None,
63
+ ) -> None:
64
+ """One queue's full detail."""
65
+ from queue_aiops.ops import rabbit_reads as ops
66
+
67
+ conn, _ = get_connection(target)
68
+ console.print_json(json.dumps(ops.queue_detail(conn, vhost, name)))
69
+
70
+
71
+ @rabbitmq_app.command("connections")
72
+ @cli_errors
73
+ def rabbit_connections(target: TargetOption = None) -> None:
74
+ """Client connections grouped by peer host."""
75
+ from queue_aiops.ops import rabbit_reads as ops
76
+
77
+ conn, _ = get_connection(target)
78
+ console.print_json(json.dumps(ops.list_connections(conn)))
79
+
80
+
81
+ @rabbitmq_app.command("channels")
82
+ @cli_errors
83
+ def rabbit_channels(target: TargetOption = None) -> None:
84
+ """Channels with unacked/prefetch pressure."""
85
+ from queue_aiops.ops import rabbit_reads as ops
86
+
87
+ conn, _ = get_connection(target)
88
+ console.print_json(json.dumps(ops.list_channels(conn)))
89
+
90
+
91
+ @rabbitmq_app.command("policies")
92
+ @cli_errors
93
+ def rabbit_policies(
94
+ vhost: Annotated[
95
+ str | None, typer.Option("--vhost", help="Restrict to one vhost")
96
+ ] = None,
97
+ target: TargetOption = None,
98
+ ) -> None:
99
+ """Policies with pattern, priority, definition."""
100
+ from queue_aiops.ops import rabbit_reads as ops
101
+
102
+ conn, _ = get_connection(target)
103
+ console.print_json(json.dumps(ops.list_policies(conn, vhost)))
104
+
105
+
106
+ @rabbitmq_app.command("nodes")
107
+ @cli_errors
108
+ def rabbit_nodes(target: TargetOption = None) -> None:
109
+ """Node memory/disk/fd posture + watermark alarms."""
110
+ from queue_aiops.ops import rabbit_reads as ops
111
+
112
+ conn, _ = get_connection(target)
113
+ console.print_json(json.dumps(ops.node_health(conn)))
114
+
115
+
116
+ @rabbitmq_app.command("purge")
117
+ @cli_errors
118
+ def rabbit_purge(
119
+ name: Annotated[str, typer.Argument(help="Queue name to purge")],
120
+ vhost: VhostOption = "/",
121
+ target: TargetOption = None,
122
+ dry_run: DryRunOption = False,
123
+ ) -> None:
124
+ """Purge all ready messages from a queue (IRREVERSIBLE, risk=high)."""
125
+ from mcp_server.tools import writes as gov
126
+
127
+ if dry_run:
128
+ dry_run_print(operation="purge_queue", api_call="DELETE queue contents",
129
+ parameters={"vhost": vhost, "name": name})
130
+ return
131
+ double_confirm("purge queue", name)
132
+ console.print_json(json.dumps(gov.purge_queue(vhost=vhost, name=name, target=target)))
133
+
134
+
135
+ @rabbitmq_app.command("delete-queue")
136
+ @cli_errors
137
+ def rabbit_delete_queue(
138
+ name: Annotated[str, typer.Argument(help="Queue name to delete")],
139
+ vhost: VhostOption = "/",
140
+ target: TargetOption = None,
141
+ dry_run: DryRunOption = False,
142
+ ) -> None:
143
+ """Delete a queue (risk=high; undo re-declares the captured definition)."""
144
+ from mcp_server.tools import writes as gov
145
+
146
+ if dry_run:
147
+ dry_run_print(operation="delete_queue", api_call="DELETE queue",
148
+ parameters={"vhost": vhost, "name": name})
149
+ return
150
+ double_confirm("delete queue", name)
151
+ console.print_json(json.dumps(gov.delete_queue(vhost=vhost, name=name, target=target)))
152
+
153
+
154
+ @rabbitmq_app.command("declare-queue")
155
+ @cli_errors
156
+ def rabbit_declare_queue(
157
+ name: Annotated[str, typer.Argument(help="Queue name to declare")],
158
+ vhost: VhostOption = "/",
159
+ durable: Annotated[
160
+ bool, typer.Option("--durable/--transient", help="Survive broker restarts")
161
+ ] = True,
162
+ auto_delete: Annotated[
163
+ bool, typer.Option("--auto-delete", help="Delete when the last consumer leaves")
164
+ ] = False,
165
+ target: TargetOption = None,
166
+ dry_run: DryRunOption = False,
167
+ ) -> None:
168
+ """Declare (create) a queue (undo deletes it when newly created)."""
169
+ from mcp_server.tools import writes as gov
170
+
171
+ if dry_run:
172
+ dry_run_print(operation="declare_queue", api_call="PUT queue",
173
+ parameters={"vhost": vhost, "name": name, "durable": durable,
174
+ "auto_delete": auto_delete})
175
+ return
176
+ double_confirm("declare queue", name)
177
+ console.print_json(json.dumps(gov.declare_queue(
178
+ vhost=vhost, name=name, durable=durable, auto_delete=auto_delete, target=target
179
+ )))
180
+
181
+
182
+ @rabbitmq_app.command("set-policy")
183
+ @cli_errors
184
+ def rabbit_set_policy(
185
+ name: Annotated[str, typer.Argument(help="Policy name")],
186
+ pattern: Annotated[str, typer.Argument(help="Regex the policy matches names against")],
187
+ definition: Annotated[
188
+ str, typer.Argument(help="Definition as JSON, e.g. '{\"max-length\": 100000}'")
189
+ ],
190
+ vhost: VhostOption = "/",
191
+ priority: Annotated[int, typer.Option("--priority", help="Higher wins")] = 0,
192
+ apply_to: Annotated[
193
+ str, typer.Option("--apply-to", help="queues / exchanges / all")
194
+ ] = "all",
195
+ target: TargetOption = None,
196
+ dry_run: DryRunOption = False,
197
+ ) -> None:
198
+ """Create/update a policy (reversible — prior policy captured for undo)."""
199
+ from mcp_server.tools import writes as gov
200
+
201
+ try:
202
+ definition_obj = json.loads(definition)
203
+ except json.JSONDecodeError as exc:
204
+ raise ValueError(f"'definition' must be valid JSON: {exc}") from exc
205
+ if dry_run:
206
+ dry_run_print(operation="set_policy", api_call="PUT policy",
207
+ parameters={"vhost": vhost, "name": name, "pattern": pattern,
208
+ "definition": definition_obj, "priority": priority,
209
+ "apply_to": apply_to})
210
+ return
211
+ double_confirm("set policy", name)
212
+ console.print_json(json.dumps(gov.set_policy(
213
+ vhost=vhost, name=name, pattern=pattern, definition=definition_obj,
214
+ priority=priority, apply_to=apply_to, target=target,
215
+ )))
216
+
217
+
218
+ @rabbitmq_app.command("delete-policy")
219
+ @cli_errors
220
+ def rabbit_delete_policy(
221
+ name: Annotated[str, typer.Argument(help="Policy name to delete")],
222
+ vhost: VhostOption = "/",
223
+ target: TargetOption = None,
224
+ dry_run: DryRunOption = False,
225
+ ) -> None:
226
+ """Delete a policy (reversible — its definition is captured for undo)."""
227
+ from mcp_server.tools import writes as gov
228
+
229
+ if dry_run:
230
+ dry_run_print(operation="delete_policy", api_call="DELETE policy",
231
+ parameters={"vhost": vhost, "name": name})
232
+ return
233
+ double_confirm("delete policy", name)
234
+ console.print_json(json.dumps(gov.delete_policy(vhost=vhost, name=name, target=target)))
@@ -0,0 +1,147 @@
1
+ """``queue-aiops redis`` — redis reads + governed writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from queue_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
+ redis_app = typer.Typer(
21
+ name="redis",
22
+ help="redis: server/memory/clients/slowlog/config/keyspace/big-key reads, "
23
+ "plus governed config-set and kill-client.",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+
28
+ @redis_app.command("info")
29
+ @cli_errors
30
+ def redis_info(target: TargetOption = None) -> None:
31
+ """Server identity + health basics (version, role, clients, ops/sec)."""
32
+ from queue_aiops.ops import redis_reads as ops
33
+
34
+ conn, _ = get_connection(target)
35
+ console.print_json(json.dumps(ops.server_info(conn)))
36
+
37
+
38
+ @redis_app.command("memory")
39
+ @cli_errors
40
+ def redis_memory(target: TargetOption = None) -> None:
41
+ """Memory posture: used vs maxmemory, policy, fragmentation."""
42
+ from queue_aiops.ops import redis_reads as ops
43
+
44
+ conn, _ = get_connection(target)
45
+ console.print_json(json.dumps(ops.memory_stats(conn)))
46
+
47
+
48
+ @redis_app.command("clients")
49
+ @cli_errors
50
+ def redis_clients(target: TargetOption = None) -> None:
51
+ """Connected clients grouped by source address."""
52
+ from queue_aiops.ops import redis_reads as ops
53
+
54
+ conn, _ = get_connection(target)
55
+ console.print_json(json.dumps(ops.list_clients(conn)))
56
+
57
+
58
+ @redis_app.command("slowlog")
59
+ @cli_errors
60
+ def redis_slowlog(
61
+ count: Annotated[int, typer.Option("--count", "-n", help="Max entries")] = 128,
62
+ target: TargetOption = None,
63
+ ) -> None:
64
+ """Recent SLOWLOG entries, slowest first."""
65
+ from queue_aiops.ops import redis_reads as ops
66
+
67
+ conn, _ = get_connection(target)
68
+ console.print_json(json.dumps(ops.slowlog(conn, count)))
69
+
70
+
71
+ @redis_app.command("config-get")
72
+ @cli_errors
73
+ def redis_config_get(
74
+ pattern: Annotated[str, typer.Argument(help="Config-name glob, e.g. 'maxmemory*'")] = "*",
75
+ target: TargetOption = None,
76
+ ) -> None:
77
+ """CONFIG GET for a glob pattern."""
78
+ from queue_aiops.ops import redis_reads as ops
79
+
80
+ conn, _ = get_connection(target)
81
+ console.print_json(json.dumps(ops.config_get(conn, pattern)))
82
+
83
+
84
+ @redis_app.command("keyspace")
85
+ @cli_errors
86
+ def redis_keyspace(target: TargetOption = None) -> None:
87
+ """Per-db key counts and expiry coverage."""
88
+ from queue_aiops.ops import redis_reads as ops
89
+
90
+ conn, _ = get_connection(target)
91
+ console.print_json(json.dumps(ops.keyspace(conn)))
92
+
93
+
94
+ @redis_app.command("bigkeys")
95
+ @cli_errors
96
+ def redis_bigkeys(
97
+ top: Annotated[int, typer.Option("--top", help="Rows to return, largest first")] = 20,
98
+ target: TargetOption = None,
99
+ ) -> None:
100
+ """SCAN-budgeted big-key sample (never KEYS *)."""
101
+ from queue_aiops.ops import redis_reads as ops
102
+
103
+ conn, _ = get_connection(target)
104
+ console.print_json(json.dumps(ops.big_key_sample(conn, top)))
105
+
106
+
107
+ @redis_app.command("config-set")
108
+ @cli_errors
109
+ def redis_config_set(
110
+ parameter: Annotated[str, typer.Argument(help="Config parameter, e.g. maxmemory-policy")],
111
+ value: Annotated[str, typer.Argument(help="New value")],
112
+ target: TargetOption = None,
113
+ dry_run: DryRunOption = False,
114
+ ) -> None:
115
+ """Set one config parameter (reversible — prior value captured for undo)."""
116
+ from mcp_server.tools import writes as gov
117
+
118
+ if dry_run:
119
+ dry_run_print(operation="redis_config_set", api_call="CONFIG SET",
120
+ parameters={"parameter": parameter, "value": value})
121
+ return
122
+ double_confirm("set config parameter", parameter)
123
+ console.print_json(
124
+ json.dumps(gov.redis_config_set(parameter=parameter, value=value, target=target))
125
+ )
126
+
127
+
128
+ @redis_app.command("kill-client")
129
+ @cli_errors
130
+ def redis_kill_client(
131
+ client_id: Annotated[int, typer.Option("--id", help="Client id (from 'clients')")] = 0,
132
+ addr: Annotated[str, typer.Option("--addr", help="Or the client's ip:port")] = "",
133
+ target: TargetOption = None,
134
+ dry_run: DryRunOption = False,
135
+ ) -> None:
136
+ """Disconnect one client (irreversible; its info is captured as priorState)."""
137
+ from mcp_server.tools import writes as gov
138
+
139
+ who = str(client_id) if client_id else addr
140
+ if dry_run:
141
+ dry_run_print(operation="redis_kill_client", api_call="CLIENT KILL",
142
+ parameters={"client_id": client_id, "addr": addr})
143
+ return
144
+ double_confirm("kill client", who or "(unspecified)")
145
+ console.print_json(
146
+ json.dumps(gov.redis_kill_client(client_id=client_id, addr=addr, target=target))
147
+ )
@@ -0,0 +1,105 @@
1
+ """``queue-aiops secret`` — manage the encrypted credential store.
2
+
3
+ Secrets (Monitoring API keys) are stored in ``~/.queue-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 queue_aiops.cli._common import cli_errors, console
16
+ from queue_aiops.config import SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX
17
+ from queue_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 API key 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="API key value (omit to be prompted, hidden)"),
41
+ ] = None,
42
+ ) -> None:
43
+ """Store (or replace) the API key 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"API key for '{name}' (hidden): ")
47
+ store = SecretStore.unlock(password)
48
+ store.set(name, value)
49
+ console.print(f"[green]✓ Stored encrypted API key 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 API key (values never shown)."""
56
+ store = SecretStore.unlock()
57
+ names = store.names()
58
+ if not names:
59
+ console.print(
60
+ "[yellow]No secrets stored yet. Add one: queue-aiops secret set <name>[/]"
61
+ )
62
+ return
63
+ console.print("[bold]Stored secrets:[/]")
64
+ for n in names:
65
+ console.print(f" • {n}")
66
+ warning = check_permissions()
67
+ if warning:
68
+ console.print(f"[yellow]! {warning}[/]")
69
+
70
+
71
+ @secret_app.command("rm")
72
+ @cli_errors
73
+ def secret_rm(name: NameArg) -> None:
74
+ """Delete a stored API key."""
75
+ store = SecretStore.unlock()
76
+ store.delete(name)
77
+ console.print(f"[green]✓ Deleted API key for '{name}'[/]")
78
+
79
+
80
+ @secret_app.command("migrate")
81
+ @cli_errors
82
+ def secret_migrate() -> None:
83
+ """Import API keys from a legacy plaintext .env into the encrypted store."""
84
+ password = resolve_master_password(confirm_if_new=True)
85
+ imported = migrate_legacy_env(SECRET_ENV_PREFIX, SECRET_ENV_SUFFIX, password)
86
+ if not imported:
87
+ console.print("[yellow]Nothing to migrate (no legacy .env secrets found).[/]")
88
+ return
89
+ console.print(f"[green]✓ Imported {len(imported)} secret(s): {', '.join(imported)}[/]")
90
+ console.print("[dim]The old .env was renamed to .env.migrated — delete it once verified.[/]")
91
+
92
+
93
+ @secret_app.command("rotate-password")
94
+ @cli_errors
95
+ def secret_rotate_password() -> None:
96
+ """Re-encrypt the whole store under a new master password."""
97
+ console.print("[bold]Unlock with the current master password:[/]")
98
+ store = SecretStore.unlock()
99
+ new_pw = getpass.getpass("New master password: ")
100
+ confirm = getpass.getpass("Confirm new master password: ")
101
+ if new_pw != confirm:
102
+ console.print("[red]Passwords did not match. Aborted.[/]")
103
+ raise typer.Exit(1)
104
+ store.with_password(new_pw)
105
+ console.print("[green]✓ Master password rotated. Update QUEUE_AIOPS_MASTER_PASSWORD.[/]")