kubectl-mcp-server 1.14.0__py3-none-any.whl → 1.16.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.16.0.dist-info/METADATA +1047 -0
- kubectl_mcp_server-1.16.0.dist-info/RECORD +61 -0
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/crd_detector.py +247 -0
- kubectl_mcp_tool/k8s_config.py +304 -63
- kubectl_mcp_tool/mcp_server.py +27 -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 +395 -121
- kubectl_mcp_tool/tools/core.py +157 -60
- kubectl_mcp_tool/tools/cost.py +97 -41
- kubectl_mcp_tool/tools/deployments.py +173 -56
- kubectl_mcp_tool/tools/diagnostics.py +40 -13
- kubectl_mcp_tool/tools/gitops.py +552 -0
- kubectl_mcp_tool/tools/helm.py +133 -46
- 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/networking.py +106 -32
- kubectl_mcp_tool/tools/operations.py +176 -50
- kubectl_mcp_tool/tools/pods.py +162 -50
- kubectl_mcp_tool/tools/policy.py +554 -0
- kubectl_mcp_tool/tools/rollouts.py +790 -0
- kubectl_mcp_tool/tools/security.py +89 -36
- kubectl_mcp_tool/tools/storage.py +35 -16
- tests/test_browser.py +2 -2
- tests/test_ecosystem.py +331 -0
- tests/test_tools.py +73 -10
- kubectl_mcp_server-1.14.0.dist-info/METADATA +0 -780
- kubectl_mcp_server-1.14.0.dist-info/RECORD +0 -49
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""Cluster API (CAPI) toolset for kubectl-mcp-server.
|
|
2
|
+
|
|
3
|
+
Provides tools for managing Cluster API clusters and machine lifecycle.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
from typing import Dict, Any, List
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from fastmcp.tools import ToolAnnotations
|
|
13
|
+
except ImportError:
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
from mcp.types import ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from ..k8s_config import _get_kubectl_context_args
|
|
18
|
+
from ..crd_detector import crd_exists
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Cluster API CRDs
|
|
22
|
+
CLUSTER_CRD = "clusters.cluster.x-k8s.io"
|
|
23
|
+
MACHINE_CRD = "machines.cluster.x-k8s.io"
|
|
24
|
+
MACHINEDEPLOYMENT_CRD = "machinedeployments.cluster.x-k8s.io"
|
|
25
|
+
MACHINESET_CRD = "machinesets.cluster.x-k8s.io"
|
|
26
|
+
MACHINEPOOL_CRD = "machinepools.cluster.x-k8s.io"
|
|
27
|
+
MACHINEHEALTHCHECK_CRD = "machinehealthchecks.cluster.x-k8s.io"
|
|
28
|
+
CLUSTERCLASS_CRD = "clusterclasses.cluster.x-k8s.io"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_kubectl(args: List[str], context: str = "") -> Dict[str, Any]:
|
|
32
|
+
"""Run kubectl command and return result."""
|
|
33
|
+
cmd = ["kubectl"] + _get_kubectl_context_args(context) + args
|
|
34
|
+
try:
|
|
35
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
36
|
+
if result.returncode == 0:
|
|
37
|
+
return {"success": True, "output": result.stdout}
|
|
38
|
+
return {"success": False, "error": result.stderr}
|
|
39
|
+
except subprocess.TimeoutExpired:
|
|
40
|
+
return {"success": False, "error": "Command timed out"}
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return {"success": False, "error": str(e)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_resources(kind: str, namespace: str = "", context: str = "", label_selector: str = "") -> List[Dict]:
|
|
46
|
+
"""Get Kubernetes resources of a specific kind."""
|
|
47
|
+
args = ["get", kind, "-o", "json"]
|
|
48
|
+
if namespace:
|
|
49
|
+
args.extend(["-n", namespace])
|
|
50
|
+
else:
|
|
51
|
+
args.append("-A")
|
|
52
|
+
if label_selector:
|
|
53
|
+
args.extend(["-l", label_selector])
|
|
54
|
+
|
|
55
|
+
result = _run_kubectl(args, context)
|
|
56
|
+
if result["success"]:
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(result["output"])
|
|
59
|
+
return data.get("items", [])
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
return []
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clusterctl_available() -> bool:
|
|
66
|
+
"""Check if clusterctl CLI is available."""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(["clusterctl", "version"], capture_output=True, timeout=5)
|
|
69
|
+
return result.returncode == 0
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def capi_clusters_list(
|
|
75
|
+
namespace: str = "",
|
|
76
|
+
context: str = "",
|
|
77
|
+
label_selector: str = ""
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
"""List Cluster API managed clusters.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
83
|
+
context: Kubernetes context to use (optional)
|
|
84
|
+
label_selector: Label selector to filter clusters
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of CAPI clusters with their status
|
|
88
|
+
"""
|
|
89
|
+
if not crd_exists(CLUSTER_CRD, context):
|
|
90
|
+
return {
|
|
91
|
+
"success": False,
|
|
92
|
+
"error": "Cluster API is not installed (clusters.cluster.x-k8s.io CRD not found)"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clusters = []
|
|
96
|
+
for item in _get_resources("clusters.cluster.x-k8s.io", namespace, context, label_selector):
|
|
97
|
+
status = item.get("status", {})
|
|
98
|
+
spec = item.get("spec", {})
|
|
99
|
+
conditions = status.get("conditions", [])
|
|
100
|
+
|
|
101
|
+
# Find key conditions
|
|
102
|
+
ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
|
|
103
|
+
infra_ready = next((c for c in conditions if c.get("type") == "InfrastructureReady"), {})
|
|
104
|
+
cp_ready = next((c for c in conditions if c.get("type") == "ControlPlaneReady"), {})
|
|
105
|
+
|
|
106
|
+
clusters.append({
|
|
107
|
+
"name": item["metadata"]["name"],
|
|
108
|
+
"namespace": item["metadata"]["namespace"],
|
|
109
|
+
"phase": status.get("phase", "Unknown"),
|
|
110
|
+
"ready": ready_cond.get("status") == "True",
|
|
111
|
+
"infrastructure_ready": infra_ready.get("status") == "True",
|
|
112
|
+
"control_plane_ready": cp_ready.get("status") == "True",
|
|
113
|
+
"control_plane_endpoint": spec.get("controlPlaneEndpoint", {}),
|
|
114
|
+
"infrastructure_ref": spec.get("infrastructureRef", {}),
|
|
115
|
+
"control_plane_ref": spec.get("controlPlaneRef", {}),
|
|
116
|
+
"cluster_network": spec.get("clusterNetwork", {}),
|
|
117
|
+
"paused": spec.get("paused", False),
|
|
118
|
+
"observed_generation": status.get("observedGeneration"),
|
|
119
|
+
"failure_reason": status.get("failureReason"),
|
|
120
|
+
"failure_message": status.get("failureMessage"),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
# Summary
|
|
124
|
+
ready = sum(1 for c in clusters if c["ready"])
|
|
125
|
+
provisioning = sum(1 for c in clusters if c["phase"] == "Provisioning")
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"context": context or "current",
|
|
129
|
+
"total": len(clusters),
|
|
130
|
+
"ready": ready,
|
|
131
|
+
"provisioning": provisioning,
|
|
132
|
+
"clusters": clusters,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def capi_cluster_get(
|
|
137
|
+
name: str,
|
|
138
|
+
namespace: str,
|
|
139
|
+
context: str = ""
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
"""Get detailed information about a CAPI cluster.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Name of the cluster
|
|
145
|
+
namespace: Namespace of the cluster
|
|
146
|
+
context: Kubernetes context to use (optional)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Detailed cluster information
|
|
150
|
+
"""
|
|
151
|
+
if not crd_exists(CLUSTER_CRD, context):
|
|
152
|
+
return {"success": False, "error": "Cluster API is not installed"}
|
|
153
|
+
|
|
154
|
+
args = ["get", "clusters.cluster.x-k8s.io", name, "-n", namespace, "-o", "json"]
|
|
155
|
+
result = _run_kubectl(args, context)
|
|
156
|
+
|
|
157
|
+
if result["success"]:
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(result["output"])
|
|
160
|
+
return {
|
|
161
|
+
"success": True,
|
|
162
|
+
"context": context or "current",
|
|
163
|
+
"cluster": data,
|
|
164
|
+
}
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
167
|
+
|
|
168
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def capi_machines_list(
|
|
172
|
+
namespace: str = "",
|
|
173
|
+
cluster_name: str = "",
|
|
174
|
+
context: str = "",
|
|
175
|
+
label_selector: str = ""
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""List Cluster API machines.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
181
|
+
cluster_name: Filter by cluster name
|
|
182
|
+
context: Kubernetes context to use (optional)
|
|
183
|
+
label_selector: Label selector to filter machines
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of machines with their status
|
|
187
|
+
"""
|
|
188
|
+
if not crd_exists(MACHINE_CRD, context):
|
|
189
|
+
return {
|
|
190
|
+
"success": False,
|
|
191
|
+
"error": "Cluster API is not installed (machines.cluster.x-k8s.io CRD not found)"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Build label selector
|
|
195
|
+
selector = label_selector
|
|
196
|
+
if cluster_name:
|
|
197
|
+
cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
|
|
198
|
+
selector = f"{selector},{cluster_label}" if selector else cluster_label
|
|
199
|
+
|
|
200
|
+
machines = []
|
|
201
|
+
for item in _get_resources("machines.cluster.x-k8s.io", namespace, context, selector):
|
|
202
|
+
status = item.get("status", {})
|
|
203
|
+
spec = item.get("spec", {})
|
|
204
|
+
conditions = status.get("conditions", [])
|
|
205
|
+
|
|
206
|
+
ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
|
|
207
|
+
infra_ready = next((c for c in conditions if c.get("type") == "InfrastructureReady"), {})
|
|
208
|
+
|
|
209
|
+
machines.append({
|
|
210
|
+
"name": item["metadata"]["name"],
|
|
211
|
+
"namespace": item["metadata"]["namespace"],
|
|
212
|
+
"cluster": spec.get("clusterName", ""),
|
|
213
|
+
"phase": status.get("phase", "Unknown"),
|
|
214
|
+
"ready": ready_cond.get("status") == "True",
|
|
215
|
+
"infrastructure_ready": infra_ready.get("status") == "True",
|
|
216
|
+
"provider_id": spec.get("providerID", ""),
|
|
217
|
+
"version": spec.get("version", ""),
|
|
218
|
+
"bootstrap_ref": spec.get("bootstrap", {}).get("configRef", {}),
|
|
219
|
+
"infrastructure_ref": spec.get("infrastructureRef", {}),
|
|
220
|
+
"node_ref": status.get("nodeRef", {}),
|
|
221
|
+
"addresses": status.get("addresses", []),
|
|
222
|
+
"failure_reason": status.get("failureReason"),
|
|
223
|
+
"failure_message": status.get("failureMessage"),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
# Summary
|
|
227
|
+
ready = sum(1 for m in machines if m["ready"])
|
|
228
|
+
running = sum(1 for m in machines if m["phase"] == "Running")
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"context": context or "current",
|
|
232
|
+
"total": len(machines),
|
|
233
|
+
"ready": ready,
|
|
234
|
+
"running": running,
|
|
235
|
+
"machines": machines,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def capi_machine_get(
|
|
240
|
+
name: str,
|
|
241
|
+
namespace: str,
|
|
242
|
+
context: str = ""
|
|
243
|
+
) -> Dict[str, Any]:
|
|
244
|
+
"""Get detailed information about a CAPI machine.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
name: Name of the machine
|
|
248
|
+
namespace: Namespace of the machine
|
|
249
|
+
context: Kubernetes context to use (optional)
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Detailed machine information
|
|
253
|
+
"""
|
|
254
|
+
if not crd_exists(MACHINE_CRD, context):
|
|
255
|
+
return {"success": False, "error": "Cluster API is not installed"}
|
|
256
|
+
|
|
257
|
+
args = ["get", "machines.cluster.x-k8s.io", name, "-n", namespace, "-o", "json"]
|
|
258
|
+
result = _run_kubectl(args, context)
|
|
259
|
+
|
|
260
|
+
if result["success"]:
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(result["output"])
|
|
263
|
+
return {
|
|
264
|
+
"success": True,
|
|
265
|
+
"context": context or "current",
|
|
266
|
+
"machine": data,
|
|
267
|
+
}
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
270
|
+
|
|
271
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def capi_machinedeployments_list(
|
|
275
|
+
namespace: str = "",
|
|
276
|
+
cluster_name: str = "",
|
|
277
|
+
context: str = "",
|
|
278
|
+
label_selector: str = ""
|
|
279
|
+
) -> Dict[str, Any]:
|
|
280
|
+
"""List Cluster API MachineDeployments.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
284
|
+
cluster_name: Filter by cluster name
|
|
285
|
+
context: Kubernetes context to use (optional)
|
|
286
|
+
label_selector: Label selector to filter
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of MachineDeployments with their status
|
|
290
|
+
"""
|
|
291
|
+
if not crd_exists(MACHINEDEPLOYMENT_CRD, context):
|
|
292
|
+
return {
|
|
293
|
+
"success": False,
|
|
294
|
+
"error": "MachineDeployments CRD not found"
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Build label selector
|
|
298
|
+
selector = label_selector
|
|
299
|
+
if cluster_name:
|
|
300
|
+
cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
|
|
301
|
+
selector = f"{selector},{cluster_label}" if selector else cluster_label
|
|
302
|
+
|
|
303
|
+
deployments = []
|
|
304
|
+
for item in _get_resources("machinedeployments.cluster.x-k8s.io", namespace, context, selector):
|
|
305
|
+
status = item.get("status", {})
|
|
306
|
+
spec = item.get("spec", {})
|
|
307
|
+
conditions = status.get("conditions", [])
|
|
308
|
+
|
|
309
|
+
ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
|
|
310
|
+
available_cond = next((c for c in conditions if c.get("type") == "Available"), {})
|
|
311
|
+
|
|
312
|
+
deployments.append({
|
|
313
|
+
"name": item["metadata"]["name"],
|
|
314
|
+
"namespace": item["metadata"]["namespace"],
|
|
315
|
+
"cluster": spec.get("clusterName", ""),
|
|
316
|
+
"phase": status.get("phase", "Unknown"),
|
|
317
|
+
"ready": ready_cond.get("status") == "True",
|
|
318
|
+
"available": available_cond.get("status") == "True",
|
|
319
|
+
"replicas": spec.get("replicas", 0),
|
|
320
|
+
"ready_replicas": status.get("readyReplicas", 0),
|
|
321
|
+
"available_replicas": status.get("availableReplicas", 0),
|
|
322
|
+
"updated_replicas": status.get("updatedReplicas", 0),
|
|
323
|
+
"unavailable_replicas": status.get("unavailableReplicas", 0),
|
|
324
|
+
"version": spec.get("template", {}).get("spec", {}).get("version", ""),
|
|
325
|
+
"strategy": spec.get("strategy", {}),
|
|
326
|
+
"observed_generation": status.get("observedGeneration"),
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
"context": context or "current",
|
|
331
|
+
"total": len(deployments),
|
|
332
|
+
"deployments": deployments,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def capi_machinedeployment_scale(
|
|
337
|
+
name: str,
|
|
338
|
+
namespace: str,
|
|
339
|
+
replicas: int,
|
|
340
|
+
context: str = ""
|
|
341
|
+
) -> Dict[str, Any]:
|
|
342
|
+
"""Scale a MachineDeployment.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
name: Name of the MachineDeployment
|
|
346
|
+
namespace: Namespace of the MachineDeployment
|
|
347
|
+
replicas: Desired number of replicas
|
|
348
|
+
context: Kubernetes context to use (optional)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Scale result
|
|
352
|
+
"""
|
|
353
|
+
if not crd_exists(MACHINEDEPLOYMENT_CRD, context):
|
|
354
|
+
return {"success": False, "error": "Cluster API is not installed"}
|
|
355
|
+
|
|
356
|
+
if replicas < 0:
|
|
357
|
+
return {"success": False, "error": "Replicas must be >= 0"}
|
|
358
|
+
|
|
359
|
+
args = [
|
|
360
|
+
"scale", "machinedeployments.cluster.x-k8s.io", name,
|
|
361
|
+
"-n", namespace,
|
|
362
|
+
f"--replicas={replicas}"
|
|
363
|
+
]
|
|
364
|
+
result = _run_kubectl(args, context)
|
|
365
|
+
|
|
366
|
+
if result["success"]:
|
|
367
|
+
return {
|
|
368
|
+
"success": True,
|
|
369
|
+
"context": context or "current",
|
|
370
|
+
"message": f"Scaled MachineDeployment {name} to {replicas} replicas",
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {"success": False, "error": result.get("error", "Failed to scale")}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def capi_machinesets_list(
|
|
377
|
+
namespace: str = "",
|
|
378
|
+
cluster_name: str = "",
|
|
379
|
+
context: str = "",
|
|
380
|
+
label_selector: str = ""
|
|
381
|
+
) -> Dict[str, Any]:
|
|
382
|
+
"""List Cluster API MachineSets.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
386
|
+
cluster_name: Filter by cluster name
|
|
387
|
+
context: Kubernetes context to use (optional)
|
|
388
|
+
label_selector: Label selector to filter
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
List of MachineSets with their status
|
|
392
|
+
"""
|
|
393
|
+
if not crd_exists(MACHINESET_CRD, context):
|
|
394
|
+
return {
|
|
395
|
+
"success": False,
|
|
396
|
+
"error": "MachineSets CRD not found"
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
selector = label_selector
|
|
400
|
+
if cluster_name:
|
|
401
|
+
cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
|
|
402
|
+
selector = f"{selector},{cluster_label}" if selector else cluster_label
|
|
403
|
+
|
|
404
|
+
machinesets = []
|
|
405
|
+
for item in _get_resources("machinesets.cluster.x-k8s.io", namespace, context, selector):
|
|
406
|
+
status = item.get("status", {})
|
|
407
|
+
spec = item.get("spec", {})
|
|
408
|
+
|
|
409
|
+
machinesets.append({
|
|
410
|
+
"name": item["metadata"]["name"],
|
|
411
|
+
"namespace": item["metadata"]["namespace"],
|
|
412
|
+
"cluster": spec.get("clusterName", ""),
|
|
413
|
+
"replicas": spec.get("replicas", 0),
|
|
414
|
+
"ready_replicas": status.get("readyReplicas", 0),
|
|
415
|
+
"available_replicas": status.get("availableReplicas", 0),
|
|
416
|
+
"fully_labeled_replicas": status.get("fullyLabeledReplicas", 0),
|
|
417
|
+
"version": spec.get("template", {}).get("spec", {}).get("version", ""),
|
|
418
|
+
"selector": spec.get("selector", {}),
|
|
419
|
+
"observed_generation": status.get("observedGeneration"),
|
|
420
|
+
"failure_reason": status.get("failureReason"),
|
|
421
|
+
"failure_message": status.get("failureMessage"),
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
"context": context or "current",
|
|
426
|
+
"total": len(machinesets),
|
|
427
|
+
"machinesets": machinesets,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def capi_machinehealthchecks_list(
|
|
432
|
+
namespace: str = "",
|
|
433
|
+
cluster_name: str = "",
|
|
434
|
+
context: str = "",
|
|
435
|
+
label_selector: str = ""
|
|
436
|
+
) -> Dict[str, Any]:
|
|
437
|
+
"""List Cluster API MachineHealthChecks.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
441
|
+
cluster_name: Filter by cluster name
|
|
442
|
+
context: Kubernetes context to use (optional)
|
|
443
|
+
label_selector: Label selector to filter
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
List of MachineHealthChecks with their status
|
|
447
|
+
"""
|
|
448
|
+
if not crd_exists(MACHINEHEALTHCHECK_CRD, context):
|
|
449
|
+
return {
|
|
450
|
+
"success": False,
|
|
451
|
+
"error": "MachineHealthChecks CRD not found"
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
selector = label_selector
|
|
455
|
+
if cluster_name:
|
|
456
|
+
cluster_label = f"cluster.x-k8s.io/cluster-name={cluster_name}"
|
|
457
|
+
selector = f"{selector},{cluster_label}" if selector else cluster_label
|
|
458
|
+
|
|
459
|
+
healthchecks = []
|
|
460
|
+
for item in _get_resources("machinehealthchecks.cluster.x-k8s.io", namespace, context, selector):
|
|
461
|
+
status = item.get("status", {})
|
|
462
|
+
spec = item.get("spec", {})
|
|
463
|
+
conditions = status.get("conditions", [])
|
|
464
|
+
|
|
465
|
+
remediation_allowed = next((c for c in conditions if c.get("type") == "RemediationAllowed"), {})
|
|
466
|
+
|
|
467
|
+
healthchecks.append({
|
|
468
|
+
"name": item["metadata"]["name"],
|
|
469
|
+
"namespace": item["metadata"]["namespace"],
|
|
470
|
+
"cluster": spec.get("clusterName", ""),
|
|
471
|
+
"expected_machines": status.get("expectedMachines", 0),
|
|
472
|
+
"current_healthy": status.get("currentHealthy", 0),
|
|
473
|
+
"remediation_allowed": remediation_allowed.get("status") == "True",
|
|
474
|
+
"unhealthy_conditions": spec.get("unhealthyConditions", []),
|
|
475
|
+
"max_unhealthy": spec.get("maxUnhealthy"),
|
|
476
|
+
"node_startup_timeout": spec.get("nodeStartupTimeout"),
|
|
477
|
+
"targets": status.get("targets", []),
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"context": context or "current",
|
|
482
|
+
"total": len(healthchecks),
|
|
483
|
+
"healthchecks": healthchecks,
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def capi_clusterclasses_list(
|
|
488
|
+
namespace: str = "",
|
|
489
|
+
context: str = "",
|
|
490
|
+
label_selector: str = ""
|
|
491
|
+
) -> Dict[str, Any]:
|
|
492
|
+
"""List Cluster API ClusterClasses.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
496
|
+
context: Kubernetes context to use (optional)
|
|
497
|
+
label_selector: Label selector to filter
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
List of ClusterClasses
|
|
501
|
+
"""
|
|
502
|
+
if not crd_exists(CLUSTERCLASS_CRD, context):
|
|
503
|
+
return {
|
|
504
|
+
"success": False,
|
|
505
|
+
"error": "ClusterClasses CRD not found"
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
classes = []
|
|
509
|
+
for item in _get_resources("clusterclasses.cluster.x-k8s.io", namespace, context, label_selector):
|
|
510
|
+
spec = item.get("spec", {})
|
|
511
|
+
status = item.get("status", {})
|
|
512
|
+
conditions = status.get("conditions", [])
|
|
513
|
+
|
|
514
|
+
ready_cond = next((c for c in conditions if c.get("type") == "Ready"), {})
|
|
515
|
+
variables_ready = next((c for c in conditions if c.get("type") == "VariablesReady"), {})
|
|
516
|
+
|
|
517
|
+
workers = spec.get("workers", {})
|
|
518
|
+
machine_deployments = workers.get("machineDeployments", [])
|
|
519
|
+
machine_pools = workers.get("machinePools", [])
|
|
520
|
+
|
|
521
|
+
classes.append({
|
|
522
|
+
"name": item["metadata"]["name"],
|
|
523
|
+
"namespace": item["metadata"]["namespace"],
|
|
524
|
+
"ready": ready_cond.get("status") == "True",
|
|
525
|
+
"variables_ready": variables_ready.get("status") == "True",
|
|
526
|
+
"infrastructure_ref": spec.get("infrastructure", {}).get("ref", {}),
|
|
527
|
+
"control_plane_ref": spec.get("controlPlane", {}).get("ref", {}),
|
|
528
|
+
"machinedeployment_classes": len(machine_deployments),
|
|
529
|
+
"machinepool_classes": len(machine_pools),
|
|
530
|
+
"variables_count": len(spec.get("variables", [])),
|
|
531
|
+
"observed_generation": status.get("observedGeneration"),
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
"context": context or "current",
|
|
536
|
+
"total": len(classes),
|
|
537
|
+
"clusterclasses": classes,
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def capi_cluster_kubeconfig(
|
|
542
|
+
name: str,
|
|
543
|
+
namespace: str,
|
|
544
|
+
context: str = ""
|
|
545
|
+
) -> Dict[str, Any]:
|
|
546
|
+
"""Get kubeconfig for a CAPI cluster.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
name: Name of the cluster
|
|
550
|
+
namespace: Namespace of the cluster
|
|
551
|
+
context: Kubernetes context to use (optional)
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
Kubeconfig secret information
|
|
555
|
+
"""
|
|
556
|
+
if not crd_exists(CLUSTER_CRD, context):
|
|
557
|
+
return {"success": False, "error": "Cluster API is not installed"}
|
|
558
|
+
|
|
559
|
+
# CAPI stores kubeconfig in a secret named <cluster-name>-kubeconfig
|
|
560
|
+
secret_name = f"{name}-kubeconfig"
|
|
561
|
+
args = ["get", "secret", secret_name, "-n", namespace, "-o", "json"]
|
|
562
|
+
result = _run_kubectl(args, context)
|
|
563
|
+
|
|
564
|
+
if result["success"]:
|
|
565
|
+
try:
|
|
566
|
+
data = json.loads(result["output"])
|
|
567
|
+
# Don't expose actual kubeconfig data, just metadata
|
|
568
|
+
return {
|
|
569
|
+
"success": True,
|
|
570
|
+
"context": context or "current",
|
|
571
|
+
"secret_name": secret_name,
|
|
572
|
+
"namespace": namespace,
|
|
573
|
+
"exists": True,
|
|
574
|
+
"data_keys": list(data.get("data", {}).keys()),
|
|
575
|
+
"note": "Use 'clusterctl get kubeconfig' or 'kubectl get secret -o jsonpath' to retrieve actual kubeconfig",
|
|
576
|
+
}
|
|
577
|
+
except json.JSONDecodeError:
|
|
578
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
579
|
+
|
|
580
|
+
# Check if this is a NotFound error vs other failures
|
|
581
|
+
error_output = result.get("output", "") + result.get("error", "")
|
|
582
|
+
if "NotFound" in error_output or "not found" in error_output.lower():
|
|
583
|
+
return {
|
|
584
|
+
"success": True,
|
|
585
|
+
"context": context or "current",
|
|
586
|
+
"secret_name": secret_name,
|
|
587
|
+
"namespace": namespace,
|
|
588
|
+
"exists": False,
|
|
589
|
+
"note": "Kubeconfig secret not found - cluster may still be provisioning",
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
# For other kubectl failures, return the actual error
|
|
593
|
+
return {
|
|
594
|
+
"success": False,
|
|
595
|
+
"error": error_output.strip() or "Failed to get kubeconfig secret",
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def capi_detect(context: str = "") -> Dict[str, Any]:
|
|
600
|
+
"""Detect if Cluster API is installed and its components.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
context: Kubernetes context to use (optional)
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Detection results for Cluster API
|
|
607
|
+
"""
|
|
608
|
+
return {
|
|
609
|
+
"context": context or "current",
|
|
610
|
+
"installed": crd_exists(CLUSTER_CRD, context),
|
|
611
|
+
"cli_available": _clusterctl_available(),
|
|
612
|
+
"crds": {
|
|
613
|
+
"clusters": crd_exists(CLUSTER_CRD, context),
|
|
614
|
+
"machines": crd_exists(MACHINE_CRD, context),
|
|
615
|
+
"machinedeployments": crd_exists(MACHINEDEPLOYMENT_CRD, context),
|
|
616
|
+
"machinesets": crd_exists(MACHINESET_CRD, context),
|
|
617
|
+
"machinepools": crd_exists(MACHINEPOOL_CRD, context),
|
|
618
|
+
"machinehealthchecks": crd_exists(MACHINEHEALTHCHECK_CRD, context),
|
|
619
|
+
"clusterclasses": crd_exists(CLUSTERCLASS_CRD, context),
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def register_capi_tools(mcp: FastMCP, non_destructive: bool = False):
|
|
625
|
+
"""Register Cluster API tools with the MCP server."""
|
|
626
|
+
|
|
627
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
628
|
+
def capi_clusters_list_tool(
|
|
629
|
+
namespace: str = "",
|
|
630
|
+
context: str = "",
|
|
631
|
+
label_selector: str = ""
|
|
632
|
+
) -> str:
|
|
633
|
+
"""List Cluster API managed clusters."""
|
|
634
|
+
return json.dumps(capi_clusters_list(namespace, context, label_selector), indent=2)
|
|
635
|
+
|
|
636
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
637
|
+
def capi_cluster_get_tool(
|
|
638
|
+
name: str,
|
|
639
|
+
namespace: str,
|
|
640
|
+
context: str = ""
|
|
641
|
+
) -> str:
|
|
642
|
+
"""Get detailed information about a CAPI cluster."""
|
|
643
|
+
return json.dumps(capi_cluster_get(name, namespace, context), indent=2)
|
|
644
|
+
|
|
645
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
646
|
+
def capi_machines_list_tool(
|
|
647
|
+
namespace: str = "",
|
|
648
|
+
cluster_name: str = "",
|
|
649
|
+
context: str = "",
|
|
650
|
+
label_selector: str = ""
|
|
651
|
+
) -> str:
|
|
652
|
+
"""List Cluster API machines."""
|
|
653
|
+
return json.dumps(capi_machines_list(namespace, cluster_name, context, label_selector), indent=2)
|
|
654
|
+
|
|
655
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
656
|
+
def capi_machine_get_tool(
|
|
657
|
+
name: str,
|
|
658
|
+
namespace: str,
|
|
659
|
+
context: str = ""
|
|
660
|
+
) -> str:
|
|
661
|
+
"""Get detailed information about a CAPI machine."""
|
|
662
|
+
return json.dumps(capi_machine_get(name, namespace, context), indent=2)
|
|
663
|
+
|
|
664
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
665
|
+
def capi_machinedeployments_list_tool(
|
|
666
|
+
namespace: str = "",
|
|
667
|
+
cluster_name: str = "",
|
|
668
|
+
context: str = "",
|
|
669
|
+
label_selector: str = ""
|
|
670
|
+
) -> str:
|
|
671
|
+
"""List Cluster API MachineDeployments."""
|
|
672
|
+
return json.dumps(capi_machinedeployments_list(namespace, cluster_name, context, label_selector), indent=2)
|
|
673
|
+
|
|
674
|
+
@mcp.tool()
|
|
675
|
+
def capi_machinedeployment_scale_tool(
|
|
676
|
+
name: str,
|
|
677
|
+
namespace: str,
|
|
678
|
+
replicas: int,
|
|
679
|
+
context: str = ""
|
|
680
|
+
) -> str:
|
|
681
|
+
"""Scale a CAPI MachineDeployment."""
|
|
682
|
+
if non_destructive:
|
|
683
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
684
|
+
return json.dumps(capi_machinedeployment_scale(name, namespace, replicas, context), indent=2)
|
|
685
|
+
|
|
686
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
687
|
+
def capi_machinesets_list_tool(
|
|
688
|
+
namespace: str = "",
|
|
689
|
+
cluster_name: str = "",
|
|
690
|
+
context: str = "",
|
|
691
|
+
label_selector: str = ""
|
|
692
|
+
) -> str:
|
|
693
|
+
"""List Cluster API MachineSets."""
|
|
694
|
+
return json.dumps(capi_machinesets_list(namespace, cluster_name, context, label_selector), indent=2)
|
|
695
|
+
|
|
696
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
697
|
+
def capi_machinehealthchecks_list_tool(
|
|
698
|
+
namespace: str = "",
|
|
699
|
+
cluster_name: str = "",
|
|
700
|
+
context: str = "",
|
|
701
|
+
label_selector: str = ""
|
|
702
|
+
) -> str:
|
|
703
|
+
"""List Cluster API MachineHealthChecks."""
|
|
704
|
+
return json.dumps(capi_machinehealthchecks_list(namespace, cluster_name, context, label_selector), indent=2)
|
|
705
|
+
|
|
706
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
707
|
+
def capi_clusterclasses_list_tool(
|
|
708
|
+
namespace: str = "",
|
|
709
|
+
context: str = "",
|
|
710
|
+
label_selector: str = ""
|
|
711
|
+
) -> str:
|
|
712
|
+
"""List Cluster API ClusterClasses."""
|
|
713
|
+
return json.dumps(capi_clusterclasses_list(namespace, context, label_selector), indent=2)
|
|
714
|
+
|
|
715
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
716
|
+
def capi_cluster_kubeconfig_tool(
|
|
717
|
+
name: str,
|
|
718
|
+
namespace: str,
|
|
719
|
+
context: str = ""
|
|
720
|
+
) -> str:
|
|
721
|
+
"""Get kubeconfig secret info for a CAPI cluster."""
|
|
722
|
+
return json.dumps(capi_cluster_kubeconfig(name, namespace, context), indent=2)
|
|
723
|
+
|
|
724
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
725
|
+
def capi_detect_tool(context: str = "") -> str:
|
|
726
|
+
"""Detect if Cluster API is installed and its components."""
|
|
727
|
+
return json.dumps(capi_detect(context), indent=2)
|