proxy-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 (58) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +108 -0
  3. mcp_server/server.py +40 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/analysis.py +170 -0
  6. mcp_server/tools/certs.py +29 -0
  7. mcp_server/tools/configread.py +50 -0
  8. mcp_server/tools/routes.py +52 -0
  9. mcp_server/tools/services.py +74 -0
  10. mcp_server/tools/status.py +47 -0
  11. mcp_server/tools/traffic.py +36 -0
  12. mcp_server/tools/undo.py +121 -0
  13. mcp_server/tools/writes.py +265 -0
  14. proxy_aiops/__init__.py +14 -0
  15. proxy_aiops/cli/__init__.py +5 -0
  16. proxy_aiops/cli/_common.py +78 -0
  17. proxy_aiops/cli/_root.py +68 -0
  18. proxy_aiops/cli/analyze.py +81 -0
  19. proxy_aiops/cli/certs.py +43 -0
  20. proxy_aiops/cli/configcmd.py +107 -0
  21. proxy_aiops/cli/doctor.py +21 -0
  22. proxy_aiops/cli/init.py +166 -0
  23. proxy_aiops/cli/overview.py +16 -0
  24. proxy_aiops/cli/routes.py +59 -0
  25. proxy_aiops/cli/secret.py +105 -0
  26. proxy_aiops/cli/server.py +74 -0
  27. proxy_aiops/cli/services.py +54 -0
  28. proxy_aiops/cli/undo.py +62 -0
  29. proxy_aiops/config.py +174 -0
  30. proxy_aiops/connection.py +189 -0
  31. proxy_aiops/doctor.py +97 -0
  32. proxy_aiops/governance/__init__.py +40 -0
  33. proxy_aiops/governance/audit.py +377 -0
  34. proxy_aiops/governance/budget.py +225 -0
  35. proxy_aiops/governance/decorators.py +482 -0
  36. proxy_aiops/governance/paths.py +23 -0
  37. proxy_aiops/governance/patterns.py +378 -0
  38. proxy_aiops/governance/policy.py +430 -0
  39. proxy_aiops/governance/sanitize.py +45 -0
  40. proxy_aiops/governance/undo.py +218 -0
  41. proxy_aiops/ops/__init__.py +2 -0
  42. proxy_aiops/ops/_util.py +86 -0
  43. proxy_aiops/ops/analysis.py +469 -0
  44. proxy_aiops/ops/certs.py +146 -0
  45. proxy_aiops/ops/configread.py +94 -0
  46. proxy_aiops/ops/overview.py +55 -0
  47. proxy_aiops/ops/routes.py +209 -0
  48. proxy_aiops/ops/services.py +304 -0
  49. proxy_aiops/ops/status.py +73 -0
  50. proxy_aiops/ops/traffic.py +178 -0
  51. proxy_aiops/ops/writes.py +174 -0
  52. proxy_aiops/platform.py +371 -0
  53. proxy_aiops/secretstore.py +304 -0
  54. proxy_aiops-0.1.0.dist-info/METADATA +213 -0
  55. proxy_aiops-0.1.0.dist-info/RECORD +58 -0
  56. proxy_aiops-0.1.0.dist-info/WHEEL +4 -0
  57. proxy_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  58. proxy_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,121 @@
