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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +108 -0
- mcp_server/server.py +40 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis.py +170 -0
- mcp_server/tools/certs.py +29 -0
- mcp_server/tools/configread.py +50 -0
- mcp_server/tools/routes.py +52 -0
- mcp_server/tools/services.py +74 -0
- mcp_server/tools/status.py +47 -0
- mcp_server/tools/traffic.py +36 -0
- mcp_server/tools/undo.py +121 -0
- mcp_server/tools/writes.py +265 -0
- proxy_aiops/__init__.py +14 -0
- proxy_aiops/cli/__init__.py +5 -0
- proxy_aiops/cli/_common.py +78 -0
- proxy_aiops/cli/_root.py +68 -0
- proxy_aiops/cli/analyze.py +81 -0
- proxy_aiops/cli/certs.py +43 -0
- proxy_aiops/cli/configcmd.py +107 -0
- proxy_aiops/cli/doctor.py +21 -0
- proxy_aiops/cli/init.py +166 -0
- proxy_aiops/cli/overview.py +16 -0
- proxy_aiops/cli/routes.py +59 -0
- proxy_aiops/cli/secret.py +105 -0
- proxy_aiops/cli/server.py +74 -0
- proxy_aiops/cli/services.py +54 -0
- proxy_aiops/cli/undo.py +62 -0
- proxy_aiops/config.py +174 -0
- proxy_aiops/connection.py +189 -0
- proxy_aiops/doctor.py +97 -0
- proxy_aiops/governance/__init__.py +40 -0
- proxy_aiops/governance/audit.py +377 -0
- proxy_aiops/governance/budget.py +225 -0
- proxy_aiops/governance/decorators.py +482 -0
- proxy_aiops/governance/paths.py +23 -0
- proxy_aiops/governance/patterns.py +378 -0
- proxy_aiops/governance/policy.py +430 -0
- proxy_aiops/governance/sanitize.py +45 -0
- proxy_aiops/governance/undo.py +218 -0
- proxy_aiops/ops/__init__.py +2 -0
- proxy_aiops/ops/_util.py +86 -0
- proxy_aiops/ops/analysis.py +469 -0
- proxy_aiops/ops/certs.py +146 -0
- proxy_aiops/ops/configread.py +94 -0
- proxy_aiops/ops/overview.py +55 -0
- proxy_aiops/ops/routes.py +209 -0
- proxy_aiops/ops/services.py +304 -0
- proxy_aiops/ops/status.py +73 -0
- proxy_aiops/ops/traffic.py +178 -0
- proxy_aiops/ops/writes.py +174 -0
- proxy_aiops/platform.py +371 -0
- proxy_aiops/secretstore.py +304 -0
- proxy_aiops-0.1.0.dist-info/METADATA +213 -0
- proxy_aiops-0.1.0.dist-info/RECORD +58 -0
- proxy_aiops-0.1.0.dist-info/WHEEL +4 -0
- proxy_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- proxy_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_server/tools/undo.py
ADDED
|
@@ -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)
|
proxy_aiops/__init__.py
ADDED
|
@@ -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,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
|
+
)
|
proxy_aiops/cli/_root.py
ADDED
|
@@ -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
|
+
))
|
proxy_aiops/cli/certs.py
ADDED
|
@@ -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))
|