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