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,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
+ ]