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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +107 -0
- mcp_server/server.py +38 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis.py +148 -0
- mcp_server/tools/overview.py +23 -0
- mcp_server/tools/rabbit_reads.py +95 -0
- mcp_server/tools/redis_reads.py +98 -0
- mcp_server/tools/undo.py +121 -0
- mcp_server/tools/writes.py +355 -0
- queue_aiops/__init__.py +14 -0
- queue_aiops/cli/__init__.py +9 -0
- queue_aiops/cli/_common.py +78 -0
- queue_aiops/cli/_root.py +62 -0
- queue_aiops/cli/analyze.py +85 -0
- queue_aiops/cli/doctor.py +21 -0
- queue_aiops/cli/init.py +176 -0
- queue_aiops/cli/overview.py +16 -0
- queue_aiops/cli/rabbit_cmds.py +234 -0
- queue_aiops/cli/redis_cmds.py +147 -0
- queue_aiops/cli/secret.py +105 -0
- queue_aiops/cli/undo.py +62 -0
- queue_aiops/config.py +175 -0
- queue_aiops/connection.py +277 -0
- queue_aiops/doctor.py +98 -0
- queue_aiops/governance/__init__.py +40 -0
- queue_aiops/governance/audit.py +377 -0
- queue_aiops/governance/budget.py +225 -0
- queue_aiops/governance/decorators.py +482 -0
- queue_aiops/governance/paths.py +23 -0
- queue_aiops/governance/patterns.py +378 -0
- queue_aiops/governance/policy.py +430 -0
- queue_aiops/governance/sanitize.py +45 -0
- queue_aiops/governance/undo.py +218 -0
- queue_aiops/ops/__init__.py +1 -0
- queue_aiops/ops/_util.py +55 -0
- queue_aiops/ops/analysis.py +591 -0
- queue_aiops/ops/overview.py +90 -0
- queue_aiops/ops/rabbit_reads.py +218 -0
- queue_aiops/ops/redis_reads.py +237 -0
- queue_aiops/ops/writes.py +284 -0
- queue_aiops/platform.py +217 -0
- queue_aiops/secretstore.py +304 -0
- queue_aiops-0.1.0.dist-info/METADATA +168 -0
- queue_aiops-0.1.0.dist-info/RECORD +48 -0
- queue_aiops-0.1.0.dist-info/WHEEL +4 -0
- queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Governed broker-write MCP tools (the only state-changing tools).
|
|
2
|
+
|
|
3
|
+
Every tool is wrapped with the governance harness (audit + graduated approval
|
|
4
|
+
tier) and takes a ``dry_run`` preview. Reversible writes pass an ``undo=``
|
|
5
|
+
callback that turns the fetched before-state into an inverse descriptor the
|
|
6
|
+
harness records; irreversible ones (kill_client, purge_queue) record only the
|
|
7
|
+
priorState in the result.
|
|
8
|
+
|
|
9
|
+
Risk tiers: purge_queue / delete_queue = high (destroy messages / a queue);
|
|
10
|
+
redis_config_set / redis_kill_client / declare_queue / set_policy /
|
|
11
|
+
delete_policy = medium.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from mcp_server._shared import _get_connection, mcp, tool_errors
|
|
17
|
+
from queue_aiops.governance import governed_tool
|
|
18
|
+
from queue_aiops.ops import writes as ops
|
|
19
|
+
|
|
20
|
+
# ── undo descriptors (built from the fetched before-state) ──────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _config_set_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
24
|
+
"""Inverse of redis_config_set: set the parameter back to its prior value."""
|
|
25
|
+
if not isinstance(result, dict):
|
|
26
|
+
return None
|
|
27
|
+
prior = (result.get("priorState") or {}).get("value")
|
|
28
|
+
if prior is None:
|
|
29
|
+
return None
|
|
30
|
+
return {
|
|
31
|
+
"tool": "redis_config_set",
|
|
32
|
+
"params": {"parameter": params.get("parameter"), "value": prior},
|
|
33
|
+
"skill": "queue-aiops",
|
|
34
|
+
"note": "Inverse of redis_config_set: restore the parameter's prior value.",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _delete_queue_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
39
|
+
"""Inverse of delete_queue: re-declare the queue with the captured definition.
|
|
40
|
+
|
|
41
|
+
The queue's *messages* are gone — this restores the definition only, and the
|
|
42
|
+
note says so.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(result, dict):
|
|
45
|
+
return None
|
|
46
|
+
prior = result.get("priorState") or {}
|
|
47
|
+
return {
|
|
48
|
+
"tool": "declare_queue",
|
|
49
|
+
"params": {
|
|
50
|
+
"vhost": params.get("vhost"),
|
|
51
|
+
"name": params.get("name"),
|
|
52
|
+
"durable": bool(prior.get("durable", True)),
|
|
53
|
+
"auto_delete": bool(prior.get("autoDelete", False)),
|
|
54
|
+
"arguments": prior.get("arguments") or {},
|
|
55
|
+
},
|
|
56
|
+
"skill": "queue-aiops",
|
|
57
|
+
"note": "Inverse of delete_queue: re-declare the captured definition "
|
|
58
|
+
"(messages are NOT restored).",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _declare_queue_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
63
|
+
"""Inverse of declare_queue: delete the queue — only when it was newly created."""
|
|
64
|
+
if not isinstance(result, dict):
|
|
65
|
+
return None
|
|
66
|
+
if (result.get("priorState") or {}).get("existed"):
|
|
67
|
+
return None # the queue predates this call; deleting it would not be an undo
|
|
68
|
+
return {
|
|
69
|
+
"tool": "delete_queue",
|
|
70
|
+
"params": {"vhost": params.get("vhost"), "name": params.get("name")},
|
|
71
|
+
"skill": "queue-aiops",
|
|
72
|
+
"note": "Inverse of declare_queue: delete the queue that was created.",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _set_policy_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
77
|
+
"""Inverse of set_policy: restore the prior policy, or delete it if it is new."""
|
|
78
|
+
if not isinstance(result, dict):
|
|
79
|
+
return None
|
|
80
|
+
prior_state = result.get("priorState") or {}
|
|
81
|
+
prior = prior_state.get("policy")
|
|
82
|
+
if prior_state.get("existed") and isinstance(prior, dict):
|
|
83
|
+
return {
|
|
84
|
+
"tool": "set_policy",
|
|
85
|
+
"params": {
|
|
86
|
+
"vhost": params.get("vhost"),
|
|
87
|
+
"name": params.get("name"),
|
|
88
|
+
"pattern": prior.get("pattern"),
|
|
89
|
+
"definition": prior.get("definition") or {},
|
|
90
|
+
"priority": int(prior.get("priority") or 0),
|
|
91
|
+
"apply_to": prior.get("applyTo") or "all",
|
|
92
|
+
},
|
|
93
|
+
"skill": "queue-aiops",
|
|
94
|
+
"note": "Inverse of set_policy: restore the prior policy fields.",
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
"tool": "delete_policy",
|
|
98
|
+
"params": {"vhost": params.get("vhost"), "name": params.get("name")},
|
|
99
|
+
"skill": "queue-aiops",
|
|
100
|
+
"note": "Inverse of set_policy: delete the policy that was newly created.",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _delete_policy_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
|
|
105
|
+
"""Inverse of delete_policy: re-create the captured policy."""
|
|
106
|
+
if not isinstance(result, dict):
|
|
107
|
+
return None
|
|
108
|
+
prior = (result.get("priorState") or {}).get("policy")
|
|
109
|
+
if not isinstance(prior, dict):
|
|
110
|
+
return None
|
|
111
|
+
return {
|
|
112
|
+
"tool": "set_policy",
|
|
113
|
+
"params": {
|
|
114
|
+
"vhost": params.get("vhost"),
|
|
115
|
+
"name": params.get("name"),
|
|
116
|
+
"pattern": prior.get("pattern"),
|
|
117
|
+
"definition": prior.get("definition") or {},
|
|
118
|
+
"priority": int(prior.get("priority") or 0),
|
|
119
|
+
"apply_to": prior.get("applyTo") or "all",
|
|
120
|
+
},
|
|
121
|
+
"skill": "queue-aiops",
|
|
122
|
+
"note": "Inverse of delete_policy: re-create the captured policy.",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── redis writes ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@mcp.tool()
|
|
130
|
+
@governed_tool(risk_level="medium", undo=_config_set_undo)
|
|
131
|
+
@tool_errors("dict")
|
|
132
|
+
def redis_config_set(
|
|
133
|
+
parameter: str,
|
|
134
|
+
value: str,
|
|
135
|
+
dry_run: bool = False,
|
|
136
|
+
target: Optional[str] = None,
|
|
137
|
+
) -> dict:
|
|
138
|
+
"""[WRITE][risk=medium] Set one redis config parameter; reversible.
|
|
139
|
+
|
|
140
|
+
Reads the parameter first (CONFIG GET) so the harness records an undo that
|
|
141
|
+
restores its prior value. Runtime-only — not persisted to the config file.
|
|
142
|
+
Pass dry_run=True to preview.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
parameter: Config parameter name (e.g. maxmemory-policy), from redis_config_get.
|
|
146
|
+
value: New value.
|
|
147
|
+
dry_run: If True, preview without changing.
|
|
148
|
+
target: redis target name from config; omit for the default.
|
|
149
|
+
"""
|
|
150
|
+
conn = _get_connection(target)
|
|
151
|
+
if dry_run:
|
|
152
|
+
return {"dryRun": True, "wouldSet": {"parameter": parameter, "value": value}}
|
|
153
|
+
return ops.config_set(conn, parameter, value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
@governed_tool(risk_level="medium")
|
|
158
|
+
@tool_errors("dict")
|
|
159
|
+
def redis_kill_client(
|
|
160
|
+
client_id: int = 0,
|
|
161
|
+
addr: str = "",
|
|
162
|
+
dry_run: bool = False,
|
|
163
|
+
target: Optional[str] = None,
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""[WRITE][risk=medium] Disconnect one redis client by id or addr. IRREVERSIBLE.
|
|
166
|
+
|
|
167
|
+
Captures the client's CLIENT LIST row as priorState (who/where/last
|
|
168
|
+
command) before the kill — the connection cannot be restored, so no undo
|
|
169
|
+
is recorded; most clients transparently reconnect. Pass dry_run=True to
|
|
170
|
+
preview.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
client_id: Client id from redis_clients (preferred).
|
|
174
|
+
addr: Or the client's 'ip:port' address.
|
|
175
|
+
dry_run: If True, preview without disconnecting.
|
|
176
|
+
target: redis target name from config; omit for the default.
|
|
177
|
+
"""
|
|
178
|
+
conn = _get_connection(target)
|
|
179
|
+
if dry_run:
|
|
180
|
+
return {"dryRun": True, "wouldKill": {"clientId": client_id, "addr": addr}}
|
|
181
|
+
return ops.kill_client(conn, client_id=client_id, addr=addr)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── rabbitmq queue writes ────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@mcp.tool()
|
|
188
|
+
@governed_tool(risk_level="high")
|
|
189
|
+
@tool_errors("dict")
|
|
190
|
+
def purge_queue(
|
|
191
|
+
vhost: str,
|
|
192
|
+
name: str,
|
|
193
|
+
dry_run: bool = False,
|
|
194
|
+
target: Optional[str] = None,
|
|
195
|
+
) -> dict:
|
|
196
|
+
"""[WRITE][risk=high] Purge all ready messages from a rabbitmq queue. IRREVERSIBLE.
|
|
197
|
+
|
|
198
|
+
Reads the queue first so priorState carries the message count about to be
|
|
199
|
+
destroyed (audit evidence); unacked messages are not purged. No undo —
|
|
200
|
+
purged messages cannot be restored. Requires an approver
|
|
201
|
+
(QUEUE_AUDIT_APPROVED_BY) under the graduated-autonomy policy. Pass
|
|
202
|
+
dry_run=True to preview.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
vhost: The queue's vhost (the default vhost is '/').
|
|
206
|
+
name: Queue name (from list_queues).
|
|
207
|
+
dry_run: If True, preview without purging.
|
|
208
|
+
target: rabbitmq target name from config; omit for the default.
|
|
209
|
+
"""
|
|
210
|
+
conn = _get_connection(target)
|
|
211
|
+
if dry_run:
|
|
212
|
+
return {"dryRun": True, "wouldPurge": {"vhost": vhost, "queue": name}}
|
|
213
|
+
return ops.purge_queue(conn, vhost, name)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@mcp.tool()
|
|
217
|
+
@governed_tool(risk_level="high", undo=_delete_queue_undo)
|
|
218
|
+
@tool_errors("dict")
|
|
219
|
+
def delete_queue(
|
|
220
|
+
vhost: str,
|
|
221
|
+
name: str,
|
|
222
|
+
dry_run: bool = False,
|
|
223
|
+
target: Optional[str] = None,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""[WRITE][risk=high] Delete a rabbitmq queue; the undo re-declares its definition.
|
|
226
|
+
|
|
227
|
+
Reads the queue first so priorState carries the definition (durable /
|
|
228
|
+
auto_delete / arguments) and message count; the recorded undo re-declares
|
|
229
|
+
the queue with those properties — its messages are NOT restored. Requires
|
|
230
|
+
an approver (QUEUE_AUDIT_APPROVED_BY) under the graduated-autonomy policy.
|
|
231
|
+
Pass dry_run=True to preview.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
vhost: The queue's vhost (the default vhost is '/').
|
|
235
|
+
name: Queue name (from list_queues).
|
|
236
|
+
dry_run: If True, preview without deleting.
|
|
237
|
+
target: rabbitmq target name from config; omit for the default.
|
|
238
|
+
"""
|
|
239
|
+
conn = _get_connection(target)
|
|
240
|
+
if dry_run:
|
|
241
|
+
return {"dryRun": True, "wouldDelete": {"vhost": vhost, "queue": name}}
|
|
242
|
+
return ops.delete_queue(conn, vhost, name)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@mcp.tool()
|
|
246
|
+
@governed_tool(risk_level="medium", undo=_declare_queue_undo)
|
|
247
|
+
@tool_errors("dict")
|
|
248
|
+
def declare_queue(
|
|
249
|
+
vhost: str,
|
|
250
|
+
name: str,
|
|
251
|
+
durable: bool = True,
|
|
252
|
+
auto_delete: bool = False,
|
|
253
|
+
arguments: Optional[dict[str, Any]] = None,
|
|
254
|
+
dry_run: bool = False,
|
|
255
|
+
target: Optional[str] = None,
|
|
256
|
+
) -> dict:
|
|
257
|
+
"""[WRITE][risk=medium] Declare (create) a rabbitmq queue; undo deletes it if new.
|
|
258
|
+
|
|
259
|
+
Records whether the queue already existed; the undo (delete) is recorded
|
|
260
|
+
only for a newly-created queue. Also the replay target for delete_queue's
|
|
261
|
+
undo. Pass dry_run=True to preview.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
vhost: The queue's vhost (the default vhost is '/').
|
|
265
|
+
name: Queue name to declare.
|
|
266
|
+
durable: Survive broker restarts (default True).
|
|
267
|
+
auto_delete: Delete when the last consumer disconnects (default False).
|
|
268
|
+
arguments: Optional x-arguments (e.g. {'x-queue-type': 'quorum'}).
|
|
269
|
+
dry_run: If True, preview without declaring.
|
|
270
|
+
target: rabbitmq target name from config; omit for the default.
|
|
271
|
+
"""
|
|
272
|
+
conn = _get_connection(target)
|
|
273
|
+
if dry_run:
|
|
274
|
+
return {
|
|
275
|
+
"dryRun": True,
|
|
276
|
+
"wouldDeclare": {
|
|
277
|
+
"vhost": vhost, "queue": name, "durable": durable,
|
|
278
|
+
"autoDelete": auto_delete, "arguments": arguments or {},
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
return ops.declare_queue(
|
|
282
|
+
conn, vhost, name, durable=durable, auto_delete=auto_delete, arguments=arguments
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── rabbitmq policy writes ───────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@mcp.tool()
|
|
290
|
+
@governed_tool(risk_level="medium", undo=_set_policy_undo)
|
|
291
|
+
@tool_errors("dict")
|
|
292
|
+
def set_policy(
|
|
293
|
+
vhost: str,
|
|
294
|
+
name: str,
|
|
295
|
+
pattern: str,
|
|
296
|
+
definition: dict[str, Any],
|
|
297
|
+
priority: int = 0,
|
|
298
|
+
apply_to: str = "all",
|
|
299
|
+
dry_run: bool = False,
|
|
300
|
+
target: Optional[str] = None,
|
|
301
|
+
) -> dict:
|
|
302
|
+
"""[WRITE][risk=medium] Create/update a rabbitmq policy; reversible.
|
|
303
|
+
|
|
304
|
+
Reads the policy first: the recorded undo restores the prior policy fields,
|
|
305
|
+
or deletes the policy when it is newly created. Pass dry_run=True to
|
|
306
|
+
preview.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
vhost: The policy's vhost (the default vhost is '/').
|
|
310
|
+
name: Policy name.
|
|
311
|
+
pattern: Regex the policy matches queue/exchange names against.
|
|
312
|
+
definition: Policy definition object (e.g. {'max-length': 100000}).
|
|
313
|
+
priority: Policy priority (higher wins; default 0).
|
|
314
|
+
apply_to: 'queues', 'exchanges', or 'all' (default).
|
|
315
|
+
dry_run: If True, preview without changing.
|
|
316
|
+
target: rabbitmq target name from config; omit for the default.
|
|
317
|
+
"""
|
|
318
|
+
conn = _get_connection(target)
|
|
319
|
+
if dry_run:
|
|
320
|
+
return {
|
|
321
|
+
"dryRun": True,
|
|
322
|
+
"wouldSetPolicy": {
|
|
323
|
+
"vhost": vhost, "policy": name, "pattern": pattern,
|
|
324
|
+
"definition": definition, "priority": priority, "applyTo": apply_to,
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
return ops.set_policy(
|
|
328
|
+
conn, vhost, name, pattern, definition, priority=priority, apply_to=apply_to
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp.tool()
|
|
333
|
+
@governed_tool(risk_level="medium", undo=_delete_policy_undo)
|
|
334
|
+
@tool_errors("dict")
|
|
335
|
+
def delete_policy(
|
|
336
|
+
vhost: str,
|
|
337
|
+
name: str,
|
|
338
|
+
dry_run: bool = False,
|
|
339
|
+
target: Optional[str] = None,
|
|
340
|
+
) -> dict:
|
|
341
|
+
"""[WRITE][risk=medium] Delete a rabbitmq policy; the undo re-creates it.
|
|
342
|
+
|
|
343
|
+
Reads the policy first so the recorded undo can re-create it exactly. Pass
|
|
344
|
+
dry_run=True to preview.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
vhost: The policy's vhost (the default vhost is '/').
|
|
348
|
+
name: Policy name (from list_policies).
|
|
349
|
+
dry_run: If True, preview without deleting.
|
|
350
|
+
target: rabbitmq target name from config; omit for the default.
|
|
351
|
+
"""
|
|
352
|
+
conn = _get_connection(target)
|
|
353
|
+
if dry_run:
|
|
354
|
+
return {"dryRun": True, "wouldDeletePolicy": {"vhost": vhost, "policy": name}}
|
|
355
|
+
return ops.delete_policy(conn, vhost, name)
|
queue_aiops/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""queue-aiops — governed redis + rabbitmq broker operations for AI agents.
|
|
2
|
+
|
|
3
|
+
Standalone and self-contained: the governance harness (audit, token budget,
|
|
4
|
+
undo-token recording, graduated risk tiers, output sanitize) is
|
|
5
|
+
bundled under ``queue_aiops.governance`` — this package has no external
|
|
6
|
+
skill-family dependency. Preview: not yet full-coverage.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("queue-aiops")
|
|
13
|
+
except PackageNotFoundError: # running from an uninstalled source tree
|
|
14
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared helpers for queue-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[
|
|
18
|
+
str | None, typer.Option("--target", "-t", help="Target name from config")
|
|
19
|
+
]
|
|
20
|
+
DryRunOption = Annotated[
|
|
21
|
+
bool, typer.Option("--dry-run", help="Print the API call without executing")
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
26
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback."""
|
|
27
|
+
from queue_aiops.connection import QueueApiError
|
|
28
|
+
|
|
29
|
+
return (QueueApiError, KeyError, OSError, ValueError)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
33
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
34
|
+
|
|
35
|
+
@functools.wraps(fn)
|
|
36
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return fn(*args, **kwargs)
|
|
39
|
+
except (typer.Exit, typer.Abort):
|
|
40
|
+
raise
|
|
41
|
+
except _cli_error_types() as e:
|
|
42
|
+
message = str(e)
|
|
43
|
+
if isinstance(e, KeyError):
|
|
44
|
+
message = f"Missing required key or environment variable: {message}"
|
|
45
|
+
console.print(f"[red]Error: {message}[/]")
|
|
46
|
+
raise typer.Exit(1) from e
|
|
47
|
+
|
|
48
|
+
return wrapper
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_connection(target: str | None, config_path: Path | None = None) -> tuple[Any, Any]:
|
|
52
|
+
"""Return a (conn, config) tuple for the given target."""
|
|
53
|
+
from queue_aiops.config import load_config
|
|
54
|
+
from queue_aiops.connection import ConnectionManager
|
|
55
|
+
|
|
56
|
+
cfg = load_config(config_path)
|
|
57
|
+
mgr = ConnectionManager(cfg)
|
|
58
|
+
return mgr.connect(target), cfg
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
|
|
62
|
+
"""Print a dry-run preview of the API call that would be made."""
|
|
63
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
|
|
64
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
65
|
+
console.print(f"[magenta] API Call: {api_call}[/]")
|
|
66
|
+
for k, v in (parameters or {}).items():
|
|
67
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
68
|
+
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
72
|
+
"""Require two confirmations for a destructive operation."""
|
|
73
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
74
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
75
|
+
typer.confirm(
|
|
76
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
77
|
+
abort=True,
|
|
78
|
+
)
|
queue_aiops/cli/_root.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Top-level Typer app: assembles sub-apps and top-level commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from queue_aiops.cli._common import cli_errors
|
|
8
|
+
from queue_aiops.cli.analyze import analyze_app
|
|
9
|
+
from queue_aiops.cli.doctor import doctor_cmd
|
|
10
|
+
from queue_aiops.cli.init import init_cmd
|
|
11
|
+
from queue_aiops.cli.overview import overview_cmd
|
|
12
|
+
from queue_aiops.cli.rabbit_cmds import rabbitmq_app
|
|
13
|
+
from queue_aiops.cli.redis_cmds import redis_app
|
|
14
|
+
from queue_aiops.cli.secret import secret_app
|
|
15
|
+
from queue_aiops.cli.undo import undo_app
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="queue-aiops",
|
|
19
|
+
help="Governed AI-ops for redis + rabbitmq: memory/latency/backlog/churn "
|
|
20
|
+
"RCAs, slowlog and big-key sampling, queues/policies, and governed writes "
|
|
21
|
+
"(config set, client kill, purge/delete queue, policies).",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app.add_typer(redis_app, name="redis")
|
|
26
|
+
app.add_typer(rabbitmq_app, name="rabbitmq")
|
|
27
|
+
app.add_typer(analyze_app, name="analyze")
|
|
28
|
+
app.add_typer(secret_app, name="secret")
|
|
29
|
+
app.add_typer(undo_app, name="undo")
|
|
30
|
+
app.command("init")(init_cmd)
|
|
31
|
+
app.command("overview")(overview_cmd)
|
|
32
|
+
app.command("doctor")(doctor_cmd)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("mcp")
|
|
36
|
+
@cli_errors
|
|
37
|
+
def mcp_cmd() -> None:
|
|
38
|
+
"""Start the MCP server (stdio transport).
|
|
39
|
+
|
|
40
|
+
Single-command entry point for MCP clients (does not go through uvx/PyPI
|
|
41
|
+
resolution at launch):
|
|
42
|
+
queue-aiops mcp
|
|
43
|
+
"""
|
|
44
|
+
import sys
|
|
45
|
+
|
|
46
|
+
if sys.version_info < (3, 11):
|
|
47
|
+
typer.echo(
|
|
48
|
+
f"ERROR: queue-aiops requires Python >= 3.11 "
|
|
49
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
50
|
+
f"Fix: uv python install 3.12 && "
|
|
51
|
+
f"uv tool install --python 3.12 --force queue-aiops",
|
|
52
|
+
err=True,
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(2)
|
|
55
|
+
|
|
56
|
+
from mcp_server.server import main as _mcp_main
|
|
57
|
+
|
|
58
|
+
_mcp_main()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
app()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""``queue-aiops analyze`` — the flagship RCA analyses (read-only)."""
|
|
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 TargetOption, cli_errors, console, get_connection
|
|
11
|
+
|
|
12
|
+
analyze_app = typer.Typer(
|
|
13
|
+
name="analyze",
|
|
14
|
+
help="Flagship RCAs: redis memory pressure + latency, rabbitmq queue "
|
|
15
|
+
"backlog, and connection churn (both platforms).",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@analyze_app.command("memory")
|
|
21
|
+
@cli_errors
|
|
22
|
+
def analyze_memory(
|
|
23
|
+
used_pct: Annotated[
|
|
24
|
+
float, typer.Option("--used-pct", help="used/maxmemory %% pressure threshold")
|
|
25
|
+
] = 85.0,
|
|
26
|
+
target: TargetOption = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""redis memory-pressure RCA: maxmemory, eviction, fragmentation, big keys."""
|
|
29
|
+
from queue_aiops.ops import analysis as ops
|
|
30
|
+
from queue_aiops.ops._util import as_obj, num
|
|
31
|
+
|
|
32
|
+
conn, _ = get_connection(target)
|
|
33
|
+
telemetry = ops.pull_memory_telemetry(conn)
|
|
34
|
+
console.print_json(json.dumps(ops.redis_memory_pressure_rca(
|
|
35
|
+
as_obj(telemetry.get("memory")),
|
|
36
|
+
evicted_keys=num(telemetry.get("evictedKeys")),
|
|
37
|
+
big_keys=as_obj(telemetry.get("bigKeys")),
|
|
38
|
+
used_pct=used_pct,
|
|
39
|
+
)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@analyze_app.command("latency")
|
|
43
|
+
@cli_errors
|
|
44
|
+
def analyze_latency(
|
|
45
|
+
slow_us: Annotated[
|
|
46
|
+
float, typer.Option("--slow-us", help="Slowlog threshold in microseconds")
|
|
47
|
+
] = 10_000.0,
|
|
48
|
+
target: TargetOption = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""redis latency RCA: slowlog digest, blocked clients, fork/AOF stalls."""
|
|
51
|
+
from queue_aiops.ops import analysis as ops
|
|
52
|
+
|
|
53
|
+
conn, _ = get_connection(target)
|
|
54
|
+
telemetry = ops.pull_latency_telemetry(conn)
|
|
55
|
+
console.print_json(json.dumps(ops.redis_latency_rca(telemetry, slow_us=slow_us)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@analyze_app.command("backlog")
|
|
59
|
+
@cli_errors
|
|
60
|
+
def analyze_backlog(
|
|
61
|
+
vhost: Annotated[
|
|
62
|
+
str | None, typer.Option("--vhost", help="Restrict to one vhost")
|
|
63
|
+
] = None,
|
|
64
|
+
top: Annotated[int, typer.Option("--top", help="Queue rows, deepest first")] = 20,
|
|
65
|
+
target: TargetOption = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""rabbitmq queue-backlog RCA: consumers, unacked pileups, watermark blocks."""
|
|
68
|
+
from queue_aiops.ops import analysis as ops
|
|
69
|
+
|
|
70
|
+
conn, _ = get_connection(target)
|
|
71
|
+
telemetry = ops.pull_backlog_telemetry(conn, vhost)
|
|
72
|
+
console.print_json(json.dumps(ops.rabbitmq_queue_backlog_rca(
|
|
73
|
+
telemetry.get("queues", []), nodes=telemetry.get("nodes", []), top=top,
|
|
74
|
+
)))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@analyze_app.command("churn")
|
|
78
|
+
@cli_errors
|
|
79
|
+
def analyze_churn(target: TargetOption = None) -> None:
|
|
80
|
+
"""Connection-churn analysis (both platforms): counts, rates, by source."""
|
|
81
|
+
from queue_aiops.ops import analysis as ops
|
|
82
|
+
|
|
83
|
+
conn, _ = get_connection(target)
|
|
84
|
+
snapshot = ops.pull_churn_snapshot(conn)
|
|
85
|
+
console.print_json(json.dumps(ops.connection_churn_analysis(snapshot)))
|
|
@@ -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 queue_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 queue_aiops.doctor import run_doctor
|
|
20
|
+
|
|
21
|
+
raise typer.Exit(run_doctor(skip_auth=skip_auth))
|