1
+ """Undo executor — list recorded inverse descriptors and APPLY them.
2
+
3
+ Every governed write records a replayable inverse descriptor (tool + params) to
4
+ ``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
5
+ descriptor and dispatches it to the named governed tool, so the inverse runs on
6
+ the SAME governance path as any other call (audited, budget-checked, and — if
7
+ the inverse itself is destructive — re-gated by its own risk tier / approver
8
+ requirement). ``undo_apply`` is itself governed; the real risk is enforced by
9
+ the inner tool it calls.
10
+
11
+ Note: recorded ``undo_params`` are the redacted safe-params captured at record
12
+ time, so an inverse that would need a secret value cannot be replayed here — by
13
+ design, inverses in this line key off ids/names, not credentials.
14
+ """
15
+
16
+ import json
17
+ from typing import Any, Optional
18
+
19
+ from mcp_server._shared import mcp, tool_errors
20
+ from proxy_aiops.governance import governed_tool
21
+ from proxy_aiops.governance.undo import get_undo_store
22
+
23
+
24
+ def _resolve_tool(name: str) -> Any:
25
+ """Return the governed callable registered under ``name`` (or None)."""
26
+ tool = mcp._tool_manager._tools.get(name)
27
+ return getattr(tool, "fn", None) if tool else None
28
+
29
+
30
+ @mcp.tool()
31
+ @governed_tool(risk_level="low")
32
+ @tool_errors("dict")
33
+ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
34
+ """[READ] List recorded, not-yet-applied undo tokens (most recent first).
35
+
36
+ Each entry names the original tool, the inverse tool that ``undo_apply``
37
+ would run, and a human note. Use the ``undoId`` with ``undo_apply``.
38
+
39
+ Args:
40
+ limit: Max rows to return (default 50).
41
+ target: Unused (undo state is host-local); accepted for CLI uniformity.
42
+ """
43
+ rows = get_undo_store().list(status="recorded", limit=max(1, min(limit, 500)))
44
+ return {
45
+ "count": len(rows),
46
+ "undos": [
47
+ {
48
+ "undoId": r["undo_id"],
49
+ "ts": r["ts"],
50
+ "originalTool": r["tool"],
51
+ "inverseTool": r["undo_tool"],
52
+ "note": r.get("note", ""),
53
+ }
54
+ for r in rows
55
+ ],
56
+ }
57
+
58
+
59
+ @mcp.tool()
60
+ @governed_tool(risk_level="medium")
61
+ @tool_errors("dict")
62
+ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
63
+ """[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
64
+
65
+ The inverse runs through its own governed tool, so its real risk tier and
66
+ any approver requirement are enforced there. Pass dry_run=True to preview
67
+ the inverse call without executing it. A token can only be applied once.
68
+
69
+ Args:
70
+ undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
71
+ dry_run: If True, preview the inverse tool + params without running it.
72
+ target: Passed through to the inverse tool when it accepts a target.
73
+ """
74
+ store = get_undo_store()
75
+ rec = store.get(undo_id)
76
+ if not rec:
77
+ raise ValueError(f"Unknown undo id '{undo_id}'. Run undo_list to see available tokens.")
78
+ if rec["status"] != "recorded":
79
+ raise ValueError(
80
+ f"Undo '{undo_id}' is already '{rec['status']}' — a token can only be applied once."
81
+ )
82
+
83
+ inverse_tool = rec["undo_tool"]
84
+ try:
85
+ params = json.loads(rec["undo_params"]) if rec["undo_params"] else {}
86
+ except (ValueError, TypeError):
87
+ params = {}
88
+ if not isinstance(params, dict):
89
+ params = {}
90
+
91
+ fn = _resolve_tool(inverse_tool)
92
+ if fn is None:
93
+ raise ValueError(
94
+ f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
95
+ )
96
+
97
+ if dry_run:
98
+ return {
99
+ "dryRun": True,
100
+ "undoId": undo_id,
101
+ "wouldApply": {"tool": inverse_tool, "params": params},
102
+ }
103
+
104
+ call_params = dict(params)
105
+ if target is not None and "target" not in call_params:
106
+ call_params["target"] = target
107
+ result = fn(**call_params)
108
+
109
+ # Only mark applied when the inverse did not itself return an error dict.
110
+ if not (isinstance(result, dict) and result.get("error")):
111
+ store.mark(undo_id, "applied")
112
+ applied = True
113
+ else:
114
+ applied = False
115
+
116
+ return {
117
+ "undoId": undo_id,
118
+ "applied": applied,
119
+ "inverseTool": inverse_tool,
120
+ "result": result,
121
+ }
@@ -0,0 +1,265 @@
1
+ """Governed proxy-write MCP tools (the only state-changing tools).
2
+
3
+ Every tool is wrapped with the governance harness (audit + graduated approval
4
+ tier) and takes a ``dry_run`` preview. Reversible writes pass an ``undo=``
5
+ callback that turns the fetched before-state into an inverse descriptor the
6
+ harness records; the undo params match the target tool's own signature, so the
7
+ descriptor is replayable as-is.
8
+
9
+ Risk tiers: delete_config_path / load_config = high (delete / full replace);
10
+ set_config_value / set_server_state / set_server_weight = medium.
11
+
12
+ Platform dispatch is the support matrix's job: a write against the wrong
13
+ platform raises its teaching error (traefik writes → edit the provider;
14
+ haproxy config writes → transactions out of scope) before anything mutates.
15
+ """
16
+
17
+ from typing import Any, Optional
18
+
19
+ from mcp_server._shared import _get_connection, mcp, tool_errors
20
+ from proxy_aiops.governance import governed_tool
21
+ from proxy_aiops.ops import writes as ops
22
+
23
+ # ── undo descriptors (built from the fetched before-state) ──────────────────
24
+
25
+
26
+ def _set_config_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
27
+ """Inverse of set_config_value: restore the prior subtree (or delete a
28
+ path that did not exist before)."""
29
+ if not isinstance(result, dict):
30
+ return None
31
+ prior = result.get("priorState") or {}
32
+ if prior.get("existed"):
33
+ return {
34
+ "tool": "set_config_value",
35
+ "params": {"path": params.get("path"), "value": prior.get("value")},
36
+ "skill": "proxy-aiops",
37
+ "note": "Inverse of set_config_value: restore the prior config subtree.",
38
+ }
39
+ return {
40
+ "tool": "delete_config_path",
41
+ "params": {"path": params.get("path")},
42
+ "skill": "proxy-aiops",
43
+ "note": "Inverse of set_config_value: the path did not exist before — delete it.",
44
+ }
45
+
46
+
47
+ def _delete_config_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
48
+ """Inverse of delete_config_path: re-create the deleted subtree."""
49
+ if not isinstance(result, dict):
50
+ return None
51
+ prior = result.get("priorState") or {}
52
+ if not prior.get("existed"):
53
+ return None
54
+ return {
55
+ "tool": "set_config_value",
56
+ "params": {"path": params.get("path"), "value": prior.get("value")},
57
+ "skill": "proxy-aiops",
58
+ "note": "Inverse of delete_config_path: re-create the deleted subtree.",
59
+ }
60
+
61
+
62
+ def _load_config_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
63
+ """Inverse of load_config: re-load the snapshotted prior config."""
64
+ if not isinstance(result, dict):
65
+ return None
66
+ prior = (result.get("priorState") or {}).get("config")
67
+ if not isinstance(prior, dict) or not prior:
68
+ return None
69
+ return {
70
+ "tool": "load_config",
71
+ "params": {"config": prior},
72
+ "skill": "proxy-aiops",
73
+ "note": "Inverse of load_config: restore the snapshotted prior config.",
74
+ }
75
+
76
+
77
+ def _server_state_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
78
+ """Inverse of set_server_state: restore the prior admin state."""
79
+ if not isinstance(result, dict):
80
+ return None
81
+ prior = (result.get("priorState") or {}).get("adminState")
82
+ if prior not in ops.SERVER_STATES:
83
+ return None
84
+ return {
85
+ "tool": "set_server_state",
86
+ "params": {
87
+ "backend": params.get("backend"),
88
+ "server": params.get("server"),
89
+ "state": prior,
90
+ },
91
+ "skill": "proxy-aiops",
92
+ "note": "Inverse of set_server_state: restore the prior admin state.",
93
+ }
94
+
95
+
96
+ def _server_weight_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
97
+ """Inverse of set_server_weight: restore the prior weight."""
98
+ if not isinstance(result, dict):
99
+ return None
100
+ prior = (result.get("priorState") or {}).get("weight")
101
+ if prior is None:
102
+ return None
103
+ return {
104
+ "tool": "set_server_weight",
105
+ "params": {
106
+ "backend": params.get("backend"),
107
+ "server": params.get("server"),
108
+ "weight": prior,
109
+ },
110
+ "skill": "proxy-aiops",
111
+ "note": "Inverse of set_server_weight: restore the prior weight.",
112
+ }
113
+
114
+
115
+ # ── caddy config writes ──────────────────────────────────────────────────────
116
+
117
+
118
+ @mcp.tool()
119
+ @governed_tool(risk_level="medium", undo=_set_config_undo)
120
+ @tool_errors("dict")
121
+ def set_config_value(
122
+ path: str,
123
+ value: Any,
124
+ dry_run: bool = False,
125
+ target: Optional[str] = None,
126
+ ) -> dict:
127
+ """[WRITE][risk=medium] Set a caddy config subtree (e.g. a route's
128
+ upstreams); reversible — the prior subtree is fetched first and the undo
129
+ restores it.
130
+
131
+ Caddy applies the change immediately. On traefik/haproxy this raises the
132
+ support matrix's teaching error. Pass dry_run=True to preview.
133
+
134
+ Args:
135
+ path: Slash-separated config path (from search_config / list_routes,
136
+ e.g. apps/http/servers/srv0/routes/0/handle/0/upstreams).
137
+ value: The JSON value to write at that path.
138
+ dry_run: If True, preview without changing.
139
+ target: Proxy target name from config; omit for the default.
140
+ """
141
+ conn = _get_connection(target)
142
+ if dry_run:
143
+ return {"dryRun": True, "wouldSet": {"path": path, "value": value}}
144
+ return ops.set_config_value(conn, path, value)
145
+
146
+
147
+ @mcp.tool()
148
+ @governed_tool(risk_level="high", undo=_delete_config_undo)
149
+ @tool_errors("dict")
150
+ def delete_config_path(
151
+ path: str,
152
+ dry_run: bool = False,
153
+ target: Optional[str] = None,
154
+ ) -> dict:
155
+ """[WRITE][risk=high] Delete a caddy config subtree; reversible — the
156
+ subtree is captured first and the undo re-creates it.
157
+
158
+ Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
159
+ graduated-autonomy policy. Pass dry_run=True to preview.
160
+
161
+ Args:
162
+ path: Slash-separated config path to delete.
163
+ dry_run: If True, preview without deleting.
164
+ target: Proxy target name from config; omit for the default.
165
+ """
166
+ conn = _get_connection(target)
167
+ if dry_run:
168
+ return {"dryRun": True, "wouldDelete": {"path": path}}
169
+ return ops.delete_config_path(conn, path)
170
+
171
+
172
+ @mcp.tool()
173
+ @governed_tool(risk_level="high", undo=_load_config_undo)
174
+ @tool_errors("dict")
175
+ def load_config(
176
+ config: dict,
177
+ dry_run: bool = False,
178
+ target: Optional[str] = None,
179
+ ) -> dict:
180
+ """[WRITE][risk=high] Replace caddy's FULL running config; reversible —
181
+ the prior config is snapshotted first and the undo re-loads it.
182
+
183
+ Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
184
+ graduated-autonomy policy. Pass dry_run=True to preview.
185
+
186
+ Args:
187
+ config: The full config tree to load (a JSON object).
188
+ dry_run: If True, preview without loading.
189
+ target: Proxy target name from config; omit for the default.
190
+ """
191
+ conn = _get_connection(target)
192
+ if dry_run:
193
+ return {"dryRun": True, "wouldLoad": {"topLevelKeys": sorted(config or {})}}
194
+ return ops.load_config(conn, config)
195
+
196
+
197
+ # ── haproxy runtime-server writes ────────────────────────────────────────────
198
+
199
+
200
+ @mcp.tool()
201
+ @governed_tool(risk_level="medium", undo=_server_state_undo)
202
+ @tool_errors("dict")
203
+ def set_server_state(
204
+ backend: str,
205
+ server: str,
206
+ state: str,
207
+ dry_run: bool = False,
208
+ target: Optional[str] = None,
209
+ ) -> dict:
210
+ """[WRITE][risk=medium] Set an haproxy server's admin state (ready / drain
211
+ / maint); reversible — the prior admin state is fetched first and the undo
212
+ restores it.
213
+
214
+ drain finishes in-flight sessions but takes no new ones; maint removes the
215
+ server immediately; ready returns it to rotation. On traefik/caddy this
216
+ raises the support matrix's teaching error. Pass dry_run=True to preview.
217
+
218
+ Args:
219
+ backend: Backend name (from list_services).
220
+ server: Server name inside the backend (from list_upstreams).
221
+ state: One of ready, drain, maint.
222
+ dry_run: If True, preview without changing.
223
+ target: Proxy target name from config; omit for the default.
224
+ """
225
+ conn = _get_connection(target)
226
+ if dry_run:
227
+ return {
228
+ "dryRun": True,
229
+ "wouldSetState": {"backend": backend, "server": server, "state": state},
230
+ }
231
+ return ops.set_server_state(conn, backend, server, state)
232
+
233
+
234
+ @mcp.tool()
235
+ @governed_tool(risk_level="medium", undo=_server_weight_undo)
236
+ @tool_errors("dict")
237
+ def set_server_weight(
238
+ backend: str,
239
+ server: str,
240
+ weight: int,
241
+ dry_run: bool = False,
242
+ target: Optional[str] = None,
243
+ ) -> dict:
244
+ """[WRITE][risk=medium] Set an haproxy server's load-balancing weight
245
+ (0-256); reversible — the prior weight is fetched first and the undo
246
+ restores it.
247
+
248
+ Weight 0 stops new traffic to the server without a state change. On
249
+ traefik/caddy this raises the support matrix's teaching error. Pass
250
+ dry_run=True to preview.
251
+
252
+ Args:
253
+ backend: Backend name (from list_services).
254
+ server: Server name inside the backend (from list_upstreams).
255
+ weight: New weight, 0-256.
256
+ dry_run: If True, preview without changing.
257
+ target: Proxy target name from config; omit for the default.
258
+ """
259
+ conn = _get_connection(target)
260
+ if dry_run:
261
+ return {
262
+ "dryRun": True,
263
+ "wouldSetWeight": {"backend": backend, "server": server, "weight": weight},
264
+ }
265
+ return ops.set_server_weight(conn, backend, server, weight)
@@ -0,0 +1,14 @@
1
+ """proxy-aiops — governed Traefik + Caddy + HAProxy proxy operations for AI agents.
2
+
3
+ Standalone and self-contained: the governance harness (audit, token budget,
4
+ undo-token recording, graduated risk tiers, output sanitize) is
5
+ bundled under ``proxy_aiops.governance`` — this package has no external
6
+ skill-family dependency. Preview: not yet full-coverage.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("proxy-aiops")
13
+ except PackageNotFoundError: # running from an uninstalled source tree
14
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,5 @@
1
+ """proxy-aiops CLI package — exposes the Typer app."""
2
+
3
+ from proxy_aiops.cli._root import app
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,78 @@
1
+ """Shared helpers for proxy-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[
18
+ str | None, typer.Option("--target", "-t", help="Target name from config")
19
+ ]
20
+ DryRunOption = Annotated[
21
+ bool, typer.Option("--dry-run", help="Print the API call without executing")
22
+ ]
23
+
24
+
25
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
26
+ """Exceptions translated to a one-line teaching error instead of a traceback."""
27
+ from proxy_aiops.connection import ProxyApiError
28
+
29
+ return (ProxyApiError, KeyError, OSError, ValueError)
30
+
31
+
32
+ def cli_errors(fn: Callable) -> Callable:
33
+ """Translate known exceptions into one red line + exit code 1."""
34
+
35
+ @functools.wraps(fn)
36
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
37
+ try:
38
+ return fn(*args, **kwargs)
39
+ except (typer.Exit, typer.Abort):
40
+ raise
41
+ except _cli_error_types() as e:
42
+ message = str(e)
43
+ if isinstance(e, KeyError):
44
+ message = f"Missing required key or environment variable: {message}"
45
+ console.print(f"[red]Error: {message}[/]")
46
+ raise typer.Exit(1) from e
47
+
48
+ return wrapper
49
+
50
+
51
+ def get_connection(target: str | None, config_path: Path | None = None) -> tuple[Any, Any]:
52
+ """Return a (conn, config) tuple for the given target."""
53
+ from proxy_aiops.config import load_config
54
+ from proxy_aiops.connection import ConnectionManager
55
+
56
+ cfg = load_config(config_path)
57
+ mgr = ConnectionManager(cfg)
58
+ return mgr.connect(target), cfg
59
+
60
+
61
+ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
62
+ """Print a dry-run preview of the API call that would be made."""
63
+ console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
64
+ console.print(f"[magenta] Operation: {operation}[/]")
65
+ console.print(f"[magenta] API Call: {api_call}[/]")
66
+ for k, v in (parameters or {}).items():
67
+ console.print(f"[magenta] Param: {k} = {v}[/]")
68
+ console.print("[magenta] Run without --dry-run to execute.[/]\n")
69
+
70
+
71
+ def double_confirm(action: str, resource: str) -> None:
72
+ """Require two confirmations for a destructive operation."""
73
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
74
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
75
+ typer.confirm(
76
+ f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
77
+ abort=True,
78
+ )
@@ -0,0 +1,68 @@
1
+ """Top-level Typer app: assembles sub-apps and top-level commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from proxy_aiops.cli._common import cli_errors
8
+ from proxy_aiops.cli.analyze import analyze_app
9
+ from proxy_aiops.cli.certs import certs_cmd
10
+ from proxy_aiops.cli.configcmd import config_app
11
+ from proxy_aiops.cli.doctor import doctor_cmd
12
+ from proxy_aiops.cli.init import init_cmd
13
+ from proxy_aiops.cli.overview import overview_cmd
14
+ from proxy_aiops.cli.routes import routes_app
15
+ from proxy_aiops.cli.secret import secret_app
16
+ from proxy_aiops.cli.server import server_app
17
+ from proxy_aiops.cli.services import services_app
18
+ from proxy_aiops.cli.undo import undo_app
19
+
20
+ app = typer.Typer(
21
+ name="proxy-aiops",
22
+ help="Governed AI-ops for Traefik + Caddy + HAProxy: routes, services, "
23
+ "upstream health, certs, error-rate RCA, and governed writes (caddy "
24
+ "config, haproxy server state/weight).",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ app.add_typer(routes_app, name="routes")
29
+ app.add_typer(services_app, name="services")
30
+ app.add_typer(analyze_app, name="analyze")
31
+ app.add_typer(config_app, name="config")
32
+ app.add_typer(server_app, name="server")
33
+ app.add_typer(secret_app, name="secret")
34
+ app.add_typer(undo_app, name="undo")
35
+ app.command("init")(init_cmd)
36
+ app.command("overview")(overview_cmd)
37
+ app.command("certs")(certs_cmd)
38
+ app.command("doctor")(doctor_cmd)
39
+
40
+
41
+ @app.command("mcp")
42
+ @cli_errors
43
+ def mcp_cmd() -> None:
44
+ """Start the MCP server (stdio transport).
45
+
46
+ Single-command entry point for MCP clients (does not go through uvx/PyPI
47
+ resolution at launch):
48
+ proxy-aiops mcp
49
+ """
50
+ import sys
51
+
52
+ if sys.version_info < (3, 11):
53
+ typer.echo(
54
+ f"ERROR: proxy-aiops requires Python >= 3.11 "
55
+ f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
56
+ f"Fix: uv python install 3.12 && "
57
+ f"uv tool install --python 3.12 --force proxy-aiops",
58
+ err=True,
59
+ )
60
+ raise typer.Exit(2)
61
+
62
+ from mcp_server.server import main as _mcp_main
63
+
64
+ _mcp_main()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ app()
@@ -0,0 +1,81 @@
1
+ """``proxy-aiops analyze`` — the flagship RCA analyses from the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ analyze_app = typer.Typer(
13
+ name="analyze",
14
+ help="Flagship analyses: backend health RCA, error-rate RCA, route conflicts.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @analyze_app.command("health")
20
+ @cli_errors
21
+ def analyze_health(
22
+ service: Annotated[
23
+ str | None, typer.Option("--service", "-s", help="Filter by service/backend")
24
+ ] = None,
25
+ target: TargetOption = None,
26
+ ) -> None:
27
+ """Backend/upstream health RCA: down servers grouped per service, cause + action."""
28
+ from proxy_aiops.ops import analysis as analysis_ops
29
+ from proxy_aiops.ops import services as svc_ops
30
+
31
+ conn, _ = get_connection(target)
32
+ pulled = svc_ops.list_upstreams(conn, service)
33
+ if "error" in pulled:
34
+ console.print_json(json.dumps(pulled))
35
+ raise typer.Exit(1)
36
+ console.print_json(json.dumps(analysis_ops.backend_health_rca(pulled["upstreams"])))
37
+
38
+
39
+ @analyze_app.command("errors")
40
+ @cli_errors
41
+ def analyze_errors(
42
+ error_rate_pct: Annotated[
43
+ float, typer.Option("--rate", help="5xx %% at/above which a service is flagged")
44
+ ] = 5.0,
45
+ min_requests: Annotated[
46
+ float, typer.Option("--min-requests", help="Minimum requests to be flagged")
47
+ ] = 30.0,
48
+ target: TargetOption = None,
49
+ ) -> None:
50
+ """5xx / error-rate RCA vs the fleet baseline, cause + action per service."""
51
+ from proxy_aiops.ops import analysis as analysis_ops
52
+ from proxy_aiops.ops import traffic as traffic_ops
53
+
54
+ conn, _ = get_connection(target)
55
+ pulled = traffic_ops.error_counters(conn)
56
+ if "error" in pulled:
57
+ console.print_json(json.dumps(pulled))
58
+ raise typer.Exit(1)
59
+ console.print_json(json.dumps(analysis_ops.error_rate_rca(
60
+ pulled["services"], error_rate_pct=error_rate_pct, min_requests=min_requests
61
+ )))
62
+
63
+
64
+ @analyze_app.command("conflicts")
65
+ @cli_errors
66
+ def analyze_conflicts(target: TargetOption = None) -> None:
67
+ """Route conflict & shadow analysis: shadowed / dead routes, redirect loops."""
68
+ from proxy_aiops.ops import analysis as analysis_ops
69
+ from proxy_aiops.ops import routes as route_ops
70
+ from proxy_aiops.ops import services as svc_ops
71
+
72
+ conn, _ = get_connection(target)
73
+ routes = route_ops.list_routes(conn)
74
+ if "error" in routes:
75
+ console.print_json(json.dumps(routes))
76
+ raise typer.Exit(1)
77
+ services = svc_ops.list_services(conn)
78
+ svc_rows = services.get("services") if "error" not in services else None
79
+ console.print_json(json.dumps(
80
+ analysis_ops.route_conflict_analysis(routes["routes"], svc_rows)
81
+ ))
@@ -0,0 +1,43 @@
1
+ """``proxy-aiops certs`` — TLS certificate inventory + expiry sweep."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from proxy_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+
13
+ @cli_errors
14
+ def certs_cmd(
15
+ sweep: Annotated[
16
+ bool,
17
+ typer.Option("--sweep/--no-sweep", help="Probe expiry and bucket by days-to-expiry"),
18
+ ] = True,
19
+ warn_days: Annotated[
20
+ float, typer.Option("--warn-days", help="Warning threshold (days)")
21
+ ] = 30.0,
22
+ critical_days: Annotated[
23
+ float, typer.Option("--critical-days", help="Critical threshold (days)")
24
+ ] = 7.0,
25
+ port: Annotated[int, typer.Option("--port", help="TLS port to probe")] = 443,
26
+ target: TargetOption = None,
27
+ ) -> None:
28
+ """TLS domain inventory; --sweep live-probes each cert's expiry."""
29
+ from proxy_aiops.ops import analysis as analysis_ops
30
+ from proxy_aiops.ops import certs as ops
31
+
32
+ conn, _ = get_connection(target)
33
+ inventory = ops.list_certificates(conn, probe=sweep, port=port)
34
+ if not sweep or "unsupported" in inventory or "error" in inventory:
35
+ console.print_json(json.dumps(inventory))
36
+ return
37
+ result = analysis_ops.cert_expiry_sweep(
38
+ inventory.get("certificates", []),
39
+ warn_days=warn_days,
40
+ critical_days=critical_days,
41
+ platform=conn.target.platform,
42
+ )
43
+ console.print_json(json.dumps(result))