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
queue_aiops/cli/undo.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``queue-aiops undo`` — list recorded undo tokens and apply them.
|
|
2
|
+
|
|
3
|
+
Real execution is delegated to the ``@governed_tool``-wrapped functions in
|
|
4
|
+
``mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
|
|
5
|
+
path as any other write (the inverse tool it dispatches is itself re-gated).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from queue_aiops.cli._common import (
|
|
16
|
+
DryRunOption,
|
|
17
|
+
TargetOption,
|
|
18
|
+
cli_errors,
|
|
19
|
+
console,
|
|
20
|
+
double_confirm,
|
|
21
|
+
dry_run_print,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
undo_app = typer.Typer(
|
|
25
|
+
name="undo",
|
|
26
|
+
help="List recorded undo tokens and apply their inverse operations.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
UndoIdArg = Annotated[str, typer.Argument(help="Undo id from 'undo list'")]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@undo_app.command("list")
|
|
34
|
+
@cli_errors
|
|
35
|
+
def undo_list_cmd(
|
|
36
|
+
limit: Annotated[int, typer.Option("--limit", help="Max tokens to show")] = 50,
|
|
37
|
+
target: TargetOption = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""List recorded, not-yet-applied undo tokens."""
|
|
40
|
+
from mcp_server.tools import undo as gov
|
|
41
|
+
|
|
42
|
+
console.print_json(json.dumps(gov.undo_list(limit=limit, target=target)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@undo_app.command("apply")
|
|
46
|
+
@cli_errors
|
|
47
|
+
def undo_apply_cmd(
|
|
48
|
+
undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
|
|
51
|
+
from mcp_server.tools import undo as gov
|
|
52
|
+
|
|
53
|
+
if dry_run:
|
|
54
|
+
preview = gov.undo_apply(undo_id=undo_id, dry_run=True, target=target)
|
|
55
|
+
dry_run_print(
|
|
56
|
+
operation="undo_apply",
|
|
57
|
+
api_call=f"inverse: {preview.get('wouldApply', {}).get('tool', '?')}",
|
|
58
|
+
parameters=preview.get("wouldApply", {}).get("params", {}),
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
double_confirm("apply undo", undo_id)
|
|
62
|
+
console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
|
queue_aiops/config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Configuration management for Queue AIops.
|
|
2
|
+
|
|
3
|
+
Loads broker connection targets from a YAML config file. Each target names its
|
|
4
|
+
``platform`` — ``redis`` (RESP wire protocol via the Python client) or
|
|
5
|
+
``rabbitmq`` (management HTTP API) — so one config can span a mixed estate. See
|
|
6
|
+
:mod:`queue_aiops.platform` for how the platform name selects the protocol
|
|
7
|
+
shape (client vs HTTP + resource paths).
|
|
8
|
+
|
|
9
|
+
The secret is NEVER stored in the config file or in plaintext on disk: it lives
|
|
10
|
+
in the encrypted store ``~/.queue-aiops/secrets.enc`` (see
|
|
11
|
+
:mod:`queue_aiops.secretstore`). For redis the secret is the ``AUTH`` password
|
|
12
|
+
— **optional**, because auth-less lab instances are common (reachability is
|
|
13
|
+
then the boundary). For rabbitmq it is the management user's password that
|
|
14
|
+
pairs with ``username`` (presented as HTTP Basic auth) and is required. A
|
|
15
|
+
legacy env var (``QUEUE_<TARGET>_SECRET``) is honoured as a fallback.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from queue_aiops.governance.paths import ops_home
|
|
29
|
+
from queue_aiops.platform import PLATFORMS, RABBITMQ, REDIS, get_platform
|
|
30
|
+
from queue_aiops.secretstore import SecretStoreError, get_secret, has_store
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from queue_aiops.platform import Platform
|
|
34
|
+
|
|
35
|
+
CONFIG_DIR = ops_home()
|
|
36
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
37
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
38
|
+
|
|
39
|
+
SECRET_ENV_PREFIX = "QUEUE_" # nosec B105 — env-var name, not a secret
|
|
40
|
+
SECRET_ENV_SUFFIX = "_SECRET" # nosec B105 — env-var name, not a secret
|
|
41
|
+
|
|
42
|
+
_log = logging.getLogger("queue-aiops.config")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _secret_env_key(name: str) -> str:
|
|
46
|
+
"""Legacy per-target secret env var name, e.g. QUEUE_CACHE1_SECRET."""
|
|
47
|
+
return f"{SECRET_ENV_PREFIX}{name.upper().replace('-', '_')}{SECRET_ENV_SUFFIX}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_secret(name: str, *, required: bool) -> str:
|
|
51
|
+
"""Return a target's secret: encrypted store first, then legacy env var.
|
|
52
|
+
|
|
53
|
+
``required=False`` (redis) returns "" when no secret is stored anywhere —
|
|
54
|
+
an auth-less lab instance is a legitimate setup, not an error.
|
|
55
|
+
"""
|
|
56
|
+
if has_store():
|
|
57
|
+
try:
|
|
58
|
+
return get_secret(name)
|
|
59
|
+
except SecretStoreError:
|
|
60
|
+
pass # fall through to legacy env var
|
|
61
|
+
legacy = os.environ.get(_secret_env_key(name))
|
|
62
|
+
if legacy:
|
|
63
|
+
_log.warning(
|
|
64
|
+
"Using plaintext env var %s. Migrate to the encrypted store with "
|
|
65
|
+
"'queue-aiops secret migrate'.",
|
|
66
|
+
_secret_env_key(name),
|
|
67
|
+
)
|
|
68
|
+
return legacy
|
|
69
|
+
if not required:
|
|
70
|
+
return ""
|
|
71
|
+
raise OSError(
|
|
72
|
+
f"No secret for target '{name}'. Add one with "
|
|
73
|
+
f"'queue-aiops secret set {name}' (stored encrypted), or run "
|
|
74
|
+
f"'queue-aiops init'."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class TargetConfig:
|
|
80
|
+
"""A connection target for one broker.
|
|
81
|
+
|
|
82
|
+
``platform`` is ``redis`` or ``rabbitmq`` (validated at construction).
|
|
83
|
+
``username`` holds the rabbitmq management user (unused for redis); ``db``
|
|
84
|
+
is the redis logical database index (unused for rabbitmq). ``use_tls``
|
|
85
|
+
selects rediss:// / https://; ``verify_ssl`` governs certificate checks.
|
|
86
|
+
The secret (redis password — optional — / rabbitmq password) comes from the
|
|
87
|
+
encrypted store.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
name: str
|
|
91
|
+
platform: str = REDIS
|
|
92
|
+
host: str = ""
|
|
93
|
+
port: int = 0
|
|
94
|
+
username: str = ""
|
|
95
|
+
db: int = 0
|
|
96
|
+
use_tls: bool = False
|
|
97
|
+
verify_ssl: bool = True
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if self.platform not in PLATFORMS:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Target '{self.name}': platform must be one of {PLATFORMS}, "
|
|
103
|
+
f"got '{self.platform}'."
|
|
104
|
+
)
|
|
105
|
+
if not self.port:
|
|
106
|
+
object.__setattr__(self, "port", self.platform_obj.default_port)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def platform_obj(self) -> Platform:
|
|
110
|
+
return get_platform(self.platform)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def secret_required(self) -> bool:
|
|
114
|
+
"""Redis auth is optional (lab instances); rabbitmq Basic auth is not."""
|
|
115
|
+
return self.platform == RABBITMQ
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def secret(self) -> str:
|
|
119
|
+
return _resolve_secret(self.name, required=self.secret_required)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def base_url(self) -> str:
|
|
123
|
+
"""HTTP base URL (rabbitmq management API)."""
|
|
124
|
+
scheme = "https" if self.use_tls else "http"
|
|
125
|
+
return f"{scheme}://{self.host}:{self.port}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class AppConfig:
|
|
130
|
+
"""Top-level application config."""
|
|
131
|
+
|
|
132
|
+
targets: tuple[TargetConfig, ...] = ()
|
|
133
|
+
|
|
134
|
+
def get_target(self, name: str) -> TargetConfig:
|
|
135
|
+
for t in self.targets:
|
|
136
|
+
if t.name == name:
|
|
137
|
+
return t
|
|
138
|
+
available = ", ".join(t.name for t in self.targets) or "(none)"
|
|
139
|
+
raise KeyError(f"Target '{name}' not found. Available: {available}")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def default_target(self) -> TargetConfig:
|
|
143
|
+
if not self.targets:
|
|
144
|
+
raise ValueError("No targets configured. Check config.yaml")
|
|
145
|
+
return self.targets[0]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config(config_path: Path | None = None) -> AppConfig:
|
|
149
|
+
"""Load config from YAML; the secret comes from the encrypted store."""
|
|
150
|
+
path = config_path or CONFIG_FILE
|
|
151
|
+
if not path.exists():
|
|
152
|
+
raise FileNotFoundError(
|
|
153
|
+
f"Config file not found: {path}\n"
|
|
154
|
+
f"Run 'queue-aiops init' to set up a redis or rabbitmq target, "
|
|
155
|
+
f"or create {CONFIG_FILE} with a 'targets' list."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
with open(path) as f:
|
|
159
|
+
raw = yaml.safe_load(f) or {}
|
|
160
|
+
|
|
161
|
+
targets = tuple(
|
|
162
|
+
TargetConfig(
|
|
163
|
+
name=t["name"],
|
|
164
|
+
platform=t.get("platform", REDIS),
|
|
165
|
+
host=t["host"],
|
|
166
|
+
port=t.get("port", 0),
|
|
167
|
+
username=t.get("username", ""),
|
|
168
|
+
db=t.get("db", 0),
|
|
169
|
+
use_tls=t.get("use_tls", False),
|
|
170
|
+
verify_ssl=t.get("verify_ssl", True),
|
|
171
|
+
)
|
|
172
|
+
for t in raw.get("targets", [])
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return AppConfig(targets=targets)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Connection management for brokers (redis + rabbitmq).
|
|
2
|
+
|
|
3
|
+
A single :class:`QueueConnection` speaks the protocol of its target's platform:
|
|
4
|
+
|
|
5
|
+
* **redis** — the Redis wire protocol via the ``redis`` Python client
|
|
6
|
+
(password optional, TLS optional, 30s socket timeouts,
|
|
7
|
+
``decode_responses=True`` so all values come back as ``str``). Typed
|
|
8
|
+
``redis_*`` helpers cover exactly the command surface the ops layer needs —
|
|
9
|
+
INFO / SLOWLOG / CLIENT LIST / CLIENT KILL / CONFIG GET/SET /
|
|
10
|
+
MEMORY STATS/USAGE / SCAN / DBSIZE / PING. There is deliberately **no**
|
|
11
|
+
generic "run any command" passthrough, and big-key sampling is SCAN-based
|
|
12
|
+
with a hard budget — never ``KEYS *``.
|
|
13
|
+
* **rabbitmq** — the management HTTP API with HTTP Basic auth. Ops modules
|
|
14
|
+
never hard-code a path: they ask ``conn.platform.path("queues")`` for the
|
|
15
|
+
concrete URL (every interpolated segment percent-encoded, including the
|
|
16
|
+
default vhost ``/``) and ``conn.platform.rows()`` to unwrap list payloads.
|
|
17
|
+
|
|
18
|
+
All failures are translated centrally into ``QueueApiError`` with a teaching
|
|
19
|
+
message — protocol errors are translated at the connection layer rather than
|
|
20
|
+
leaking raw tracebacks. The underlying client is injectable for tests: pass
|
|
21
|
+
``client=`` a mock implementing the redis client methods (redis targets) or
|
|
22
|
+
``request`` / ``close`` (rabbitmq targets). No live broker is needed in tests.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from queue_aiops.config import AppConfig, TargetConfig, load_config
|
|
32
|
+
from queue_aiops.platform import RABBITMQ, REDIS
|
|
33
|
+
|
|
34
|
+
_TIMEOUT = 30.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QueueApiError(Exception):
|
|
38
|
+
"""A broker call failed; carries a teaching message + status/context."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, *, status_code: int | None = None, path: str = "") -> None:
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.path = path
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _teaching_message(status: int, path: str, body: str, label: str) -> str:
|
|
47
|
+
"""Map a non-2xx management-API status to an actionable, teaching message."""
|
|
48
|
+
snippet = body[:200].strip()
|
|
49
|
+
if status in (401, 403):
|
|
50
|
+
return (
|
|
51
|
+
f"Authentication/authorization failed ({status}) on {label} {path}. "
|
|
52
|
+
f"Check the management user/password and that the user has the "
|
|
53
|
+
f"'monitoring' or 'management' tag (and access to the vhost). {snippet}"
|
|
54
|
+
)
|
|
55
|
+
if status == 404:
|
|
56
|
+
return (
|
|
57
|
+
f"Resource not found (404) on {label} {path}. The vhost or "
|
|
58
|
+
f"queue/policy name may be stale — list the parent collection first "
|
|
59
|
+
f"(the default vhost is '/', sent percent-encoded). {snippet}"
|
|
60
|
+
)
|
|
61
|
+
if status == 400:
|
|
62
|
+
return (
|
|
63
|
+
f"Bad request (400) on {label} {path}. The broker rejected the "
|
|
64
|
+
f"request — check required fields and value formats. {snippet}"
|
|
65
|
+
)
|
|
66
|
+
if status in (500, 502, 503, 504):
|
|
67
|
+
return (
|
|
68
|
+
f"{label} server error ({status}) on {path}. The broker may be "
|
|
69
|
+
f"busy; retry shortly. {snippet}"
|
|
70
|
+
)
|
|
71
|
+
return f"{label} API error ({status}) on {path}. {snippet}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class QueueConnection:
|
|
75
|
+
"""A single authenticated session against one redis or rabbitmq target."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, target: TargetConfig, client: Any | None = None) -> None:
|
|
78
|
+
self._target = target
|
|
79
|
+
self._client = client or self._build_client(target)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _build_client(target: TargetConfig) -> Any:
|
|
83
|
+
if target.platform == REDIS:
|
|
84
|
+
import redis as redis_pkg # lazy: only redis targets need it
|
|
85
|
+
|
|
86
|
+
kwargs: dict[str, Any] = {
|
|
87
|
+
"host": target.host,
|
|
88
|
+
"port": target.port,
|
|
89
|
+
"db": target.db,
|
|
90
|
+
"socket_timeout": _TIMEOUT,
|
|
91
|
+
"socket_connect_timeout": _TIMEOUT,
|
|
92
|
+
"decode_responses": True,
|
|
93
|
+
}
|
|
94
|
+
if target.secret:
|
|
95
|
+
kwargs["password"] = target.secret
|
|
96
|
+
if target.use_tls:
|
|
97
|
+
kwargs["ssl"] = True
|
|
98
|
+
kwargs["ssl_cert_reqs"] = "required" if target.verify_ssl else "none"
|
|
99
|
+
return redis_pkg.Redis(**kwargs)
|
|
100
|
+
return httpx.Client(
|
|
101
|
+
base_url=target.base_url,
|
|
102
|
+
verify=target.verify_ssl,
|
|
103
|
+
timeout=_TIMEOUT,
|
|
104
|
+
auth=httpx.BasicAuth(target.username, target.secret),
|
|
105
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def target(self) -> TargetConfig:
|
|
110
|
+
return self._target
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def platform(self) -> Any:
|
|
114
|
+
return self._target.platform_obj
|
|
115
|
+
|
|
116
|
+
def _require(self, platform_name: str) -> None:
|
|
117
|
+
"""Fail fast (teaching) when an op is asked of the wrong platform."""
|
|
118
|
+
if self._target.platform != platform_name:
|
|
119
|
+
raise QueueApiError(
|
|
120
|
+
f"Target '{self._target.name}' is a {self._target.platform} "
|
|
121
|
+
f"target; this operation needs a {platform_name} target. "
|
|
122
|
+
f"Pass a {platform_name} target name, or check the config."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
# ── redis command helpers (typed surface, no generic passthrough) ──────
|
|
129
|
+
def _redis_call(self, description: str, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
"""Guard the platform FIRST, then resolve + run the client method."""
|
|
131
|
+
self._require(REDIS)
|
|
132
|
+
fn = getattr(self._client, method)
|
|
133
|
+
try:
|
|
134
|
+
return fn(*args, **kwargs)
|
|
135
|
+
except QueueApiError:
|
|
136
|
+
raise
|
|
137
|
+
except Exception as exc: # redis.RedisError, socket errors — translated
|
|
138
|
+
raise QueueApiError(
|
|
139
|
+
f"Redis call failed ({description}) against "
|
|
140
|
+
f"{self._target.host}:{self._target.port}: {exc}. Check "
|
|
141
|
+
f"host/port, password (AUTH), and that the instance is up.",
|
|
142
|
+
path=description,
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
def redis_ping(self) -> bool:
|
|
146
|
+
return bool(self._redis_call("PING", "ping"))
|
|
147
|
+
|
|
148
|
+
def redis_info(self, section: str | None = None) -> dict:
|
|
149
|
+
out = (
|
|
150
|
+
self._redis_call(f"INFO {section}", "info", section)
|
|
151
|
+
if section
|
|
152
|
+
else self._redis_call("INFO", "info")
|
|
153
|
+
)
|
|
154
|
+
return out if isinstance(out, dict) else {}
|
|
155
|
+
|
|
156
|
+
def redis_slowlog(self, count: int = 128) -> list:
|
|
157
|
+
out = self._redis_call("SLOWLOG GET", "slowlog_get", count)
|
|
158
|
+
return out if isinstance(out, list) else []
|
|
159
|
+
|
|
160
|
+
def redis_client_list(self) -> list[dict]:
|
|
161
|
+
out = self._redis_call("CLIENT LIST", "client_list")
|
|
162
|
+
return [c for c in out if isinstance(c, dict)] if isinstance(out, list) else []
|
|
163
|
+
|
|
164
|
+
def redis_client_kill_id(self, client_id: int) -> int:
|
|
165
|
+
"""CLIENT KILL ID <id>; returns the number of clients killed."""
|
|
166
|
+
out = self._redis_call(
|
|
167
|
+
"CLIENT KILL ID", "client_kill_filter", _id=str(int(client_id))
|
|
168
|
+
)
|
|
169
|
+
return int(out or 0)
|
|
170
|
+
|
|
171
|
+
def redis_client_kill_addr(self, addr: str) -> int:
|
|
172
|
+
"""CLIENT KILL ADDR <addr>; returns the number of clients killed."""
|
|
173
|
+
out = self._redis_call("CLIENT KILL ADDR", "client_kill_filter", addr=str(addr))
|
|
174
|
+
return int(out or 0)
|
|
175
|
+
|
|
176
|
+
def redis_config_get(self, pattern: str) -> dict:
|
|
177
|
+
out = self._redis_call(f"CONFIG GET {pattern}", "config_get", pattern)
|
|
178
|
+
return out if isinstance(out, dict) else {}
|
|
179
|
+
|
|
180
|
+
def redis_config_set(self, param: str, value: str) -> bool:
|
|
181
|
+
return bool(self._redis_call(f"CONFIG SET {param}", "config_set", param, value))
|
|
182
|
+
|
|
183
|
+
def redis_memory_stats(self) -> dict:
|
|
184
|
+
out = self._redis_call("MEMORY STATS", "memory_stats")
|
|
185
|
+
return out if isinstance(out, dict) else {}
|
|
186
|
+
|
|
187
|
+
def redis_memory_usage(self, key: str, samples: int = 0) -> int | None:
|
|
188
|
+
out = self._redis_call("MEMORY USAGE", "memory_usage", key, samples=samples)
|
|
189
|
+
return int(out) if out is not None else None
|
|
190
|
+
|
|
191
|
+
def redis_scan(self, cursor: int = 0, count: int = 500) -> tuple[int, list[str]]:
|
|
192
|
+
"""One SCAN page. The caller enforces the scan budget."""
|
|
193
|
+
out = self._redis_call("SCAN", "scan", cursor=cursor, count=count)
|
|
194
|
+
nxt, keys = (out if isinstance(out, (tuple, list)) and len(out) == 2 else (0, []))
|
|
195
|
+
return int(nxt), [str(k) for k in (keys or [])]
|
|
196
|
+
|
|
197
|
+
def redis_dbsize(self) -> int:
|
|
198
|
+
return int(self._redis_call("DBSIZE", "dbsize") or 0)
|
|
199
|
+
|
|
200
|
+
# ── rabbitmq management-API helpers (HTTP) ──────────────────────────────
|
|
201
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
202
|
+
self._require(RABBITMQ)
|
|
203
|
+
label = self._target.platform_obj.label
|
|
204
|
+
try:
|
|
205
|
+
resp = self._client.request(method, path, **kwargs)
|
|
206
|
+
except httpx.HTTPError as exc:
|
|
207
|
+
raise QueueApiError(
|
|
208
|
+
f"Could not reach {label} at {self._target.base_url} "
|
|
209
|
+
f"({method} {path}): {exc}. Check host/port and that the "
|
|
210
|
+
f"management plugin is enabled.",
|
|
211
|
+
path=path,
|
|
212
|
+
) from exc
|
|
213
|
+
if not (200 <= resp.status_code < 300):
|
|
214
|
+
raise QueueApiError(
|
|
215
|
+
_teaching_message(resp.status_code, path, resp.text, label),
|
|
216
|
+
status_code=resp.status_code,
|
|
217
|
+
path=path,
|
|
218
|
+
)
|
|
219
|
+
if not resp.content:
|
|
220
|
+
return {}
|
|
221
|
+
try:
|
|
222
|
+
return resp.json()
|
|
223
|
+
except ValueError:
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
227
|
+
return self._request("GET", path, **kwargs)
|
|
228
|
+
|
|
229
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
230
|
+
return self._request("POST", path, **kwargs)
|
|
231
|
+
|
|
232
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
233
|
+
return self._request("PUT", path, **kwargs)
|
|
234
|
+
|
|
235
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
236
|
+
return self._request("DELETE", path, **kwargs)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ConnectionManager:
|
|
240
|
+
"""Manages connections to multiple broker targets with session reuse."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, config: AppConfig) -> None:
|
|
243
|
+
self._config = config
|
|
244
|
+
self._connections: dict[str, QueueConnection] = {}
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_config(cls, config: AppConfig | None = None) -> ConnectionManager:
|
|
248
|
+
cfg = config or load_config()
|
|
249
|
+
return cls(cfg)
|
|
250
|
+
|
|
251
|
+
def connect(self, target_name: str | None = None) -> QueueConnection:
|
|
252
|
+
target = (
|
|
253
|
+
self._config.get_target(target_name)
|
|
254
|
+
if target_name
|
|
255
|
+
else self._config.default_target
|
|
256
|
+
)
|
|
257
|
+
cached = self._connections.get(target.name)
|
|
258
|
+
if cached is not None:
|
|
259
|
+
return cached
|
|
260
|
+
conn = QueueConnection(target)
|
|
261
|
+
self._connections[target.name] = conn
|
|
262
|
+
return conn
|
|
263
|
+
|
|
264
|
+
def disconnect(self, target_name: str) -> None:
|
|
265
|
+
conn = self._connections.pop(target_name, None)
|
|
266
|
+
if conn is not None:
|
|
267
|
+
conn.close()
|
|
268
|
+
|
|
269
|
+
def disconnect_all(self) -> None:
|
|
270
|
+
for name in list(self._connections):
|
|
271
|
+
self.disconnect(name)
|
|
272
|
+
|
|
273
|
+
def list_targets(self) -> list[str]:
|
|
274
|
+
return [t.name for t in self._config.targets]
|
|
275
|
+
|
|
276
|
+
def list_connected(self) -> list[str]:
|
|
277
|
+
return list(self._connections.keys())
|
queue_aiops/doctor.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Environment and connectivity diagnostics for Queue AIops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from queue_aiops.config import CONFIG_FILE, ENV_FILE, load_config
|
|
8
|
+
from queue_aiops.platform import REDIS
|
|
9
|
+
from queue_aiops.secretstore import SECRETS_FILE, check_permissions, has_store
|
|
10
|
+
|
|
11
|
+
_console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_doctor(skip_auth: bool = False) -> int:
|
|
15
|
+
"""Check config, secrets, and (optionally) connectivity.
|
|
16
|
+
|
|
17
|
+
Returns a process exit code: 0 healthy, 1 problems found. Connectivity
|
|
18
|
+
failures are reported as status, never raised as tracebacks (a doctor must
|
|
19
|
+
survive the thing it diagnoses being unhealthy).
|
|
20
|
+
"""
|
|
21
|
+
problems = 0
|
|
22
|
+
|
|
23
|
+
if not CONFIG_FILE.exists():
|
|
24
|
+
_console.print(f"[red]✗ Config file missing: {CONFIG_FILE}[/]")
|
|
25
|
+
_console.print("[yellow] Run 'queue-aiops init' to set up your first target.[/]")
|
|
26
|
+
return 1
|
|
27
|
+
_console.print(f"[green]✓ Config file present: {CONFIG_FILE}[/]")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
config = load_config()
|
|
31
|
+
except Exception as exc: # noqa: BLE001 — report, do not crash
|
|
32
|
+
_console.print(f"[red]✗ Config load failed: {exc}[/]")
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
if not config.targets:
|
|
36
|
+
_console.print("[red]✗ No targets configured[/]")
|
|
37
|
+
return 1
|
|
38
|
+
_console.print(f"[green]✓ {len(config.targets)} target(s) configured[/]")
|
|
39
|
+
|
|
40
|
+
if has_store():
|
|
41
|
+
_console.print(f"[green]✓ Encrypted secret store present: {SECRETS_FILE}[/]")
|
|
42
|
+
perm_warning = check_permissions()
|
|
43
|
+
if perm_warning:
|
|
44
|
+
_console.print(f"[yellow]! {perm_warning}[/]")
|
|
45
|
+
elif ENV_FILE.exists():
|
|
46
|
+
_console.print(
|
|
47
|
+
f"[yellow]! Using legacy plaintext .env ({ENV_FILE}). Migrate with "
|
|
48
|
+
f"'queue-aiops secret migrate'.[/]"
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
_console.print(
|
|
52
|
+
"[yellow]! No secret store yet. Run 'queue-aiops init' to set up "
|
|
53
|
+
"credentials (stored encrypted).[/]"
|
|
54
|
+
)
|
|
55
|
+
problems += 1
|
|
56
|
+
|
|
57
|
+
for target in config.targets:
|
|
58
|
+
try:
|
|
59
|
+
secret = target.secret
|
|
60
|
+
if secret:
|
|
61
|
+
_console.print(
|
|
62
|
+
f"[green]✓ Secret present for '{target.name}' ({target.platform})[/]"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
# Redis auth is optional: an empty secret is a valid lab setup.
|
|
66
|
+
_console.print(
|
|
67
|
+
f"[yellow]! No secret for '{target.name}' ({target.platform}) — "
|
|
68
|
+
f"connecting without AUTH (fine for a lab instance).[/]"
|
|
69
|
+
)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
_console.print(f"[red]✗ {exc}[/]")
|
|
72
|
+
problems += 1
|
|
73
|
+
|
|
74
|
+
if skip_auth:
|
|
75
|
+
_console.print("[dim]Skipping connectivity check (--skip-auth).[/]")
|
|
76
|
+
return 1 if problems else 0
|
|
77
|
+
|
|
78
|
+
from queue_aiops.connection import ConnectionManager
|
|
79
|
+
|
|
80
|
+
mgr = ConnectionManager(config)
|
|
81
|
+
for target in config.targets:
|
|
82
|
+
try:
|
|
83
|
+
conn = mgr.connect(target.name)
|
|
84
|
+
if target.platform == REDIS:
|
|
85
|
+
conn.redis_ping()
|
|
86
|
+
probe = "PING OK"
|
|
87
|
+
else:
|
|
88
|
+
conn.get(conn.platform.path("overview"))
|
|
89
|
+
probe = "management /api/overview OK"
|
|
90
|
+
_console.print(
|
|
91
|
+
f"[green]✓ Connected to '{target.name}' ({target.platform} "
|
|
92
|
+
f"{target.host}) — {probe}[/]"
|
|
93
|
+
)
|
|
94
|
+
except Exception as exc: # noqa: BLE001 — connectivity is a status, not a crash
|
|
95
|
+
_console.print(f"[red]✗ Connect to '{target.name}' failed: {exc}[/]")
|
|
96
|
+
problems += 1
|
|
97
|
+
|
|
98
|
+
return 1 if problems else 0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Governance harness for queue-aiops — audit, policy, budget, undo, sanitize.
|
|
2
|
+
|
|
3
|
+
A self-contained, vendored governance layer. queue-aiops has NO dependency on
|
|
4
|
+
any external skill family — this package is its own copy of the harness:
|
|
5
|
+
|
|
6
|
+
- ``@governed_tool`` — mandatory decorator on every MCP tool: policy pre-check,
|
|
7
|
+
token/runaway budget guard, graduated-autonomy risk-tier gate, audit logging,
|
|
8
|
+
and undo-token recording.
|
|
9
|
+
- unified SQLite audit log under ``~/.queue-aiops/`` (override with
|
|
10
|
+
``QUEUE_AIOPS_HOME``).
|
|
11
|
+
- ``sanitize`` — output hygiene (encoding-level defense) for API-returned text.
|
|
12
|
+
|
|
13
|
+
State lives under ``ops_home()`` (default ``~/.queue-aiops``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from queue_aiops.governance.audit import AuditEngine, get_engine
|
|
17
|
+
from queue_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
|
|
18
|
+
from queue_aiops.governance.decorators import PolicyDenied, governed_tool
|
|
19
|
+
from queue_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
|
|
20
|
+
from queue_aiops.governance.policy import TierDecision, get_policy_engine
|
|
21
|
+
from queue_aiops.governance.sanitize import sanitize
|
|
22
|
+
from queue_aiops.governance.undo import UndoStore, get_undo_store
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"governed_tool",
|
|
26
|
+
"sanitize",
|
|
27
|
+
"PolicyDenied",
|
|
28
|
+
"get_engine",
|
|
29
|
+
"AuditEngine",
|
|
30
|
+
"get_policy_engine",
|
|
31
|
+
"TierDecision",
|
|
32
|
+
"get_budget",
|
|
33
|
+
"BudgetTracker",
|
|
34
|
+
"BudgetExceeded",
|
|
35
|
+
"get_undo_store",
|
|
36
|
+
"UndoStore",
|
|
37
|
+
"Pattern",
|
|
38
|
+
"PatternMatch",
|
|
39
|
+
"get_pattern_engine",
|
|
40
|
+
]
|