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,284 @@
|
|
|
1
|
+
"""Governed broker writes — the only state-changing operations in the tool.
|
|
2
|
+
|
|
3
|
+
Every reversible write reads the broker's current state **before** it changes
|
|
4
|
+
anything, so the harness records a faithful undo / audit trail (the before-state
|
|
5
|
+
is fetched via a real read, never guessed):
|
|
6
|
+
|
|
7
|
+
* ``config_set`` (redis) — reads the parameter's current value via CONFIG GET
|
|
8
|
+
first; undo sets it back.
|
|
9
|
+
* ``set_policy`` / ``delete_policy`` (rabbitmq) — read the current policy
|
|
10
|
+
first; undo restores the prior policy (or deletes a policy that did not
|
|
11
|
+
exist before).
|
|
12
|
+
* ``delete_queue`` (rabbitmq) — reads the queue's definition (durable /
|
|
13
|
+
auto_delete / arguments) first; undo re-declares the queue with the
|
|
14
|
+
captured properties. The *messages* in it are gone — the undo restores the
|
|
15
|
+
queue definition, not its contents, and says so.
|
|
16
|
+
|
|
17
|
+
Irreversible writes record priorState only (no undo): ``kill_client`` (the
|
|
18
|
+
connection is gone; priorState = the client row) and ``purge_queue`` (messages
|
|
19
|
+
are destroyed; priorState = the message count).
|
|
20
|
+
|
|
21
|
+
Each function returns a plain descriptor; the MCP layer adds dry-run + the
|
|
22
|
+
governance harness (risk tier + audit + undo).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from queue_aiops.connection import QueueApiError
|
|
31
|
+
from queue_aiops.ops._util import as_obj, num, s
|
|
32
|
+
|
|
33
|
+
# CONFIG SET parameter names are plain words (e.g. maxmemory-policy); reject
|
|
34
|
+
# anything else before it reaches the wire.
|
|
35
|
+
_CONFIG_PARAM_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── redis: config_set (reversible) ───────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def config_set(conn: Any, parameter: str, value: str) -> dict:
|
|
42
|
+
"""[WRITE][med] Set one redis config parameter, capturing its prior value.
|
|
43
|
+
|
|
44
|
+
Reads the parameter first (CONFIG GET) so ``priorState.value`` reflects
|
|
45
|
+
what it *was* (drives a faithful undo). Runtime-only — CONFIG SET does not
|
|
46
|
+
persist to the config file unless CONFIG REWRITE is run out-of-band.
|
|
47
|
+
"""
|
|
48
|
+
parameter = str(parameter).strip().lower()
|
|
49
|
+
if not _CONFIG_PARAM_RE.match(parameter):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Invalid config parameter name '{parameter[:64]}': expected a "
|
|
52
|
+
f"plain word like 'maxmemory-policy'."
|
|
53
|
+
)
|
|
54
|
+
prior = conn.redis_config_get(parameter)
|
|
55
|
+
if parameter not in prior:
|
|
56
|
+
raise KeyError(
|
|
57
|
+
f"Config parameter '{parameter}' does not exist on this instance — "
|
|
58
|
+
f"check the name with config_get first."
|
|
59
|
+
)
|
|
60
|
+
prior_value = s(prior.get(parameter), 128)
|
|
61
|
+
conn.redis_config_set(parameter, str(value))
|
|
62
|
+
return {
|
|
63
|
+
"action": "config_set",
|
|
64
|
+
"parameter": parameter,
|
|
65
|
+
"value": s(value, 128),
|
|
66
|
+
"priorState": {"value": prior_value},
|
|
67
|
+
"note": "Runtime change only — not persisted to the config file.",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── redis: kill_client (irreversible; priorState only) ──────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def kill_client(conn: Any, client_id: int = 0, addr: str = "") -> dict:
|
|
75
|
+
"""[WRITE][med] Disconnect one client by id or addr. IRREVERSIBLE.
|
|
76
|
+
|
|
77
|
+
Captures the client's CLIENT LIST row as priorState before the kill (who it
|
|
78
|
+
was, from where, last command) — the connection itself cannot be restored,
|
|
79
|
+
so no undo is recorded. Most clients transparently reconnect.
|
|
80
|
+
"""
|
|
81
|
+
if not client_id and not addr:
|
|
82
|
+
raise ValueError("Pass client_id (from list_clients) or addr ('ip:port').")
|
|
83
|
+
match: dict = {}
|
|
84
|
+
for c in conn.redis_client_list():
|
|
85
|
+
if (client_id and str(c.get("id")) == str(client_id)) or (
|
|
86
|
+
addr and str(c.get("addr")) == str(addr)
|
|
87
|
+
):
|
|
88
|
+
match = {
|
|
89
|
+
"id": s(c.get("id"), 32),
|
|
90
|
+
"addr": s(c.get("addr"), 64),
|
|
91
|
+
"name": s(c.get("name"), 64),
|
|
92
|
+
"lastCommand": s(c.get("cmd"), 64),
|
|
93
|
+
"ageSeconds": num(c.get("age")),
|
|
94
|
+
}
|
|
95
|
+
break
|
|
96
|
+
killed = (
|
|
97
|
+
conn.redis_client_kill_id(int(client_id))
|
|
98
|
+
if client_id
|
|
99
|
+
else conn.redis_client_kill_addr(str(addr))
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
"action": "kill_client",
|
|
103
|
+
"clientId": s(client_id, 32) if client_id else "",
|
|
104
|
+
"addr": s(addr, 64),
|
|
105
|
+
"killed": int(killed),
|
|
106
|
+
"priorState": {"client": match},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── rabbitmq: purge_queue (irreversible; priorState only) ────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _queue_def(conn: Any, vhost: str, name: str) -> dict:
|
|
114
|
+
"""Fetch a queue's current definition + counts (raises if it is missing)."""
|
|
115
|
+
obj = as_obj(
|
|
116
|
+
conn.platform.normalise(conn.get(conn.platform.path("queue", vhost=vhost, name=name)))
|
|
117
|
+
)
|
|
118
|
+
return {
|
|
119
|
+
"messages": num(obj.get("messages")),
|
|
120
|
+
"durable": bool(obj.get("durable")),
|
|
121
|
+
"autoDelete": bool(obj.get("auto_delete")),
|
|
122
|
+
"arguments": as_obj(obj.get("arguments")),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def purge_queue(conn: Any, vhost: str, name: str) -> dict:
|
|
127
|
+
"""[WRITE][high] Purge all ready messages from a queue. IRREVERSIBLE.
|
|
128
|
+
|
|
129
|
+
Reads the queue first so priorState carries the message count that is about
|
|
130
|
+
to be destroyed (audit evidence). Unacked messages are not purged.
|
|
131
|
+
"""
|
|
132
|
+
prior = _queue_def(conn, vhost, name)
|
|
133
|
+
conn.delete(conn.platform.path("queue_purge", vhost=vhost, name=name))
|
|
134
|
+
return {
|
|
135
|
+
"action": "purge_queue",
|
|
136
|
+
"vhost": s(vhost, 64),
|
|
137
|
+
"queue": s(name, 128),
|
|
138
|
+
"priorState": {"messages": prior["messages"]},
|
|
139
|
+
"note": "Messages are destroyed — no undo. Unacked messages are not purged.",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── rabbitmq: delete/declare queue (definition-reversible) ───────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def delete_queue(conn: Any, vhost: str, name: str) -> dict:
|
|
147
|
+
"""[WRITE][high] Delete a queue, capturing its definition for the undo.
|
|
148
|
+
|
|
149
|
+
Reads the queue first so priorState carries the full definition (durable /
|
|
150
|
+
auto_delete / arguments) and the message count. The undo re-declares the
|
|
151
|
+
queue with the captured properties — the queue comes back, its messages do
|
|
152
|
+
not (that is stated in the descriptor, not hidden).
|
|
153
|
+
"""
|
|
154
|
+
prior = _queue_def(conn, vhost, name)
|
|
155
|
+
conn.delete(conn.platform.path("queue", vhost=vhost, name=name))
|
|
156
|
+
return {
|
|
157
|
+
"action": "delete_queue",
|
|
158
|
+
"vhost": s(vhost, 64),
|
|
159
|
+
"queue": s(name, 128),
|
|
160
|
+
"priorState": prior,
|
|
161
|
+
"note": (
|
|
162
|
+
"Undo re-declares the queue with the captured definition; the "
|
|
163
|
+
f"{int(prior['messages'])} message(s) it held are NOT restored."
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def declare_queue(
|
|
169
|
+
conn: Any,
|
|
170
|
+
vhost: str,
|
|
171
|
+
name: str,
|
|
172
|
+
durable: bool = True,
|
|
173
|
+
auto_delete: bool = False,
|
|
174
|
+
arguments: dict | None = None,
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""[WRITE][med] Declare (create) a queue via PUT; idempotent when identical.
|
|
177
|
+
|
|
178
|
+
Reads first so priorState records whether the queue already existed. This
|
|
179
|
+
is also the replay target for delete_queue's undo descriptor.
|
|
180
|
+
"""
|
|
181
|
+
existed = True
|
|
182
|
+
try:
|
|
183
|
+
_queue_def(conn, vhost, name)
|
|
184
|
+
except QueueApiError as exc:
|
|
185
|
+
if exc.status_code != 404:
|
|
186
|
+
raise
|
|
187
|
+
existed = False
|
|
188
|
+
body = {
|
|
189
|
+
"durable": bool(durable),
|
|
190
|
+
"auto_delete": bool(auto_delete),
|
|
191
|
+
"arguments": as_obj(arguments),
|
|
192
|
+
}
|
|
193
|
+
conn.put(conn.platform.path("queue", vhost=vhost, name=name), json=body)
|
|
194
|
+
return {
|
|
195
|
+
"action": "declare_queue",
|
|
196
|
+
"vhost": s(vhost, 64),
|
|
197
|
+
"queue": s(name, 128),
|
|
198
|
+
"durable": bool(durable),
|
|
199
|
+
"autoDelete": bool(auto_delete),
|
|
200
|
+
"arguments": as_obj(arguments),
|
|
201
|
+
"priorState": {"existed": existed},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── rabbitmq: policies (reversible) ──────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _policy_or_none(conn: Any, vhost: str, name: str) -> dict | None:
|
|
209
|
+
"""Fetch a policy's current definition; None when it does not exist."""
|
|
210
|
+
try:
|
|
211
|
+
obj = as_obj(
|
|
212
|
+
conn.platform.normalise(
|
|
213
|
+
conn.get(conn.platform.path("policy", vhost=vhost, name=name))
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
except QueueApiError as exc:
|
|
217
|
+
if exc.status_code == 404:
|
|
218
|
+
return None
|
|
219
|
+
raise
|
|
220
|
+
return {
|
|
221
|
+
"pattern": s(obj.get("pattern"), 128),
|
|
222
|
+
"definition": as_obj(obj.get("definition")),
|
|
223
|
+
"priority": num(obj.get("priority")),
|
|
224
|
+
"applyTo": s(obj.get("apply-to") or obj.get("apply_to") or "all", 32),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def set_policy(
|
|
229
|
+
conn: Any,
|
|
230
|
+
vhost: str,
|
|
231
|
+
name: str,
|
|
232
|
+
pattern: str,
|
|
233
|
+
definition: dict,
|
|
234
|
+
priority: int = 0,
|
|
235
|
+
apply_to: str = "all",
|
|
236
|
+
) -> dict:
|
|
237
|
+
"""[WRITE][med] Create/update a policy, capturing the prior one for undo.
|
|
238
|
+
|
|
239
|
+
Reads the policy first: if it existed, undo restores the prior fields; if
|
|
240
|
+
it is new, undo deletes it.
|
|
241
|
+
"""
|
|
242
|
+
if not isinstance(definition, dict) or not definition:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"Policy 'definition' must be a non-empty object, e.g. "
|
|
245
|
+
"{'max-length': 100000} or {'message-ttl': 60000}."
|
|
246
|
+
)
|
|
247
|
+
prior = _policy_or_none(conn, vhost, name)
|
|
248
|
+
body = {
|
|
249
|
+
"pattern": str(pattern),
|
|
250
|
+
"definition": definition,
|
|
251
|
+
"priority": int(priority),
|
|
252
|
+
"apply-to": str(apply_to),
|
|
253
|
+
}
|
|
254
|
+
conn.put(conn.platform.path("policy", vhost=vhost, name=name), json=body)
|
|
255
|
+
return {
|
|
256
|
+
"action": "set_policy",
|
|
257
|
+
"vhost": s(vhost, 64),
|
|
258
|
+
"policy": s(name, 128),
|
|
259
|
+
"pattern": s(pattern, 128),
|
|
260
|
+
"definition": as_obj(definition),
|
|
261
|
+
"priority": int(priority),
|
|
262
|
+
"applyTo": s(apply_to, 32),
|
|
263
|
+
"priorState": {"existed": prior is not None, "policy": prior},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def delete_policy(conn: Any, vhost: str, name: str) -> dict:
|
|
268
|
+
"""[WRITE][med] Delete a policy, capturing its definition for the undo.
|
|
269
|
+
|
|
270
|
+
Reads the policy first so the undo can re-create it exactly.
|
|
271
|
+
"""
|
|
272
|
+
prior = _policy_or_none(conn, vhost, name)
|
|
273
|
+
if prior is None:
|
|
274
|
+
raise KeyError(
|
|
275
|
+
f"Policy '{name}' does not exist in vhost '{vhost}' — list_policies "
|
|
276
|
+
f"shows what is defined."
|
|
277
|
+
)
|
|
278
|
+
conn.delete(conn.platform.path("policy", vhost=vhost, name=name))
|
|
279
|
+
return {
|
|
280
|
+
"action": "delete_policy",
|
|
281
|
+
"vhost": s(vhost, 64),
|
|
282
|
+
"policy": s(name, 128),
|
|
283
|
+
"priorState": {"policy": prior},
|
|
284
|
+
}
|
queue_aiops/platform.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Platform descriptors — the message/cache brokers queue-aiops speaks to.
|
|
2
|
+
|
|
3
|
+
queue-aiops is multi-platform by construction. A registry maps a *platform
|
|
4
|
+
name* to a :class:`Platform` descriptor that captures everything the connection
|
|
5
|
+
and ops layers need to talk to that broker: which protocol it speaks, the
|
|
6
|
+
default port, and — for an HTTP management plane — the concrete REST path for
|
|
7
|
+
each *logical resource* plus how a raw response is normalised (injection-safe).
|
|
8
|
+
|
|
9
|
+
v0.1 registers two platforms:
|
|
10
|
+
|
|
11
|
+
* **redis** — the Redis wire protocol (RESP) via the ``redis`` Python client.
|
|
12
|
+
Password auth (``AUTH``) is optional (an auth-less lab instance is common),
|
|
13
|
+
TLS optional. There are no REST paths; the connection layer exposes typed
|
|
14
|
+
command helpers (INFO / SLOWLOG / CLIENT LIST / CONFIG / MEMORY / SCAN).
|
|
15
|
+
* **rabbitmq** — the RabbitMQ management HTTP API (``/api/...``) with HTTP
|
|
16
|
+
Basic auth (management-plugin user). Queue and policy names — and the vhost,
|
|
17
|
+
whose default is literally ``/`` — are percent-encoded into every path.
|
|
18
|
+
|
|
19
|
+
Additional brokers can ``register`` their own descriptor later without touching
|
|
20
|
+
the ops / CLI / MCP layers — a registry keyed by ``platform`` name.
|
|
21
|
+
|
|
22
|
+
The concrete REST paths below are modelled from the management API's public
|
|
23
|
+
documentation and are exercised against mocked responses only; see the README's
|
|
24
|
+
preview note.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any
|
|
31
|
+
from urllib.parse import quote
|
|
32
|
+
|
|
33
|
+
from queue_aiops.governance import sanitize
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _seg(value: Any) -> str:
|
|
37
|
+
"""URL-encode one path segment so agent-supplied identifiers (queue names,
|
|
38
|
+
policy names, and especially the default vhost ``/``) cannot smuggle ``/``,
|
|
39
|
+
``../`` or query metacharacters into the request URL."""
|
|
40
|
+
return quote(str(value), safe="")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ─── registered platform names ──────────────────────────────────────────────
|
|
44
|
+
REDIS = "redis"
|
|
45
|
+
RABBITMQ = "rabbitmq"
|
|
46
|
+
PLATFORMS = (REDIS, RABBITMQ)
|
|
47
|
+
|
|
48
|
+
# Protocol kinds.
|
|
49
|
+
KIND_CLIENT = "client" # native wire protocol via a Python client (redis)
|
|
50
|
+
KIND_HTTP = "http" # HTTP management API (rabbitmq)
|
|
51
|
+
|
|
52
|
+
# Bounds for the response normaliser (defensive against a hostile broker).
|
|
53
|
+
_MAX_STR = 512
|
|
54
|
+
_MAX_DEPTH = 8
|
|
55
|
+
|
|
56
|
+
# Keys under which an HTTP platform wraps a list payload, tried in order before
|
|
57
|
+
# falling back to a bare JSON array (the management API pages under ``items``).
|
|
58
|
+
_LIST_KEYS = ("items", "rows", "data", "results")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sanitize_obj(obj: Any, depth: int = 0) -> Any:
|
|
62
|
+
"""Recursively fold broker-returned data into injection-safe values.
|
|
63
|
+
|
|
64
|
+
Every string leaf passes through ``sanitize`` (bounded length); numbers,
|
|
65
|
+
booleans and ``None`` pass through unchanged. Depth is capped so a
|
|
66
|
+
pathological nesting cannot exhaust the stack.
|
|
67
|
+
"""
|
|
68
|
+
if depth > _MAX_DEPTH:
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(obj, dict):
|
|
71
|
+
return {str(k): _sanitize_obj(v, depth + 1) for k, v in obj.items()}
|
|
72
|
+
if isinstance(obj, (list, tuple)):
|
|
73
|
+
return [_sanitize_obj(v, depth + 1) for v in obj]
|
|
74
|
+
if isinstance(obj, str):
|
|
75
|
+
return sanitize(obj, _MAX_STR)
|
|
76
|
+
return obj
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Platform:
|
|
81
|
+
"""A broker's shape: protocol kind + logical-resource path map + normaliser."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
label: str
|
|
85
|
+
kind: str
|
|
86
|
+
default_port: int
|
|
87
|
+
paths: dict[str, str] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_http(self) -> bool:
|
|
91
|
+
return self.kind == KIND_HTTP
|
|
92
|
+
|
|
93
|
+
def path(self, resource: str, **fmt: Any) -> str:
|
|
94
|
+
"""Return the concrete REST path for a logical ``resource``.
|
|
95
|
+
|
|
96
|
+
Raises a teaching ``KeyError`` when the resource is not mapped for this
|
|
97
|
+
platform (so a caller asking for an unsupported surface fails fast with
|
|
98
|
+
the list of what *is* available, rather than hitting a confusing 404).
|
|
99
|
+
|
|
100
|
+
Every substituted value is URL-encoded (``quote(..., safe="")``) so an
|
|
101
|
+
agent-supplied identifier can never rewrite the path (e.g. via ``../``
|
|
102
|
+
or the vhost ``/``).
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
template = self.paths[resource]
|
|
106
|
+
except KeyError as exc:
|
|
107
|
+
available = ", ".join(sorted(self.paths)) or "(none)"
|
|
108
|
+
raise KeyError(
|
|
109
|
+
f"Resource '{resource}' is not mapped for platform '{self.name}'. "
|
|
110
|
+
f"Mapped resources: {available}."
|
|
111
|
+
) from exc
|
|
112
|
+
if not fmt:
|
|
113
|
+
return template
|
|
114
|
+
return template.format(**{k: _seg(v) for k, v in fmt.items()})
|
|
115
|
+
|
|
116
|
+
def supports(self, resource: str) -> bool:
|
|
117
|
+
return resource in self.paths
|
|
118
|
+
|
|
119
|
+
def rows(self, payload: Any) -> list[dict]:
|
|
120
|
+
"""Normalise a list payload to a sanitised list of dict rows.
|
|
121
|
+
|
|
122
|
+
A bare JSON array passes through; a dict is unwrapped via the first of
|
|
123
|
+
``items``/``rows``/``data``/``results`` that is present. Every row is
|
|
124
|
+
run through the injection-safe normaliser.
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(payload, dict):
|
|
127
|
+
items: Any = []
|
|
128
|
+
for key in _LIST_KEYS:
|
|
129
|
+
value = payload.get(key)
|
|
130
|
+
if isinstance(value, list):
|
|
131
|
+
items = value
|
|
132
|
+
break
|
|
133
|
+
else:
|
|
134
|
+
items = payload
|
|
135
|
+
return [_sanitize_obj(r) for r in (items or []) if isinstance(r, dict)]
|
|
136
|
+
|
|
137
|
+
def normalise(self, payload: Any) -> Any:
|
|
138
|
+
"""Return an injection-safe copy of a raw response payload."""
|
|
139
|
+
return _sanitize_obj(payload)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ─── registry ───────────────────────────────────────────────────────────────
|
|
143
|
+
_REGISTRY: dict[str, Platform] = {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register(platform: Platform) -> None:
|
|
147
|
+
"""Register a platform descriptor under its name (idempotent overwrite)."""
|
|
148
|
+
_REGISTRY[platform.name] = platform
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_platform(name: str) -> Platform:
|
|
152
|
+
"""Return the descriptor for ``name`` or raise with the registered names."""
|
|
153
|
+
try:
|
|
154
|
+
return _REGISTRY[name]
|
|
155
|
+
except KeyError as exc:
|
|
156
|
+
available = ", ".join(sorted(_REGISTRY)) or "(none)"
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Unknown platform '{name}'. Registered platforms: {available}."
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def platform_names() -> tuple[str, ...]:
|
|
163
|
+
"""All registered platform names (sorted)."""
|
|
164
|
+
return tuple(sorted(_REGISTRY))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ─── RabbitMQ management HTTP API (/api/..., HTTP Basic) ─────────────────────
|
|
168
|
+
_RABBITMQ_PATHS = {
|
|
169
|
+
# cluster / broker
|
|
170
|
+
"overview": "/api/overview",
|
|
171
|
+
"nodes": "/api/nodes",
|
|
172
|
+
"whoami": "/api/whoami",
|
|
173
|
+
# queues
|
|
174
|
+
"queues": "/api/queues",
|
|
175
|
+
"queues_vhost": "/api/queues/{vhost}",
|
|
176
|
+
"queue": "/api/queues/{vhost}/{name}",
|
|
177
|
+
"queue_purge": "/api/queues/{vhost}/{name}/contents",
|
|
178
|
+
# connections / channels / consumers
|
|
179
|
+
"connections": "/api/connections",
|
|
180
|
+
"channels": "/api/channels",
|
|
181
|
+
"consumers": "/api/consumers",
|
|
182
|
+
# policies
|
|
183
|
+
"policies": "/api/policies",
|
|
184
|
+
"policies_vhost": "/api/policies/{vhost}",
|
|
185
|
+
"policy": "/api/policies/{vhost}/{name}",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
register(
|
|
189
|
+
Platform(
|
|
190
|
+
name=REDIS,
|
|
191
|
+
label="Redis (RESP client)",
|
|
192
|
+
kind=KIND_CLIENT,
|
|
193
|
+
default_port=6379,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
register(
|
|
197
|
+
Platform(
|
|
198
|
+
name=RABBITMQ,
|
|
199
|
+
label="RabbitMQ management API",
|
|
200
|
+
kind=KIND_HTTP,
|
|
201
|
+
default_port=15672,
|
|
202
|
+
paths=_RABBITMQ_PATHS,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = [
|
|
208
|
+
"REDIS",
|
|
209
|
+
"RABBITMQ",
|
|
210
|
+
"PLATFORMS",
|
|
211
|
+
"KIND_CLIENT",
|
|
212
|
+
"KIND_HTTP",
|
|
213
|
+
"Platform",
|
|
214
|
+
"register",
|
|
215
|
+
"get_platform",
|
|
216
|
+
"platform_names",
|
|
217
|
+
]
|