kubectl-mcp-server 1.15.0__py3-none-any.whl → 1.17.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.
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/METADATA +34 -13
- kubectl_mcp_server-1.17.0.dist-info/RECORD +75 -0
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/cli/cli.py +83 -9
- kubectl_mcp_tool/cli/output.py +14 -0
- kubectl_mcp_tool/config/__init__.py +46 -0
- kubectl_mcp_tool/config/loader.py +386 -0
- kubectl_mcp_tool/config/schema.py +184 -0
- kubectl_mcp_tool/crd_detector.py +247 -0
- kubectl_mcp_tool/k8s_config.py +19 -0
- kubectl_mcp_tool/mcp_server.py +246 -8
- kubectl_mcp_tool/observability/__init__.py +59 -0
- kubectl_mcp_tool/observability/metrics.py +223 -0
- kubectl_mcp_tool/observability/stats.py +255 -0
- kubectl_mcp_tool/observability/tracing.py +335 -0
- kubectl_mcp_tool/prompts/__init__.py +43 -0
- kubectl_mcp_tool/prompts/builtin.py +695 -0
- kubectl_mcp_tool/prompts/custom.py +298 -0
- kubectl_mcp_tool/prompts/prompts.py +180 -4
- kubectl_mcp_tool/safety.py +155 -0
- kubectl_mcp_tool/tools/__init__.py +20 -0
- kubectl_mcp_tool/tools/backup.py +881 -0
- kubectl_mcp_tool/tools/capi.py +727 -0
- kubectl_mcp_tool/tools/certs.py +709 -0
- kubectl_mcp_tool/tools/cilium.py +582 -0
- kubectl_mcp_tool/tools/cluster.py +384 -0
- kubectl_mcp_tool/tools/gitops.py +552 -0
- kubectl_mcp_tool/tools/keda.py +464 -0
- kubectl_mcp_tool/tools/kiali.py +652 -0
- kubectl_mcp_tool/tools/kubevirt.py +803 -0
- kubectl_mcp_tool/tools/policy.py +554 -0
- kubectl_mcp_tool/tools/rollouts.py +790 -0
- tests/test_browser.py +2 -2
- tests/test_config.py +386 -0
- tests/test_ecosystem.py +331 -0
- tests/test_mcp_integration.py +251 -0
- tests/test_observability.py +521 -0
- tests/test_prompts.py +716 -0
- tests/test_safety.py +218 -0
- tests/test_tools.py +70 -8
- kubectl_mcp_server-1.15.0.dist-info/RECORD +0 -49
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""Argo Rollouts and Flagger progressive delivery toolset for kubectl-mcp-server.
|
|
2
|
+
|
|
3
|
+
Provides tools for managing canary deployments, blue-green deployments, and progressive delivery.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
from typing import Dict, Any, List
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
from fastmcp.tools import ToolAnnotations
|
|
14
|
+
except ImportError:
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from mcp.types import ToolAnnotations
|
|
17
|
+
|
|
18
|
+
from ..k8s_config import _get_kubectl_context_args
|
|
19
|
+
from ..crd_detector import crd_exists
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Argo Rollouts CRDs
|
|
23
|
+
ARGO_ROLLOUT_CRD = "rollouts.argoproj.io"
|
|
24
|
+
ARGO_ANALYSIS_TEMPLATE_CRD = "analysistemplates.argoproj.io"
|
|
25
|
+
ARGO_CLUSTER_ANALYSIS_TEMPLATE_CRD = "clusteranalysistemplates.argoproj.io"
|
|
26
|
+
ARGO_ANALYSIS_RUN_CRD = "analysisruns.argoproj.io"
|
|
27
|
+
ARGO_EXPERIMENT_CRD = "experiments.argoproj.io"
|
|
28
|
+
|
|
29
|
+
# Flagger CRDs
|
|
30
|
+
FLAGGER_CANARY_CRD = "canaries.flagger.app"
|
|
31
|
+
FLAGGER_METRIC_TEMPLATE_CRD = "metrictemplates.flagger.app"
|
|
32
|
+
FLAGGER_ALERT_PROVIDER_CRD = "alertproviders.flagger.app"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_kubectl(args: List[str], context: str = "") -> Dict[str, Any]:
|
|
36
|
+
"""Run kubectl command and return result."""
|
|
37
|
+
cmd = ["kubectl"] + _get_kubectl_context_args(context) + args
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
40
|
+
if result.returncode == 0:
|
|
41
|
+
return {"success": True, "output": result.stdout}
|
|
42
|
+
return {"success": False, "error": result.stderr}
|
|
43
|
+
except subprocess.TimeoutExpired:
|
|
44
|
+
return {"success": False, "error": "Command timed out"}
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"success": False, "error": str(e)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_resources(kind: str, namespace: str = "", context: str = "", label_selector: str = "") -> List[Dict]:
|
|
50
|
+
"""Get Kubernetes resources of a specific kind."""
|
|
51
|
+
args = ["get", kind, "-o", "json"]
|
|
52
|
+
if namespace:
|
|
53
|
+
args.extend(["-n", namespace])
|
|
54
|
+
else:
|
|
55
|
+
args.append("-A")
|
|
56
|
+
if label_selector:
|
|
57
|
+
args.extend(["-l", label_selector])
|
|
58
|
+
|
|
59
|
+
result = _run_kubectl(args, context)
|
|
60
|
+
if result["success"]:
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(result["output"])
|
|
63
|
+
return data.get("items", [])
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
return []
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _argo_rollouts_cli_available() -> bool:
|
|
70
|
+
"""Check if kubectl-argo-rollouts plugin is available."""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(["kubectl", "argo", "rollouts", "version"],
|
|
73
|
+
capture_output=True, timeout=5)
|
|
74
|
+
return result.returncode == 0
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ============== Argo Rollouts Functions ==============
|
|
80
|
+
|
|
81
|
+
def rollouts_list(
|
|
82
|
+
namespace: str = "",
|
|
83
|
+
context: str = "",
|
|
84
|
+
label_selector: str = ""
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
"""List Argo Rollouts with their status.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
90
|
+
context: Kubernetes context to use (optional)
|
|
91
|
+
label_selector: Label selector to filter rollouts
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of Argo Rollouts with their status
|
|
95
|
+
"""
|
|
96
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
97
|
+
return {
|
|
98
|
+
"success": False,
|
|
99
|
+
"error": "Argo Rollouts is not installed (rollouts.argoproj.io CRD not found)"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
rollouts = []
|
|
103
|
+
for item in _get_resources("rollouts.argoproj.io", namespace, context, label_selector):
|
|
104
|
+
status = item.get("status", {})
|
|
105
|
+
spec = item.get("spec", {})
|
|
106
|
+
|
|
107
|
+
# Determine strategy
|
|
108
|
+
strategy_spec = spec.get("strategy", {})
|
|
109
|
+
if "canary" in strategy_spec:
|
|
110
|
+
strategy = "canary"
|
|
111
|
+
strategy_details = strategy_spec.get("canary", {})
|
|
112
|
+
elif "blueGreen" in strategy_spec:
|
|
113
|
+
strategy = "blueGreen"
|
|
114
|
+
strategy_details = strategy_spec.get("blueGreen", {})
|
|
115
|
+
else:
|
|
116
|
+
strategy = "unknown"
|
|
117
|
+
strategy_details = {}
|
|
118
|
+
|
|
119
|
+
# Get conditions
|
|
120
|
+
conditions = status.get("conditions", [])
|
|
121
|
+
available_cond = next((c for c in conditions if c.get("type") == "Available"), {})
|
|
122
|
+
progressing_cond = next((c for c in conditions if c.get("type") == "Progressing"), {})
|
|
123
|
+
|
|
124
|
+
rollouts.append({
|
|
125
|
+
"name": item["metadata"]["name"],
|
|
126
|
+
"namespace": item["metadata"]["namespace"],
|
|
127
|
+
"strategy": strategy,
|
|
128
|
+
"phase": status.get("phase", "Unknown"),
|
|
129
|
+
"message": status.get("message", ""),
|
|
130
|
+
"replicas": spec.get("replicas", 1),
|
|
131
|
+
"ready_replicas": status.get("readyReplicas", 0),
|
|
132
|
+
"available_replicas": status.get("availableReplicas", 0),
|
|
133
|
+
"current_step": status.get("currentStepIndex"),
|
|
134
|
+
"total_steps": len(strategy_details.get("steps", [])) if strategy == "canary" else None,
|
|
135
|
+
"stable_rs": status.get("stableRS", ""),
|
|
136
|
+
"canary_rs": status.get("canaryRS", "") if strategy == "canary" else None,
|
|
137
|
+
"active_rs": status.get("blueGreen", {}).get("activeSelector", "") if strategy == "blueGreen" else None,
|
|
138
|
+
"preview_rs": status.get("blueGreen", {}).get("previewSelector", "") if strategy == "blueGreen" else None,
|
|
139
|
+
"available": available_cond.get("status") == "True",
|
|
140
|
+
"progressing": progressing_cond.get("status") == "True",
|
|
141
|
+
"paused": status.get("pauseConditions") is not None,
|
|
142
|
+
"aborted": status.get("abort", False),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
# Summary
|
|
146
|
+
healthy = sum(1 for r in rollouts if r["phase"] == "Healthy")
|
|
147
|
+
progressing = sum(1 for r in rollouts if r["phase"] == "Progressing")
|
|
148
|
+
paused = sum(1 for r in rollouts if r["paused"])
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"context": context or "current",
|
|
152
|
+
"total": len(rollouts),
|
|
153
|
+
"healthy": healthy,
|
|
154
|
+
"progressing": progressing,
|
|
155
|
+
"paused": paused,
|
|
156
|
+
"rollouts": rollouts,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rollout_get(
|
|
161
|
+
name: str,
|
|
162
|
+
namespace: str,
|
|
163
|
+
context: str = ""
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Get detailed information about an Argo Rollout.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
name: Name of the Rollout
|
|
169
|
+
namespace: Namespace of the Rollout
|
|
170
|
+
context: Kubernetes context to use (optional)
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Detailed Rollout information
|
|
174
|
+
"""
|
|
175
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
176
|
+
return {"success": False, "error": "Argo Rollouts is not installed"}
|
|
177
|
+
|
|
178
|
+
args = ["get", "rollouts.argoproj.io", name, "-n", namespace, "-o", "json"]
|
|
179
|
+
result = _run_kubectl(args, context)
|
|
180
|
+
|
|
181
|
+
if result["success"]:
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads(result["output"])
|
|
184
|
+
return {
|
|
185
|
+
"success": True,
|
|
186
|
+
"context": context or "current",
|
|
187
|
+
"rollout": data,
|
|
188
|
+
}
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
191
|
+
|
|
192
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def rollout_status(
|
|
196
|
+
name: str,
|
|
197
|
+
namespace: str,
|
|
198
|
+
context: str = ""
|
|
199
|
+
) -> Dict[str, Any]:
|
|
200
|
+
"""Get current status of an Argo Rollout with step details.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
name: Name of the Rollout
|
|
204
|
+
namespace: Namespace of the Rollout
|
|
205
|
+
context: Kubernetes context to use (optional)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Rollout status with step information
|
|
209
|
+
"""
|
|
210
|
+
result = rollout_get(name, namespace, context)
|
|
211
|
+
if not result.get("success"):
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
rollout = result["rollout"]
|
|
215
|
+
status = rollout.get("status", {})
|
|
216
|
+
spec = rollout.get("spec", {})
|
|
217
|
+
strategy_spec = spec.get("strategy", {})
|
|
218
|
+
|
|
219
|
+
# Determine strategy
|
|
220
|
+
if "canary" in strategy_spec:
|
|
221
|
+
strategy = "canary"
|
|
222
|
+
steps = strategy_spec.get("canary", {}).get("steps", [])
|
|
223
|
+
elif "blueGreen" in strategy_spec:
|
|
224
|
+
strategy = "blueGreen"
|
|
225
|
+
steps = []
|
|
226
|
+
else:
|
|
227
|
+
strategy = "unknown"
|
|
228
|
+
steps = []
|
|
229
|
+
|
|
230
|
+
current_step = status.get("currentStepIndex", 0)
|
|
231
|
+
|
|
232
|
+
# Parse steps
|
|
233
|
+
step_info = []
|
|
234
|
+
for i, step in enumerate(steps):
|
|
235
|
+
step_type = list(step.keys())[0] if step else "unknown"
|
|
236
|
+
step_value = step.get(step_type)
|
|
237
|
+
|
|
238
|
+
step_info.append({
|
|
239
|
+
"index": i,
|
|
240
|
+
"type": step_type,
|
|
241
|
+
"value": step_value,
|
|
242
|
+
"current": i == current_step,
|
|
243
|
+
"completed": i < current_step,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"success": True,
|
|
248
|
+
"context": context or "current",
|
|
249
|
+
"name": name,
|
|
250
|
+
"namespace": namespace,
|
|
251
|
+
"strategy": strategy,
|
|
252
|
+
"phase": status.get("phase", "Unknown"),
|
|
253
|
+
"message": status.get("message", ""),
|
|
254
|
+
"current_step": current_step,
|
|
255
|
+
"total_steps": len(steps),
|
|
256
|
+
"steps": step_info,
|
|
257
|
+
"paused": status.get("pauseConditions") is not None,
|
|
258
|
+
"pause_reasons": [p.get("reason") for p in (status.get("pauseConditions") or [])],
|
|
259
|
+
"canary_weight": status.get("canary", {}).get("weight", 0) if strategy == "canary" else None,
|
|
260
|
+
"stable_revision": status.get("stableRS", ""),
|
|
261
|
+
"canary_revision": status.get("canaryRS", ""),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def rollout_promote(
|
|
266
|
+
name: str,
|
|
267
|
+
namespace: str,
|
|
268
|
+
full: bool = False,
|
|
269
|
+
context: str = ""
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""Promote a paused Argo Rollout.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Name of the Rollout
|
|
275
|
+
namespace: Namespace of the Rollout
|
|
276
|
+
full: Promote to full healthy state (skip remaining steps)
|
|
277
|
+
context: Kubernetes context to use (optional)
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Promotion result
|
|
281
|
+
"""
|
|
282
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
283
|
+
return {"success": False, "error": "Argo Rollouts is not installed"}
|
|
284
|
+
|
|
285
|
+
# Use kubectl plugin if available
|
|
286
|
+
if _argo_rollouts_cli_available():
|
|
287
|
+
cmd = ["kubectl", "argo", "rollouts", "promote", name, "-n", namespace]
|
|
288
|
+
if full:
|
|
289
|
+
cmd.append("--full")
|
|
290
|
+
if context:
|
|
291
|
+
cmd.extend(["--context", context])
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
295
|
+
if result.returncode == 0:
|
|
296
|
+
return {
|
|
297
|
+
"success": True,
|
|
298
|
+
"context": context or "current",
|
|
299
|
+
"message": f"Promoted rollout {name}" + (" (full)" if full else ""),
|
|
300
|
+
"output": result.stdout,
|
|
301
|
+
}
|
|
302
|
+
return {"success": False, "error": result.stderr}
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return {"success": False, "error": str(e)}
|
|
305
|
+
|
|
306
|
+
# Fallback to patching
|
|
307
|
+
patch = {"status": {"pauseConditions": None}}
|
|
308
|
+
if full:
|
|
309
|
+
patch["status"]["promoteFull"] = True # Full promotion to end
|
|
310
|
+
|
|
311
|
+
args = [
|
|
312
|
+
"patch", "rollouts.argoproj.io", name,
|
|
313
|
+
"-n", namespace,
|
|
314
|
+
"--type=merge",
|
|
315
|
+
"-p", json.dumps(patch)
|
|
316
|
+
]
|
|
317
|
+
result = _run_kubectl(args, context)
|
|
318
|
+
|
|
319
|
+
if result["success"]:
|
|
320
|
+
return {
|
|
321
|
+
"success": True,
|
|
322
|
+
"context": context or "current",
|
|
323
|
+
"message": f"Promoted rollout {name}",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {"success": False, "error": result.get("error", "Failed to promote")}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def rollout_abort(
|
|
330
|
+
name: str,
|
|
331
|
+
namespace: str,
|
|
332
|
+
context: str = ""
|
|
333
|
+
) -> Dict[str, Any]:
|
|
334
|
+
"""Abort an Argo Rollout.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
name: Name of the Rollout
|
|
338
|
+
namespace: Namespace of the Rollout
|
|
339
|
+
context: Kubernetes context to use (optional)
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
Abort result
|
|
343
|
+
"""
|
|
344
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
345
|
+
return {"success": False, "error": "Argo Rollouts is not installed"}
|
|
346
|
+
|
|
347
|
+
if _argo_rollouts_cli_available():
|
|
348
|
+
cmd = ["kubectl", "argo", "rollouts", "abort", name, "-n", namespace]
|
|
349
|
+
if context:
|
|
350
|
+
cmd.extend(["--context", context])
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
354
|
+
if result.returncode == 0:
|
|
355
|
+
return {
|
|
356
|
+
"success": True,
|
|
357
|
+
"context": context or "current",
|
|
358
|
+
"message": f"Aborted rollout {name}",
|
|
359
|
+
"output": result.stdout,
|
|
360
|
+
}
|
|
361
|
+
return {"success": False, "error": result.stderr}
|
|
362
|
+
except Exception as e:
|
|
363
|
+
return {"success": False, "error": str(e)}
|
|
364
|
+
|
|
365
|
+
# Fallback to patching
|
|
366
|
+
patch = {"status": {"abort": True}}
|
|
367
|
+
args = [
|
|
368
|
+
"patch", "rollouts.argoproj.io", name,
|
|
369
|
+
"-n", namespace,
|
|
370
|
+
"--type=merge",
|
|
371
|
+
"-p", json.dumps(patch)
|
|
372
|
+
]
|
|
373
|
+
result = _run_kubectl(args, context)
|
|
374
|
+
|
|
375
|
+
if result["success"]:
|
|
376
|
+
return {
|
|
377
|
+
"success": True,
|
|
378
|
+
"context": context or "current",
|
|
379
|
+
"message": f"Aborted rollout {name}",
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {"success": False, "error": result.get("error", "Failed to abort")}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def rollout_retry(
|
|
386
|
+
name: str,
|
|
387
|
+
namespace: str,
|
|
388
|
+
context: str = ""
|
|
389
|
+
) -> Dict[str, Any]:
|
|
390
|
+
"""Retry an aborted Argo Rollout.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
name: Name of the Rollout
|
|
394
|
+
namespace: Namespace of the Rollout
|
|
395
|
+
context: Kubernetes context to use (optional)
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Retry result
|
|
399
|
+
"""
|
|
400
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
401
|
+
return {"success": False, "error": "Argo Rollouts is not installed"}
|
|
402
|
+
|
|
403
|
+
if _argo_rollouts_cli_available():
|
|
404
|
+
cmd = ["kubectl", "argo", "rollouts", "retry", name, "-n", namespace]
|
|
405
|
+
if context:
|
|
406
|
+
cmd.extend(["--context", context])
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
410
|
+
if result.returncode == 0:
|
|
411
|
+
return {
|
|
412
|
+
"success": True,
|
|
413
|
+
"context": context or "current",
|
|
414
|
+
"message": f"Retried rollout {name}",
|
|
415
|
+
"output": result.stdout,
|
|
416
|
+
}
|
|
417
|
+
return {"success": False, "error": result.stderr}
|
|
418
|
+
except Exception as e:
|
|
419
|
+
return {"success": False, "error": str(e)}
|
|
420
|
+
|
|
421
|
+
# Fallback to patching (clear abort status)
|
|
422
|
+
patch = {"status": {"abort": False}}
|
|
423
|
+
args = [
|
|
424
|
+
"patch", "rollouts.argoproj.io", name,
|
|
425
|
+
"-n", namespace,
|
|
426
|
+
"--type=merge",
|
|
427
|
+
"-p", json.dumps(patch)
|
|
428
|
+
]
|
|
429
|
+
result = _run_kubectl(args, context)
|
|
430
|
+
|
|
431
|
+
if result["success"]:
|
|
432
|
+
return {
|
|
433
|
+
"success": True,
|
|
434
|
+
"context": context or "current",
|
|
435
|
+
"message": f"Retried rollout {name}",
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {"success": False, "error": result.get("error", "Failed to retry")}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def rollout_restart(
|
|
442
|
+
name: str,
|
|
443
|
+
namespace: str,
|
|
444
|
+
context: str = ""
|
|
445
|
+
) -> Dict[str, Any]:
|
|
446
|
+
"""Restart an Argo Rollout.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
name: Name of the Rollout
|
|
450
|
+
namespace: Namespace of the Rollout
|
|
451
|
+
context: Kubernetes context to use (optional)
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Restart result
|
|
455
|
+
"""
|
|
456
|
+
if not crd_exists(ARGO_ROLLOUT_CRD, context):
|
|
457
|
+
return {"success": False, "error": "Argo Rollouts is not installed"}
|
|
458
|
+
|
|
459
|
+
if _argo_rollouts_cli_available():
|
|
460
|
+
cmd = ["kubectl", "argo", "rollouts", "restart", name, "-n", namespace]
|
|
461
|
+
if context:
|
|
462
|
+
cmd.extend(["--context", context])
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
466
|
+
if result.returncode == 0:
|
|
467
|
+
return {
|
|
468
|
+
"success": True,
|
|
469
|
+
"context": context or "current",
|
|
470
|
+
"message": f"Restarted rollout {name}",
|
|
471
|
+
"output": result.stdout,
|
|
472
|
+
}
|
|
473
|
+
return {"success": False, "error": result.stderr}
|
|
474
|
+
except Exception as e:
|
|
475
|
+
return {"success": False, "error": str(e)}
|
|
476
|
+
|
|
477
|
+
# Fallback: patch the template to trigger restart
|
|
478
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
479
|
+
patch = {
|
|
480
|
+
"spec": {
|
|
481
|
+
"restartAt": timestamp
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
args = [
|
|
485
|
+
"patch", "rollouts.argoproj.io", name,
|
|
486
|
+
"-n", namespace,
|
|
487
|
+
"--type=merge",
|
|
488
|
+
"-p", json.dumps(patch)
|
|
489
|
+
]
|
|
490
|
+
result = _run_kubectl(args, context)
|
|
491
|
+
|
|
492
|
+
if result["success"]:
|
|
493
|
+
return {
|
|
494
|
+
"success": True,
|
|
495
|
+
"context": context or "current",
|
|
496
|
+
"message": f"Restarted rollout {name}",
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {"success": False, "error": result.get("error", "Failed to restart")}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def analysis_runs_list(
|
|
503
|
+
namespace: str = "",
|
|
504
|
+
context: str = "",
|
|
505
|
+
label_selector: str = ""
|
|
506
|
+
) -> Dict[str, Any]:
|
|
507
|
+
"""List Argo Rollouts AnalysisRuns.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
511
|
+
context: Kubernetes context to use (optional)
|
|
512
|
+
label_selector: Label selector to filter
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
List of AnalysisRuns with their status
|
|
516
|
+
"""
|
|
517
|
+
if not crd_exists(ARGO_ANALYSIS_RUN_CRD, context):
|
|
518
|
+
return {
|
|
519
|
+
"success": False,
|
|
520
|
+
"error": "AnalysisRuns CRD not found"
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
runs = []
|
|
524
|
+
for item in _get_resources("analysisruns.argoproj.io", namespace, context, label_selector):
|
|
525
|
+
status = item.get("status", {})
|
|
526
|
+
spec = item.get("spec", {})
|
|
527
|
+
|
|
528
|
+
runs.append({
|
|
529
|
+
"name": item["metadata"]["name"],
|
|
530
|
+
"namespace": item["metadata"]["namespace"],
|
|
531
|
+
"phase": status.get("phase", "Unknown"),
|
|
532
|
+
"message": status.get("message", ""),
|
|
533
|
+
"metrics_count": len(spec.get("metrics", [])),
|
|
534
|
+
"started_at": status.get("startedAt", ""),
|
|
535
|
+
"metric_results": [
|
|
536
|
+
{
|
|
537
|
+
"name": m.get("name"),
|
|
538
|
+
"phase": m.get("phase"),
|
|
539
|
+
"count": m.get("count", 0),
|
|
540
|
+
"successful": m.get("successful", 0),
|
|
541
|
+
"failed": m.get("failed", 0),
|
|
542
|
+
}
|
|
543
|
+
for m in status.get("metricResults", [])
|
|
544
|
+
],
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
"context": context or "current",
|
|
549
|
+
"total": len(runs),
|
|
550
|
+
"analysis_runs": runs,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ============== Flagger Functions ==============
|
|
555
|
+
|
|
556
|
+
def flagger_canaries_list(
|
|
557
|
+
namespace: str = "",
|
|
558
|
+
context: str = "",
|
|
559
|
+
label_selector: str = ""
|
|
560
|
+
) -> Dict[str, Any]:
|
|
561
|
+
"""List Flagger Canary resources.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
565
|
+
context: Kubernetes context to use (optional)
|
|
566
|
+
label_selector: Label selector to filter
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
List of Flagger Canaries with their status
|
|
570
|
+
"""
|
|
571
|
+
if not crd_exists(FLAGGER_CANARY_CRD, context):
|
|
572
|
+
return {
|
|
573
|
+
"success": False,
|
|
574
|
+
"error": "Flagger is not installed (canaries.flagger.app CRD not found)"
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
canaries = []
|
|
578
|
+
for item in _get_resources("canaries.flagger.app", namespace, context, label_selector):
|
|
579
|
+
status = item.get("status", {})
|
|
580
|
+
spec = item.get("spec", {})
|
|
581
|
+
analysis = spec.get("analysis", {})
|
|
582
|
+
|
|
583
|
+
canaries.append({
|
|
584
|
+
"name": item["metadata"]["name"],
|
|
585
|
+
"namespace": item["metadata"]["namespace"],
|
|
586
|
+
"phase": status.get("phase", "Unknown"),
|
|
587
|
+
"canary_weight": status.get("canaryWeight", 0),
|
|
588
|
+
"failed_checks": status.get("failedChecks", 0),
|
|
589
|
+
"iterations": status.get("iterations", 0),
|
|
590
|
+
"target_ref": spec.get("targetRef", {}),
|
|
591
|
+
"service": spec.get("service", {}),
|
|
592
|
+
"max_weight": analysis.get("maxWeight", 50),
|
|
593
|
+
"step_weight": analysis.get("stepWeight", 5),
|
|
594
|
+
"threshold": analysis.get("threshold", 5),
|
|
595
|
+
"interval": analysis.get("interval", "1m"),
|
|
596
|
+
"metrics_count": len(analysis.get("metrics", [])),
|
|
597
|
+
"last_transition_time": status.get("lastTransitionTime", ""),
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
# Summary
|
|
601
|
+
progressing = sum(1 for c in canaries if c["phase"] == "Progressing")
|
|
602
|
+
succeeded = sum(1 for c in canaries if c["phase"] == "Succeeded")
|
|
603
|
+
failed = sum(1 for c in canaries if c["phase"] == "Failed")
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
"context": context or "current",
|
|
607
|
+
"total": len(canaries),
|
|
608
|
+
"progressing": progressing,
|
|
609
|
+
"succeeded": succeeded,
|
|
610
|
+
"failed": failed,
|
|
611
|
+
"canaries": canaries,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def flagger_canary_get(
|
|
616
|
+
name: str,
|
|
617
|
+
namespace: str,
|
|
618
|
+
context: str = ""
|
|
619
|
+
) -> Dict[str, Any]:
|
|
620
|
+
"""Get detailed information about a Flagger Canary.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
name: Name of the Canary
|
|
624
|
+
namespace: Namespace of the Canary
|
|
625
|
+
context: Kubernetes context to use (optional)
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
Detailed Canary information
|
|
629
|
+
"""
|
|
630
|
+
if not crd_exists(FLAGGER_CANARY_CRD, context):
|
|
631
|
+
return {"success": False, "error": "Flagger is not installed"}
|
|
632
|
+
|
|
633
|
+
args = ["get", "canaries.flagger.app", name, "-n", namespace, "-o", "json"]
|
|
634
|
+
result = _run_kubectl(args, context)
|
|
635
|
+
|
|
636
|
+
if result["success"]:
|
|
637
|
+
try:
|
|
638
|
+
data = json.loads(result["output"])
|
|
639
|
+
return {
|
|
640
|
+
"success": True,
|
|
641
|
+
"context": context or "current",
|
|
642
|
+
"canary": data,
|
|
643
|
+
}
|
|
644
|
+
except json.JSONDecodeError:
|
|
645
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
646
|
+
|
|
647
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def rollouts_detect(context: str = "") -> Dict[str, Any]:
|
|
651
|
+
"""Detect which progressive delivery tools are installed.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
context: Kubernetes context to use (optional)
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Detection results for Argo Rollouts and Flagger
|
|
658
|
+
"""
|
|
659
|
+
return {
|
|
660
|
+
"context": context or "current",
|
|
661
|
+
"argo_rollouts": {
|
|
662
|
+
"installed": crd_exists(ARGO_ROLLOUT_CRD, context),
|
|
663
|
+
"cli_available": _argo_rollouts_cli_available(),
|
|
664
|
+
"crds": {
|
|
665
|
+
"rollouts": crd_exists(ARGO_ROLLOUT_CRD, context),
|
|
666
|
+
"analysistemplates": crd_exists(ARGO_ANALYSIS_TEMPLATE_CRD, context),
|
|
667
|
+
"clusteranalysistemplates": crd_exists(ARGO_CLUSTER_ANALYSIS_TEMPLATE_CRD, context),
|
|
668
|
+
"analysisruns": crd_exists(ARGO_ANALYSIS_RUN_CRD, context),
|
|
669
|
+
"experiments": crd_exists(ARGO_EXPERIMENT_CRD, context),
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
"flagger": {
|
|
673
|
+
"installed": crd_exists(FLAGGER_CANARY_CRD, context),
|
|
674
|
+
"crds": {
|
|
675
|
+
"canaries": crd_exists(FLAGGER_CANARY_CRD, context),
|
|
676
|
+
"metrictemplates": crd_exists(FLAGGER_METRIC_TEMPLATE_CRD, context),
|
|
677
|
+
"alertproviders": crd_exists(FLAGGER_ALERT_PROVIDER_CRD, context),
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def register_rollouts_tools(mcp: FastMCP, non_destructive: bool = False):
|
|
684
|
+
"""Register progressive delivery tools with the MCP server."""
|
|
685
|
+
|
|
686
|
+
# Argo Rollouts tools
|
|
687
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
688
|
+
def rollouts_list_tool(
|
|
689
|
+
namespace: str = "",
|
|
690
|
+
context: str = "",
|
|
691
|
+
label_selector: str = ""
|
|
692
|
+
) -> str:
|
|
693
|
+
"""List Argo Rollouts with their status."""
|
|
694
|
+
return json.dumps(rollouts_list(namespace, context, label_selector), indent=2)
|
|
695
|
+
|
|
696
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
697
|
+
def rollout_get_tool(
|
|
698
|
+
name: str,
|
|
699
|
+
namespace: str,
|
|
700
|
+
context: str = ""
|
|
701
|
+
) -> str:
|
|
702
|
+
"""Get detailed information about an Argo Rollout."""
|
|
703
|
+
return json.dumps(rollout_get(name, namespace, context), indent=2)
|
|
704
|
+
|
|
705
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
706
|
+
def rollout_status_tool(
|
|
707
|
+
name: str,
|
|
708
|
+
namespace: str,
|
|
709
|
+
context: str = ""
|
|
710
|
+
) -> str:
|
|
711
|
+
"""Get current status of an Argo Rollout with step details."""
|
|
712
|
+
return json.dumps(rollout_status(name, namespace, context), indent=2)
|
|
713
|
+
|
|
714
|
+
@mcp.tool()
|
|
715
|
+
def rollout_promote_tool(
|
|
716
|
+
name: str,
|
|
717
|
+
namespace: str,
|
|
718
|
+
full: bool = False,
|
|
719
|
+
context: str = ""
|
|
720
|
+
) -> str:
|
|
721
|
+
"""Promote a paused Argo Rollout to the next step or full."""
|
|
722
|
+
if non_destructive:
|
|
723
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
724
|
+
return json.dumps(rollout_promote(name, namespace, full, context), indent=2)
|
|
725
|
+
|
|
726
|
+
@mcp.tool()
|
|
727
|
+
def rollout_abort_tool(
|
|
728
|
+
name: str,
|
|
729
|
+
namespace: str,
|
|
730
|
+
context: str = ""
|
|
731
|
+
) -> str:
|
|
732
|
+
"""Abort an Argo Rollout."""
|
|
733
|
+
if non_destructive:
|
|
734
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
735
|
+
return json.dumps(rollout_abort(name, namespace, context), indent=2)
|
|
736
|
+
|
|
737
|
+
@mcp.tool()
|
|
738
|
+
def rollout_retry_tool(
|
|
739
|
+
name: str,
|
|
740
|
+
namespace: str,
|
|
741
|
+
context: str = ""
|
|
742
|
+
) -> str:
|
|
743
|
+
"""Retry an aborted Argo Rollout."""
|
|
744
|
+
if non_destructive:
|
|
745
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
746
|
+
return json.dumps(rollout_retry(name, namespace, context), indent=2)
|
|
747
|
+
|
|
748
|
+
@mcp.tool()
|
|
749
|
+
def rollout_restart_tool(
|
|
750
|
+
name: str,
|
|
751
|
+
namespace: str,
|
|
752
|
+
context: str = ""
|
|
753
|
+
) -> str:
|
|
754
|
+
"""Restart an Argo Rollout."""
|
|
755
|
+
if non_destructive:
|
|
756
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
757
|
+
return json.dumps(rollout_restart(name, namespace, context), indent=2)
|
|
758
|
+
|
|
759
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
760
|
+
def analysis_runs_list_tool(
|
|
761
|
+
namespace: str = "",
|
|
762
|
+
context: str = "",
|
|
763
|
+
label_selector: str = ""
|
|
764
|
+
) -> str:
|
|
765
|
+
"""List Argo Rollouts AnalysisRuns."""
|
|
766
|
+
return json.dumps(analysis_runs_list(namespace, context, label_selector), indent=2)
|
|
767
|
+
|
|
768
|
+
# Flagger tools
|
|
769
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
770
|
+
def flagger_canaries_list_tool(
|
|
771
|
+
namespace: str = "",
|
|
772
|
+
context: str = "",
|
|
773
|
+
label_selector: str = ""
|
|
774
|
+
) -> str:
|
|
775
|
+
"""List Flagger Canary resources."""
|
|
776
|
+
return json.dumps(flagger_canaries_list(namespace, context, label_selector), indent=2)
|
|
777
|
+
|
|
778
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
779
|
+
def flagger_canary_get_tool(
|
|
780
|
+
name: str,
|
|
781
|
+
namespace: str,
|
|
782
|
+
context: str = ""
|
|
783
|
+
) -> str:
|
|
784
|
+
"""Get detailed information about a Flagger Canary."""
|
|
785
|
+
return json.dumps(flagger_canary_get(name, namespace, context), indent=2)
|
|
786
|
+
|
|
787
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
788
|
+
def rollouts_detect_tool(context: str = "") -> str:
|
|
789
|
+
"""Detect which progressive delivery tools are installed."""
|
|
790
|
+
return json.dumps(rollouts_detect(context), indent=2)
|