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,709 @@
|
|
|
1
|
+
"""Cert-Manager toolset for kubectl-mcp-server.
|
|
2
|
+
|
|
3
|
+
Provides tools for managing certificates via cert-manager.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
from typing import Dict, Any, List, Optional
|
|
9
|
+
from datetime import datetime, timezone
|
|
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
|
+
CERTIFICATE_CRD = "certificates.cert-manager.io"
|
|
23
|
+
ISSUER_CRD = "issuers.cert-manager.io"
|
|
24
|
+
CLUSTER_ISSUER_CRD = "clusterissuers.cert-manager.io"
|
|
25
|
+
CERTIFICATE_REQUEST_CRD = "certificaterequests.cert-manager.io"
|
|
26
|
+
ORDER_CRD = "orders.acme.cert-manager.io"
|
|
27
|
+
CHALLENGE_CRD = "challenges.acme.cert-manager.io"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_kubectl(args: List[str], context: str = "") -> Dict[str, Any]:
|
|
31
|
+
"""Run kubectl command and return result."""
|
|
32
|
+
cmd = ["kubectl"] + _get_kubectl_context_args(context) + args
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
35
|
+
if result.returncode == 0:
|
|
36
|
+
return {"success": True, "output": result.stdout}
|
|
37
|
+
return {"success": False, "error": result.stderr}
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
return {"success": False, "error": "Command timed out"}
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return {"success": False, "error": str(e)}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CertsResourceError(Exception):
|
|
45
|
+
"""Exception raised when fetching cert-manager resources fails."""
|
|
46
|
+
pass
|
|
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
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
CertsResourceError: If kubectl command fails or output cannot be parsed
|
|
54
|
+
"""
|
|
55
|
+
args = ["get", kind, "-o", "json"]
|
|
56
|
+
if namespace:
|
|
57
|
+
args.extend(["-n", namespace])
|
|
58
|
+
else:
|
|
59
|
+
args.append("-A")
|
|
60
|
+
if label_selector:
|
|
61
|
+
args.extend(["-l", label_selector])
|
|
62
|
+
|
|
63
|
+
result = _run_kubectl(args, context)
|
|
64
|
+
if not result["success"]:
|
|
65
|
+
error_msg = result.get("error", "Unknown error")
|
|
66
|
+
raise CertsResourceError(
|
|
67
|
+
f"Failed to get {kind} (namespace={namespace or 'all'}, context={context or 'current'}, "
|
|
68
|
+
f"label_selector={label_selector or 'none'}): {error_msg}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(result["output"])
|
|
73
|
+
return data.get("items", [])
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
raise CertsResourceError(
|
|
76
|
+
f"Failed to parse JSON response for {kind} (namespace={namespace or 'all'}, "
|
|
77
|
+
f"context={context or 'current'}): {e}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_condition(conditions: List[Dict], condition_type: str) -> Optional[Dict]:
|
|
82
|
+
"""Get a specific condition from conditions list."""
|
|
83
|
+
return next((c for c in conditions if c.get("type") == condition_type), None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_timestamp(ts: str) -> Optional[datetime]:
|
|
87
|
+
"""Parse Kubernetes timestamp string."""
|
|
88
|
+
if not ts:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def certs_list(
|
|
97
|
+
namespace: str = "",
|
|
98
|
+
context: str = "",
|
|
99
|
+
label_selector: str = ""
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""List cert-manager certificates with status.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
105
|
+
context: Kubernetes context to use (optional)
|
|
106
|
+
label_selector: Label selector to filter certificates
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of certificates with their status and expiry information
|
|
110
|
+
"""
|
|
111
|
+
if not crd_exists(CERTIFICATE_CRD, context):
|
|
112
|
+
return {
|
|
113
|
+
"success": False,
|
|
114
|
+
"error": "cert-manager is not installed (certificates.cert-manager.io CRD not found)"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
resources = _get_resources("certificates.cert-manager.io", namespace, context, label_selector)
|
|
119
|
+
except CertsResourceError as e:
|
|
120
|
+
return {"success": False, "error": str(e)}
|
|
121
|
+
|
|
122
|
+
certs = []
|
|
123
|
+
for item in resources:
|
|
124
|
+
status = item.get("status", {})
|
|
125
|
+
conditions = status.get("conditions", [])
|
|
126
|
+
ready_cond = _get_condition(conditions, "Ready")
|
|
127
|
+
spec = item.get("spec", {})
|
|
128
|
+
|
|
129
|
+
not_after = _parse_timestamp(status.get("notAfter", ""))
|
|
130
|
+
|
|
131
|
+
days_until_expiry = None
|
|
132
|
+
if not_after:
|
|
133
|
+
days_until_expiry = (not_after - datetime.now(timezone.utc)).days
|
|
134
|
+
|
|
135
|
+
certs.append({
|
|
136
|
+
"name": item["metadata"]["name"],
|
|
137
|
+
"namespace": item["metadata"]["namespace"],
|
|
138
|
+
"ready": ready_cond.get("status") == "True" if ready_cond else False,
|
|
139
|
+
"status": ready_cond.get("reason", "Unknown") if ready_cond else "Unknown",
|
|
140
|
+
"message": ready_cond.get("message", "") if ready_cond else "",
|
|
141
|
+
"issuer": spec.get("issuerRef", {}).get("name", ""),
|
|
142
|
+
"issuer_kind": spec.get("issuerRef", {}).get("kind", "Issuer"),
|
|
143
|
+
"secret_name": spec.get("secretName", ""),
|
|
144
|
+
"dns_names": spec.get("dnsNames", []),
|
|
145
|
+
"common_name": spec.get("commonName", ""),
|
|
146
|
+
"not_before": status.get("notBefore", ""),
|
|
147
|
+
"not_after": status.get("notAfter", ""),
|
|
148
|
+
"renewal_time": status.get("renewalTime", ""),
|
|
149
|
+
"days_until_expiry": days_until_expiry,
|
|
150
|
+
"revision": status.get("revision"),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expiring_soon = [c for c in certs if c.get("days_until_expiry") is not None and c["days_until_expiry"] < 30]
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"context": context or "current",
|
|
157
|
+
"total": len(certs),
|
|
158
|
+
"expiring_soon": len(expiring_soon),
|
|
159
|
+
"certificates": certs,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def certs_get(
|
|
164
|
+
name: str,
|
|
165
|
+
namespace: str,
|
|
166
|
+
context: str = ""
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
"""Get detailed information about a certificate.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
name: Name of the certificate
|
|
172
|
+
namespace: Namespace of the certificate
|
|
173
|
+
context: Kubernetes context to use (optional)
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Detailed certificate information
|
|
177
|
+
"""
|
|
178
|
+
if not crd_exists(CERTIFICATE_CRD, context):
|
|
179
|
+
return {"success": False, "error": "cert-manager is not installed"}
|
|
180
|
+
|
|
181
|
+
args = ["get", "certificates.cert-manager.io", name, "-n", namespace, "-o", "json"]
|
|
182
|
+
result = _run_kubectl(args, context)
|
|
183
|
+
|
|
184
|
+
if result["success"]:
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(result["output"])
|
|
187
|
+
return {
|
|
188
|
+
"success": True,
|
|
189
|
+
"context": context or "current",
|
|
190
|
+
"certificate": data,
|
|
191
|
+
}
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
194
|
+
|
|
195
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def certs_issuers_list(
|
|
199
|
+
namespace: str = "",
|
|
200
|
+
context: str = "",
|
|
201
|
+
include_cluster_issuers: bool = True
|
|
202
|
+
) -> Dict[str, Any]:
|
|
203
|
+
"""List cert-manager Issuers and ClusterIssuers.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
namespace: Filter by namespace for Issuers (empty for all)
|
|
207
|
+
context: Kubernetes context to use (optional)
|
|
208
|
+
include_cluster_issuers: Include ClusterIssuers in the list
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of issuers with their status
|
|
212
|
+
"""
|
|
213
|
+
issuers = []
|
|
214
|
+
|
|
215
|
+
if crd_exists(ISSUER_CRD, context):
|
|
216
|
+
try:
|
|
217
|
+
issuer_resources = _get_resources("issuers.cert-manager.io", namespace, context)
|
|
218
|
+
except CertsResourceError as e:
|
|
219
|
+
return {"success": False, "error": str(e)}
|
|
220
|
+
|
|
221
|
+
for item in issuer_resources:
|
|
222
|
+
status = item.get("status", {})
|
|
223
|
+
conditions = status.get("conditions", [])
|
|
224
|
+
ready_cond = _get_condition(conditions, "Ready")
|
|
225
|
+
spec = item.get("spec", {})
|
|
226
|
+
|
|
227
|
+
issuer_type = "Unknown"
|
|
228
|
+
if "acme" in spec:
|
|
229
|
+
issuer_type = "ACME"
|
|
230
|
+
elif "ca" in spec:
|
|
231
|
+
issuer_type = "CA"
|
|
232
|
+
elif "selfSigned" in spec:
|
|
233
|
+
issuer_type = "SelfSigned"
|
|
234
|
+
elif "vault" in spec:
|
|
235
|
+
issuer_type = "Vault"
|
|
236
|
+
elif "venafi" in spec:
|
|
237
|
+
issuer_type = "Venafi"
|
|
238
|
+
|
|
239
|
+
issuers.append({
|
|
240
|
+
"name": item["metadata"]["name"],
|
|
241
|
+
"namespace": item["metadata"]["namespace"],
|
|
242
|
+
"kind": "Issuer",
|
|
243
|
+
"type": issuer_type,
|
|
244
|
+
"ready": ready_cond.get("status") == "True" if ready_cond else False,
|
|
245
|
+
"status": ready_cond.get("reason", "Unknown") if ready_cond else "Unknown",
|
|
246
|
+
"message": ready_cond.get("message", "") if ready_cond else "",
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if include_cluster_issuers and crd_exists(CLUSTER_ISSUER_CRD, context):
|
|
250
|
+
try:
|
|
251
|
+
cluster_issuer_resources = _get_resources("clusterissuers.cert-manager.io", "", context)
|
|
252
|
+
except CertsResourceError as e:
|
|
253
|
+
return {"success": False, "error": str(e)}
|
|
254
|
+
|
|
255
|
+
for item in cluster_issuer_resources:
|
|
256
|
+
status = item.get("status", {})
|
|
257
|
+
conditions = status.get("conditions", [])
|
|
258
|
+
ready_cond = _get_condition(conditions, "Ready")
|
|
259
|
+
spec = item.get("spec", {})
|
|
260
|
+
|
|
261
|
+
issuer_type = "Unknown"
|
|
262
|
+
if "acme" in spec:
|
|
263
|
+
issuer_type = "ACME"
|
|
264
|
+
elif "ca" in spec:
|
|
265
|
+
issuer_type = "CA"
|
|
266
|
+
elif "selfSigned" in spec:
|
|
267
|
+
issuer_type = "SelfSigned"
|
|
268
|
+
elif "vault" in spec:
|
|
269
|
+
issuer_type = "Vault"
|
|
270
|
+
elif "venafi" in spec:
|
|
271
|
+
issuer_type = "Venafi"
|
|
272
|
+
|
|
273
|
+
issuers.append({
|
|
274
|
+
"name": item["metadata"]["name"],
|
|
275
|
+
"namespace": "",
|
|
276
|
+
"kind": "ClusterIssuer",
|
|
277
|
+
"type": issuer_type,
|
|
278
|
+
"ready": ready_cond.get("status") == "True" if ready_cond else False,
|
|
279
|
+
"status": ready_cond.get("reason", "Unknown") if ready_cond else "Unknown",
|
|
280
|
+
"message": ready_cond.get("message", "") if ready_cond else "",
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
"context": context or "current",
|
|
285
|
+
"total": len(issuers),
|
|
286
|
+
"issuers": issuers,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def certs_issuer_get(
|
|
291
|
+
name: str,
|
|
292
|
+
namespace: str = "",
|
|
293
|
+
kind: str = "Issuer",
|
|
294
|
+
context: str = ""
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""Get detailed information about an Issuer or ClusterIssuer.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
name: Name of the issuer
|
|
300
|
+
namespace: Namespace (only for Issuer, not ClusterIssuer)
|
|
301
|
+
kind: Issuer or ClusterIssuer
|
|
302
|
+
context: Kubernetes context to use (optional)
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Detailed issuer information
|
|
306
|
+
"""
|
|
307
|
+
if kind.lower() == "clusterissuer":
|
|
308
|
+
crd = "clusterissuers.cert-manager.io"
|
|
309
|
+
args = ["get", crd, name, "-o", "json"]
|
|
310
|
+
else:
|
|
311
|
+
# Issuers are namespaced resources, namespace is required
|
|
312
|
+
if not namespace or not namespace.strip():
|
|
313
|
+
return {"success": False, "error": "namespace is required for Issuer lookups"}
|
|
314
|
+
crd = "issuers.cert-manager.io"
|
|
315
|
+
args = ["get", crd, name, "-n", namespace, "-o", "json"]
|
|
316
|
+
|
|
317
|
+
if not crd_exists(crd, context):
|
|
318
|
+
return {"success": False, "error": f"{crd} not found"}
|
|
319
|
+
|
|
320
|
+
result = _run_kubectl(args, context)
|
|
321
|
+
|
|
322
|
+
if result["success"]:
|
|
323
|
+
try:
|
|
324
|
+
data = json.loads(result["output"])
|
|
325
|
+
return {
|
|
326
|
+
"success": True,
|
|
327
|
+
"context": context or "current",
|
|
328
|
+
"issuer": data,
|
|
329
|
+
}
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
return {"success": False, "error": "Failed to parse response"}
|
|
332
|
+
|
|
333
|
+
return {"success": False, "error": result.get("error", "Unknown error")}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def certs_renew(
|
|
337
|
+
name: str,
|
|
338
|
+
namespace: str,
|
|
339
|
+
context: str = ""
|
|
340
|
+
) -> Dict[str, Any]:
|
|
341
|
+
"""Trigger certificate renewal by adding renew annotation.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
name: Name of the certificate
|
|
345
|
+
namespace: Namespace of the certificate
|
|
346
|
+
context: Kubernetes context to use (optional)
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Renewal trigger result
|
|
350
|
+
"""
|
|
351
|
+
if not crd_exists(CERTIFICATE_CRD, context):
|
|
352
|
+
return {"success": False, "error": "cert-manager is not installed"}
|
|
353
|
+
|
|
354
|
+
cmctl_available = False
|
|
355
|
+
try:
|
|
356
|
+
check = subprocess.run(["cmctl", "version"], capture_output=True, timeout=5)
|
|
357
|
+
cmctl_available = check.returncode == 0
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
if cmctl_available:
|
|
362
|
+
cmd = ["cmctl", "renew", name, "-n", namespace]
|
|
363
|
+
if context:
|
|
364
|
+
cmd.extend(["--context", context])
|
|
365
|
+
try:
|
|
366
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
367
|
+
if result.returncode == 0:
|
|
368
|
+
return {
|
|
369
|
+
"success": True,
|
|
370
|
+
"context": context or "current",
|
|
371
|
+
"message": f"Triggered renewal for certificate {name} using cmctl",
|
|
372
|
+
"output": result.stdout,
|
|
373
|
+
}
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# Fallback: Delete the certificate's secret to trigger cert-manager to reissue
|
|
378
|
+
# This is the recommended way to trigger renewal without cmctl
|
|
379
|
+
# First, get the secret name from the certificate spec
|
|
380
|
+
get_args = [
|
|
381
|
+
"get", "certificates.cert-manager.io", name,
|
|
382
|
+
"-n", namespace,
|
|
383
|
+
"-o", "jsonpath={.spec.secretName}"
|
|
384
|
+
]
|
|
385
|
+
secret_result = _run_kubectl(get_args, context)
|
|
386
|
+
|
|
387
|
+
if not secret_result["success"]:
|
|
388
|
+
return {"success": False, "error": f"Failed to get certificate: {secret_result.get('error', 'Unknown error')}"}
|
|
389
|
+
|
|
390
|
+
secret_name = secret_result.get("output", "").strip()
|
|
391
|
+
if not secret_name:
|
|
392
|
+
return {"success": False, "error": "Certificate does not have a secretName configured"}
|
|
393
|
+
|
|
394
|
+
# Delete the secret to trigger renewal
|
|
395
|
+
delete_args = ["delete", "secret", secret_name, "-n", namespace, "--ignore-not-found"]
|
|
396
|
+
result = _run_kubectl(delete_args, context)
|
|
397
|
+
|
|
398
|
+
if result["success"]:
|
|
399
|
+
return {
|
|
400
|
+
"success": True,
|
|
401
|
+
"context": context or "current",
|
|
402
|
+
"message": f"Deleted secret '{secret_name}' to trigger renewal for certificate {name}",
|
|
403
|
+
"note": "cert-manager will automatically reissue the certificate",
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {"success": False, "error": result.get("error", "Failed to trigger renewal")}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def certs_status_explain(
|
|
410
|
+
name: str,
|
|
411
|
+
namespace: str,
|
|
412
|
+
context: str = ""
|
|
413
|
+
) -> Dict[str, Any]:
|
|
414
|
+
"""Explain certificate status with diagnosis and recommendations.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
name: Name of the certificate
|
|
418
|
+
namespace: Namespace of the certificate
|
|
419
|
+
context: Kubernetes context to use (optional)
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Status explanation with diagnosis and recommendations
|
|
423
|
+
"""
|
|
424
|
+
cert_result = certs_get(name, namespace, context)
|
|
425
|
+
if not cert_result.get("success"):
|
|
426
|
+
return cert_result
|
|
427
|
+
|
|
428
|
+
cert = cert_result["certificate"]
|
|
429
|
+
status = cert.get("status", {})
|
|
430
|
+
spec = cert.get("spec", {})
|
|
431
|
+
conditions = status.get("conditions", [])
|
|
432
|
+
|
|
433
|
+
diagnosis = []
|
|
434
|
+
recommendations = []
|
|
435
|
+
|
|
436
|
+
ready_cond = _get_condition(conditions, "Ready")
|
|
437
|
+
issuing_cond = _get_condition(conditions, "Issuing")
|
|
438
|
+
|
|
439
|
+
if ready_cond:
|
|
440
|
+
if ready_cond.get("status") != "True":
|
|
441
|
+
diagnosis.append(f"Certificate not ready: {ready_cond.get('message', 'Unknown reason')}")
|
|
442
|
+
|
|
443
|
+
reason = ready_cond.get("reason", "")
|
|
444
|
+
if "Pending" in reason:
|
|
445
|
+
recommendations.append("Check if the Issuer is ready and properly configured")
|
|
446
|
+
recommendations.append("Verify DNS records are correctly configured for ACME challenges")
|
|
447
|
+
elif "Failed" in reason:
|
|
448
|
+
recommendations.append("Check certificate request logs for detailed error")
|
|
449
|
+
recommendations.append("Verify Issuer credentials and permissions")
|
|
450
|
+
|
|
451
|
+
if issuing_cond and issuing_cond.get("status") == "True":
|
|
452
|
+
diagnosis.append("Certificate is currently being issued")
|
|
453
|
+
recommendations.append("Wait for issuance to complete")
|
|
454
|
+
|
|
455
|
+
issuer_ref = spec.get("issuerRef", {})
|
|
456
|
+
issuer_name = issuer_ref.get("name", "")
|
|
457
|
+
issuer_kind = issuer_ref.get("kind", "Issuer")
|
|
458
|
+
|
|
459
|
+
if issuer_name:
|
|
460
|
+
if issuer_kind == "ClusterIssuer":
|
|
461
|
+
issuer_result = certs_issuer_get(issuer_name, "", "ClusterIssuer", context)
|
|
462
|
+
else:
|
|
463
|
+
issuer_result = certs_issuer_get(issuer_name, namespace, "Issuer", context)
|
|
464
|
+
|
|
465
|
+
if not issuer_result.get("success"):
|
|
466
|
+
diagnosis.append(f"Referenced {issuer_kind} '{issuer_name}' not found")
|
|
467
|
+
recommendations.append(f"Create the {issuer_kind} or update the certificate to reference an existing one")
|
|
468
|
+
else:
|
|
469
|
+
issuer = issuer_result["issuer"]
|
|
470
|
+
issuer_conditions = issuer.get("status", {}).get("conditions", [])
|
|
471
|
+
issuer_ready = _get_condition(issuer_conditions, "Ready")
|
|
472
|
+
if issuer_ready and issuer_ready.get("status") != "True":
|
|
473
|
+
diagnosis.append(f"{issuer_kind} '{issuer_name}' is not ready: {issuer_ready.get('message', '')}")
|
|
474
|
+
recommendations.append(f"Fix the {issuer_kind} configuration before the certificate can be issued")
|
|
475
|
+
|
|
476
|
+
not_after = _parse_timestamp(status.get("notAfter", ""))
|
|
477
|
+
if not_after:
|
|
478
|
+
days_left = (not_after - datetime.now(timezone.utc)).days
|
|
479
|
+
if days_left < 0:
|
|
480
|
+
diagnosis.append("Certificate has EXPIRED!")
|
|
481
|
+
recommendations.append("Trigger immediate renewal or check why auto-renewal failed")
|
|
482
|
+
elif days_left < 7:
|
|
483
|
+
diagnosis.append(f"Certificate expires in {days_left} days - CRITICAL")
|
|
484
|
+
recommendations.append("Check if auto-renewal is configured and working")
|
|
485
|
+
elif days_left < 30:
|
|
486
|
+
diagnosis.append(f"Certificate expires in {days_left} days")
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
"success": True,
|
|
490
|
+
"context": context or "current",
|
|
491
|
+
"name": name,
|
|
492
|
+
"namespace": namespace,
|
|
493
|
+
"ready": ready_cond.get("status") == "True" if ready_cond else False,
|
|
494
|
+
"status": ready_cond.get("reason", "Unknown") if ready_cond else "Unknown",
|
|
495
|
+
"message": ready_cond.get("message", "") if ready_cond else "",
|
|
496
|
+
"diagnosis": diagnosis,
|
|
497
|
+
"recommendations": recommendations,
|
|
498
|
+
"issuer": {
|
|
499
|
+
"name": issuer_name,
|
|
500
|
+
"kind": issuer_kind,
|
|
501
|
+
},
|
|
502
|
+
"expiry": {
|
|
503
|
+
"not_after": status.get("notAfter", ""),
|
|
504
|
+
"days_remaining": (not_after - datetime.now(timezone.utc)).days if not_after else None,
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def certs_challenges_list(
|
|
510
|
+
namespace: str = "",
|
|
511
|
+
context: str = "",
|
|
512
|
+
label_selector: str = ""
|
|
513
|
+
) -> Dict[str, Any]:
|
|
514
|
+
"""List ACME challenges (for debugging certificate issuance).
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
518
|
+
context: Kubernetes context to use (optional)
|
|
519
|
+
label_selector: Label selector to filter challenges
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
List of ACME challenges with their status
|
|
523
|
+
"""
|
|
524
|
+
if not crd_exists(CHALLENGE_CRD, context):
|
|
525
|
+
return {
|
|
526
|
+
"success": False,
|
|
527
|
+
"error": "ACME challenges CRD not found (challenges.acme.cert-manager.io)"
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
challenges = []
|
|
531
|
+
for item in _get_resources("challenges.acme.cert-manager.io", namespace, context, label_selector):
|
|
532
|
+
status = item.get("status", {})
|
|
533
|
+
spec = item.get("spec", {})
|
|
534
|
+
|
|
535
|
+
challenges.append({
|
|
536
|
+
"name": item["metadata"]["name"],
|
|
537
|
+
"namespace": item["metadata"]["namespace"],
|
|
538
|
+
"state": status.get("state", "Unknown"),
|
|
539
|
+
"reason": status.get("reason", ""),
|
|
540
|
+
"type": spec.get("type", ""),
|
|
541
|
+
"token": spec.get("token", "")[:20] + "..." if spec.get("token") else "",
|
|
542
|
+
"dns_name": spec.get("dnsName", ""),
|
|
543
|
+
"presented": status.get("presented", False),
|
|
544
|
+
"processing": status.get("processing", False),
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
pending = [c for c in challenges if c["state"] not in ("valid", "ready")]
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
"context": context or "current",
|
|
551
|
+
"total": len(challenges),
|
|
552
|
+
"pending": len(pending),
|
|
553
|
+
"challenges": challenges,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def certs_requests_list(
|
|
558
|
+
namespace: str = "",
|
|
559
|
+
context: str = "",
|
|
560
|
+
label_selector: str = ""
|
|
561
|
+
) -> Dict[str, Any]:
|
|
562
|
+
"""List CertificateRequests.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
namespace: Filter by namespace (empty for all namespaces)
|
|
566
|
+
context: Kubernetes context to use (optional)
|
|
567
|
+
label_selector: Label selector to filter requests
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
List of certificate requests with their status
|
|
571
|
+
"""
|
|
572
|
+
if not crd_exists(CERTIFICATE_REQUEST_CRD, context):
|
|
573
|
+
return {
|
|
574
|
+
"success": False,
|
|
575
|
+
"error": "CertificateRequest CRD not found"
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
requests = []
|
|
579
|
+
for item in _get_resources("certificaterequests.cert-manager.io", namespace, context, label_selector):
|
|
580
|
+
status = item.get("status", {})
|
|
581
|
+
conditions = status.get("conditions", [])
|
|
582
|
+
ready_cond = _get_condition(conditions, "Ready")
|
|
583
|
+
approved_cond = _get_condition(conditions, "Approved")
|
|
584
|
+
spec = item.get("spec", {})
|
|
585
|
+
|
|
586
|
+
requests.append({
|
|
587
|
+
"name": item["metadata"]["name"],
|
|
588
|
+
"namespace": item["metadata"]["namespace"],
|
|
589
|
+
"ready": ready_cond.get("status") == "True" if ready_cond else False,
|
|
590
|
+
"approved": approved_cond.get("status") == "True" if approved_cond else False,
|
|
591
|
+
"status": ready_cond.get("reason", "Unknown") if ready_cond else "Unknown",
|
|
592
|
+
"message": ready_cond.get("message", "") if ready_cond else "",
|
|
593
|
+
"issuer": spec.get("issuerRef", {}).get("name", ""),
|
|
594
|
+
"issuer_kind": spec.get("issuerRef", {}).get("kind", ""),
|
|
595
|
+
"created": item["metadata"].get("creationTimestamp", ""),
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
"context": context or "current",
|
|
600
|
+
"total": len(requests),
|
|
601
|
+
"requests": requests,
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def certs_detect(context: str = "") -> Dict[str, Any]:
|
|
606
|
+
"""Detect if cert-manager is installed and its components.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
context: Kubernetes context to use (optional)
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
Detection results for cert-manager
|
|
613
|
+
"""
|
|
614
|
+
return {
|
|
615
|
+
"context": context or "current",
|
|
616
|
+
"installed": crd_exists(CERTIFICATE_CRD, context),
|
|
617
|
+
"crds": {
|
|
618
|
+
"certificates": crd_exists(CERTIFICATE_CRD, context),
|
|
619
|
+
"issuers": crd_exists(ISSUER_CRD, context),
|
|
620
|
+
"clusterissuers": crd_exists(CLUSTER_ISSUER_CRD, context),
|
|
621
|
+
"certificaterequests": crd_exists(CERTIFICATE_REQUEST_CRD, context),
|
|
622
|
+
"orders": crd_exists(ORDER_CRD, context),
|
|
623
|
+
"challenges": crd_exists(CHALLENGE_CRD, context),
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def register_certs_tools(mcp: FastMCP, non_destructive: bool = False):
|
|
629
|
+
"""Register cert-manager tools with the MCP server."""
|
|
630
|
+
|
|
631
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
632
|
+
def certs_list_tool(
|
|
633
|
+
namespace: str = "",
|
|
634
|
+
context: str = "",
|
|
635
|
+
label_selector: str = ""
|
|
636
|
+
) -> str:
|
|
637
|
+
"""List cert-manager certificates with status."""
|
|
638
|
+
return json.dumps(certs_list(namespace, context, label_selector), indent=2)
|
|
639
|
+
|
|
640
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
641
|
+
def certs_get_tool(
|
|
642
|
+
name: str,
|
|
643
|
+
namespace: str,
|
|
644
|
+
context: str = ""
|
|
645
|
+
) -> str:
|
|
646
|
+
"""Get detailed information about a certificate."""
|
|
647
|
+
return json.dumps(certs_get(name, namespace, context), indent=2)
|
|
648
|
+
|
|
649
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
650
|
+
def certs_issuers_list_tool(
|
|
651
|
+
namespace: str = "",
|
|
652
|
+
context: str = "",
|
|
653
|
+
include_cluster_issuers: bool = True
|
|
654
|
+
) -> str:
|
|
655
|
+
"""List cert-manager Issuers and ClusterIssuers."""
|
|
656
|
+
return json.dumps(certs_issuers_list(namespace, context, include_cluster_issuers), indent=2)
|
|
657
|
+
|
|
658
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
659
|
+
def certs_issuer_get_tool(
|
|
660
|
+
name: str,
|
|
661
|
+
namespace: str = "",
|
|
662
|
+
kind: str = "Issuer",
|
|
663
|
+
context: str = ""
|
|
664
|
+
) -> str:
|
|
665
|
+
"""Get detailed information about an Issuer or ClusterIssuer."""
|
|
666
|
+
return json.dumps(certs_issuer_get(name, namespace, kind, context), indent=2)
|
|
667
|
+
|
|
668
|
+
@mcp.tool()
|
|
669
|
+
def certs_renew_tool(
|
|
670
|
+
name: str,
|
|
671
|
+
namespace: str,
|
|
672
|
+
context: str = ""
|
|
673
|
+
) -> str:
|
|
674
|
+
"""Trigger certificate renewal."""
|
|
675
|
+
if non_destructive:
|
|
676
|
+
return json.dumps({"success": False, "error": "Operation blocked: non-destructive mode"})
|
|
677
|
+
return json.dumps(certs_renew(name, namespace, context), indent=2)
|
|
678
|
+
|
|
679
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
680
|
+
def certs_status_explain_tool(
|
|
681
|
+
name: str,
|
|
682
|
+
namespace: str,
|
|
683
|
+
context: str = ""
|
|
684
|
+
) -> str:
|
|
685
|
+
"""Explain certificate status with diagnosis and recommendations."""
|
|
686
|
+
return json.dumps(certs_status_explain(name, namespace, context), indent=2)
|
|
687
|
+
|
|
688
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
689
|
+
def certs_challenges_list_tool(
|
|
690
|
+
namespace: str = "",
|
|
691
|
+
context: str = "",
|
|
692
|
+
label_selector: str = ""
|
|
693
|
+
) -> str:
|
|
694
|
+
"""List ACME challenges for debugging certificate issuance."""
|
|
695
|
+
return json.dumps(certs_challenges_list(namespace, context, label_selector), indent=2)
|
|
696
|
+
|
|
697
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
698
|
+
def certs_requests_list_tool(
|
|
699
|
+
namespace: str = "",
|
|
700
|
+
context: str = "",
|
|
701
|
+
label_selector: str = ""
|
|
702
|
+
) -> str:
|
|
703
|
+
"""List CertificateRequests."""
|
|
704
|
+
return json.dumps(certs_requests_list(namespace, context, label_selector), indent=2)
|
|
705
|
+
|
|
706
|
+
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
707
|
+
def certs_detect_tool(context: str = "") -> str:
|
|
708
|
+
"""Detect if cert-manager is installed and its components."""
|
|
709
|
+
return json.dumps(certs_detect(context), indent=2)
|