LuxyAI Flawless SRE 控制面
本页聚焦 Kubernetes MCP server 如何把集群 API 包装成 namespace-aware、schema-aware、dry-run-first 的 Agent 工具层。
Kubernetes MCP guardrails
MCP server 的关键是把危险面收窄:认证路径、namespace 白名单、manifest 校验、patch 校验和 dry-run 都在工具层完成。 关键证据集中在 mcp_servers/k8s_mcp_server.py:40、mcp_servers/k8s_mcp_server.py:94、mcp_servers/k8s_mcp_server.py:153、mcp_servers/k8s_mcp_server.py:175、mcp_servers/k8s_mcp_server.py:196、mcp_servers/k8s_mcp_server.py:238,这些行号分别覆盖入口、分支、校验和测试,能帮助读者快速回到源码确认判断。
▸导入链与契约
本页不是 README 摘要,而是围绕 mcp_servers/k8s_mcp_server.py:291、mcp_servers/k8s_mcp_server.py:374、mcp_servers/k8s_mcp_server.py:427、mcp_servers/k8s_mcp_server.py:523 追踪真实调用路径。每个代码块都保留行号,后续风险判断也绑定到具体文件。
认证与 kubeconfig 兼容
▸认证与 kubeconfig 兼容 的源码证据
集群内优先 ServiceAccount,开发环境才回退 kubeconfig;TLS 和 token 的处理都集中在少量函数里。 这一段和 mcp_servers/k8s_mcp_server.py:238、mcp_servers/k8s_mcp_server.py:291、mcp_servers/k8s_mcp_server.py:374、mcp_servers/k8s_mcp_server.py:427、mcp_servers/k8s_mcp_server.py:523 互相印证:前者展示实现,后者说明它在整体执行链里的位置。
40def _load_kubeconfig_access() -> tuple[str, str, ssl.SSLContext]: 41 """加载本地 kubeconfig,用于演示机/开发机直接运行 MCP。 42 43 生产容器内优先使用 ServiceAccount。只有不存在集群内 token 时,才会进入 44 kubeconfig 兼容模式,避免影响正式 K8s 部署。 45 """ 46 import yaml 47 48 kubeconfig = os.getenv("KUBECONFIG") or os.path.expanduser("~/.kube/config") 49 with open(kubeconfig, encoding="utf-8") as f: 50 config = yaml.safe_load(f) or {} 51 52 current_context = os.getenv("KUBE_CONTEXT") or config.get("current-context") 53 contexts = {item.get("name"): item.get("context") or {} for item in config.get("contexts", [])} 54 clusters = {item.get("name"): item.get("cluster") or {} for item in config.get("clusters", [])} 55 users = {item.get("name"): item.get("user") or {} for item in config.get("users", [])} 56 context = contexts.get(current_context) or {} 57 cluster = clusters.get(context.get("cluster")) or {} 58 user = users.get(context.get("user")) or {} 59 host = str(cluster.get("server") or "").rstrip("/") 60 if not host: 61 raise RuntimeError(f"kubeconfig {kubeconfig} does not contain a server for context {current_context}") 62 63 if cluster.get("insecure-skip-tls-verify"): 64 ctx = ssl._create_unverified_context() 65 else: 66 ca_data = _decode_kubeconfig_blob(cluster.get("certificate-authority-data")) 67 ca_file = cluster.get("certificate-authority") 68 if ca_data: 69 ca_file = _write_temp_pem(ca_data, ".ca.crt") 70 ctx = ssl.create_default_context(cafile=ca_file) 71 72 cert_data = _decode_kubeconfig_blob(user.get("client-certificate-data")) 73 key_data = _decode_kubeconfig_blob(user.get("client-key-data")) 74 cert_file = user.get("client-certificate") 75 key_file = user.get("client-key") 76 if cert_data: 77 cert_file = _write_temp_pem(cert_data, ".client.crt") 78 if key_data: 79 key_file = _write_temp_pem(key_data, ".client.key") 80 if cert_file and key_file: 81 ctx.load_cert_chain(certfile=cert_file, keyfile=key_file) 82 83 return host, str(user.get("token") or ""), ctx 84 85 86if os.path.exists(_TOKEN_FILE): 87 with open(_TOKEN_FILE) as f: 88 _TOKEN = f.read().strip() 89 _CTX = ssl.create_default_context(cafile=_CA_FILE) 90else: 91 _HOST, _TOKEN, _CTX = _load_kubeconfig_access() 92 93 94def _auth_headers() -> dict: 95 return {"Authorization": f"Bearer {_TOKEN}"} if _TOKEN else {} 96 97def _k8s_get(path: str) -> dict: 98 """发送 GET 请求并返回 JSON 解析结果""" 99 req = Request( 100 f"{_HOST}{path}", 101 headers=_auth_headers() 102 ) 103 with urlopen(req, context=_CTX) as resp: 104 return json.loads(resp.read().decode()) 105 106def _k8s_patch(path: str, body: dict) -> dict: 107 """发送 PATCH 请求(JSON)并返回 JSON""" 108 data = json.dumps(body).encode() 109 req = Request( 110 f"{_HOST}{path}", 111 data=data, 112 headers={ 113 **_auth_headers(), 114 "Content-Type": "application/strategic-merge-patch+json" 115 }, 116 method="PATCH" 117 ) 118 with urlopen(req, context=_CTX) as resp: 119 return json.loads(resp.read().decode())
Manifest 和 patch schema
▸Manifest 和 patch schema 的源码证据
ConfigMap、ServiceAccount 和 workload patch 都有字段白名单,避免 LLM 把任意 YAML 直接写进集群。 这一段和 mcp_servers/k8s_mcp_server.py:620、mcp_servers/k8s_mcp_server.py:637、mcp_servers/k8s_mcp_server.py:664、mcp_servers/k8s_mcp_server.py:746、mcp_servers/k8s_mcp_server.py:904 互相印证:前者展示实现,后者说明它在整体执行链里的位置。
153def _validate_configmap_manifest(manifest: dict, namespace: str) -> tuple[bool, str]: 154 if not isinstance(manifest, dict) or manifest.get("apiVersion") != "v1" or manifest.get("kind") != "ConfigMap": 155 return False, "manifest must be v1 ConfigMap" 156 metadata = manifest.get("metadata") or {} 157 if metadata.get("namespace") != namespace: 158 return False, "ConfigMap namespace must match the approved namespace" 159 if not _valid_k8s_name(metadata.get("name", "")): 160 return False, "metadata.name is not a valid Kubernetes name" 161 illegal = set(manifest) - {"apiVersion", "kind", "metadata", "data", "binaryData", "immutable"} 162 if illegal: 163 return False, f"unsupported ConfigMap fields: {sorted(illegal)}" 164 data = manifest.get("data") or {} 165 binary = manifest.get("binaryData") or {} 166 if not isinstance(data, dict) or not isinstance(binary, dict): 167 return False, "ConfigMap data and binaryData must be objects" 168 if not data and not binary: 169 return False, "ConfigMap requires data or binaryData" 170 if any(not isinstance(k, str) or not isinstance(v, str) for k, v in data.items()): 171 return False, "ConfigMap data must be string key/value pairs" 172 return True, "" 173 174 175def _validate_service_account_patch(patch: dict) -> tuple[bool, str]: 176 if not isinstance(patch, dict) or not patch: 177 return False, "ServiceAccount patch must be a non-empty object" 178 if set(patch) - {"imagePullSecrets"}: 179 return False, "ServiceAccount patch only permits imagePullSecrets" 180 secrets = patch.get("imagePullSecrets") or [] 181 if not isinstance(secrets, list) or not secrets: 182 return False, "imagePullSecrets must be a non-empty list" 183 if any(not isinstance(item, dict) or set(item) - {"name"} or not item.get("name") for item in secrets): 184 return False, "imagePullSecrets must be a list of {name}" 185 return True, "" 186 187# ---------------------------------------------------------------------- 188def _parse_allowed_namespaces() -> list[str]: 189 return [ 190 item.strip() 191 for item in os.getenv("ALLOWED_NAMESPACES", "default").split(",") 192 if item.strip() 193 ] 194 195 196ALLOWED_NAMESPACES = _parse_allowed_namespaces() 197 198def check_namespace(namespace: str): 199 if "all" in ALLOWED_NAMESPACES or "*" in ALLOWED_NAMESPACES: 200 return 201 if namespace not in ALLOWED_NAMESPACES: 202 raise PermissionError( 203 f"namespace {namespace} is not allowed by app guard " 204 f"ALLOWED_NAMESPACES={','.join(ALLOWED_NAMESPACES)}" 205 )
诊断工具
▸诊断工具 的源码证据
list_pods、events、logs 和 diagnostics 构成证据采集通道,为 remediation engine 提供结构化上下文。 这一段和 mcp_servers/k8s_mcp_server.py:1023、mcp_servers/k8s_mcp_server.py:1160、mcp_servers/k8s_mcp_server.py:1254、mcp_servers/k8s_mcp_server.py:1355、mcp_servers/k8s_mcp_server.py:1371 互相印证:前者展示实现,后者说明它在整体执行链里的位置。
620@mcp.tool() 621def list_pods(namespace: str = "default") -> dict: 622 """List pods in a Kubernetes namespace.""" 623 try: 624 data = _k8s_get(f"/api/v1/namespaces/{namespace}/pods") 625 except Exception as e: 626 return {"error": f"Failed to list pods in namespace {namespace}: {str(e)}"} 627 628 pods = [] 629 for item in data["items"]: 630 pod = _pod_identity(item) 631 pod["classification"] = classify_pod(pod) 632 pod["workload"] = _workload_from_pod(pod) 633 pods.append(pod) 634 return {"namespace": namespace, "pods": pods} 635 636@mcp.tool() 637def get_pod_events(namespace: str, pod_name: str) -> dict: 638 """Get Kubernetes events for a specific pod.""" 639 try: 640 field_selector = f"involvedObject.name={pod_name}" 641 data = _k8s_get( 642 f"/api/v1/namespaces/{namespace}/events?fieldSelector={field_selector}" 643 ) 644 except Exception as e: 645 return {"error": str(e)} 646 647 return { 648 "namespace": namespace, 649 "pod_name": pod_name, 650 "events": [ 651 { 652 "reason": e["reason"], 653 "message": e["message"], 654 "type": e["type"], 655 "count": e.get("count"), 656 "first_timestamp": e.get("firstTimestamp"), 657 "last_timestamp": e.get("lastTimestamp"), 658 } 659 for e in data.get("items", []) 660 ], 661 } 662 663@mcp.tool() 664def get_pod_logs( 665 namespace: str, 666 pod_name: str, 667 container: Optional[str] = None, 668 tail_lines: int = 200, 669 previous: bool = False, 670) -> dict: 671 """Get recent pod logs.""" 672 try: 673 query = {"tailLines": max(1, min(int(tail_lines), 500)), "previous": str(bool(previous)).lower()} 674 if container: 675 query["container"] = container 676 path = f"/api/v1/namespaces/{quote(namespace, safe='')}/pods/{quote(pod_name, safe='')}/log?{urlencode(query)}" 677 # 日志接口返回纯文本,直接读取 678 req = Request( 679 f"{_HOST}{path}", 680 headers={"Authorization": f"Bearer {_TOKEN}"} 681 ) 682 with urlopen(req, context=_CTX) as resp: 683 logs = resp.read().decode() 684 return { 685 "namespace": namespace, 686 "pod_name": pod_name, 687 "container": container, 688 "previous": previous, 689 "logs": logs, 690 } 691 except Exception as e: 692 return {"error": str(e)} 693 694 695def _workload_api_path(workload_type: str, namespace: str, workload_name: str) -> str: 696 resource = WORKLOAD_RESOURCE_MAP.get(str(workload_type).lower()) 697 if not resource: 698 raise ValueError("workload_type must be Deployment, StatefulSet, or DaemonSet") 699 return f"/apis/apps/v1/namespaces/{quote(namespace, safe='')}/{resource}/{quote(workload_name, safe='')}" 700 701 702def _safe_workload_evidence(raw: dict) -> dict: 703 """Keep operational fields while dropping env values and sensitive data.""" 704 metadata = raw.get("metadata") or {} 705 spec = raw.get("spec") or {} 706 template = spec.get("template") or {} 707 pod_spec = template.get("spec") or {} 708 containers = [] 709 for container in pod_spec.get("containers", []) or []: 710 env_refs = [] 711 for env in container.get("env", []) or []: 712 value_from = env.get("valueFrom") or {} 713 ref_type = next(iter(value_from.keys()), "literal-present" if "value" in env else "") 714 env_refs.append({"name": env.get("name"), "source": ref_type}) 715 containers.append({ 716 "name": container.get("name"), "image": container.get("image"), 717 "resources": container.get("resources", {}), "livenessProbe": container.get("livenessProbe"), 718 "readinessProbe": container.get("readinessProbe"), "startupProbe": container.get("startupProbe"), 719 "securityContext": container.get("securityContext", {}), "volumeMounts": container.get("volumeMounts", []), 720 "envReferences": env_refs, 721 "envFromReferences": [ 722 { 723 "configMap": (item.get("configMapRef") or {}).get("name"), 724 "secret": (item.get("secretRef") or {}).get("name"), 725 } 726 for item in container.get("envFrom", []) or [] 727 ], 728 }) 729 return { 730 "apiVersion": raw.get("apiVersion"), "kind": raw.get("kind"), 731 "metadata": {"name": metadata.get("name"), "namespace": metadata.get("namespace"), "generation": metadata.get("generation")}, 732 "spec": { 733 "replicas": spec.get("replicas"), "strategy": spec.get("strategy"), 734 "template": {"spec": { 735 "containers": containers, "volumes": pod_spec.get("volumes", []), 736 "securityContext": pod_spec.get("securityContext", {}), "imagePullSecrets": pod_spec.get("imagePullSecrets", []), 737 "nodeSelector": pod_spec.get("nodeSelector", {}), "tolerations": pod_spec.get("tolerations", []), 738 "affinity": pod_spec.get("affinity", {}), "topologySpreadConstraints": pod_spec.get("topologySpreadConstraints", []), 739 }}, 740 }, 741 "status": raw.get("status", {}), 742 } 743 744 745@mcp.tool() 746def get_pod_diagnostics(namespace: str, pod_name: str, tail_lines: int = 160) -> dict: 747 """Collect bounded, read-only evidence needed for root-cause planning. 748 749 Secret values are never returned. ConfigMap/Secret references are reported 750 only by name and existence so the planner cannot leak credential material. 751 """ 752 pod_path = f"/api/v1/namespaces/{quote(namespace, safe='')}/pods/{quote(pod_name, safe='')}" 753 try: 754 raw_pod = _k8s_get(pod_path) 755 except Exception as exc: 756 return {"error": f"failed to read pod/{pod_name}: {exc}"} 757 pod = _pod_identity(raw_pod) 758 pod["classification"] = classify_pod(pod) 759 pod["workload"] = _workload_from_pod(pod) 760 events = get_pod_events(namespace, pod_name).get("events", []) 761 logs: dict[str, dict] = {} 762 for container in pod.get("containers", [])[:8]: 763 name = container.get("name") 764 if not name: 765 continue
写操作 guardrails
▸写操作 guardrails 的源码证据
patch_workload 等写工具默认 dry_run,并在提交前校验 workload patch、namespace 和风险参数。 这一段和 mcp_servers/k8s_mcp_server.py:1427、mcp_servers/k8s_mcp_server.py:1524、agents/sre_graph.py:69、agents/sre_graph.py:84、agents/sre_graph.py:103 互相印证:前者展示实现,后者说明它在整体执行链里的位置。
1160def _validate_workload_patch(patch: dict, allow_volume_patch: bool = False) -> tuple[bool, str]: 1161 if not isinstance(patch, dict): 1162 return False, "patch must be a JSON object" 1163 spec = patch.get("spec") 1164 if not isinstance(spec, dict): 1165 return False, "patch must contain spec" 1166 illegal_spec = set(spec) - {"replicas", "template"} 1167 if illegal_spec: 1168 return False, f"unsupported spec fields: {sorted(illegal_spec)}" 1169 if "replicas" in spec: 1170 try: 1171 replicas = int(spec["replicas"]) 1172 except Exception: 1173 return False, "spec.replicas must be integer" 1174 max_replicas = int(os.getenv("MAX_PATCH_REPLICAS", "20")) 1175 if replicas < 0 or replicas > max_replicas: 1176 return False, f"spec.replicas must be between 0 and {max_replicas}" 1177 template = spec.get("template") or {} 1178 if template: 1179 if not isinstance(template, dict): 1180 return False, "spec.template must be object" 1181 illegal_template = set(template) - {"metadata", "spec"} 1182 if illegal_template: 1183 return False, f"unsupported template fields: {sorted(illegal_template)}" 1184 metadata = template.get("metadata") or {} 1185 if metadata and (not isinstance(metadata, dict) or set(metadata) - {"annotations"}): 1186 return False, "only template.metadata.annotations can be patched" 1187 annotations = metadata.get("annotations") or {} 1188 if annotations and not isinstance(annotations, dict): 1189 return False, "template.metadata.annotations must be object" 1190 pod_spec = template.get("spec") or {} 1191 if pod_spec: 1192 if not isinstance(pod_spec, dict): 1193 return False, "template.spec must be object" 1194 allowed_pod_spec = {"containers", "securityContext", "imagePullSecrets", "nodeSelector", "tolerations", "affinity"} 1195 if allow_volume_patch: 1196 allowed_pod_spec.add("volumes") 1197 illegal_pod_spec = set(pod_spec) - allowed_pod_spec 1198 if illegal_pod_spec: 1199 return False, f"unsupported template.spec fields: {sorted(illegal_pod_spec)}" 1200 pod_sc = pod_spec.get("securityContext") or {} 1201 if pod_sc and set(pod_sc) - {"fsGroup", "fsGroupChangePolicy", "runAsUser", "runAsGroup", "runAsNonRoot", "supplementalGroups"}: 1202 return False, "template.spec.securityContext contains unsupported fields" 1203 image_pull_secrets = pod_spec.get("imagePullSecrets") or [] 1204 if image_pull_secrets: 1205 if not isinstance(image_pull_secrets, list) or any(not isinstance(x, dict) or set(x) - {"name"} or not x.get("name") for x in image_pull_secrets): 1206 return False, "template.spec.imagePullSecrets must be a list of {name}" 1207 node_selector = pod_spec.get("nodeSelector") or {} 1208 if node_selector and not isinstance(node_selector, dict): 1209 return False, "template.spec.nodeSelector must be object" 1210 tolerations = pod_spec.get("tolerations") or [] 1211 if tolerations and not isinstance(tolerations, list): 1212 return False, "template.spec.tolerations must be list" 1213 if tolerations and any(not isinstance(item, dict) for item in tolerations): 1214 return False, "template.spec.tolerations entries must be objects" 1215 affinity = pod_spec.get("affinity") or {} 1216 if affinity and not isinstance(affinity, dict): 1217 return False, "template.spec.affinity must be object" 1218 volumes = pod_spec.get("volumes") or [] 1219 if volumes: 1220 if not allow_volume_patch: 1221 return False, "template.spec.volumes requires high_risk_volume_patch=true" 1222 if not isinstance(volumes, list): 1223 return False, "template.spec.volumes must be list" 1224 for volume in volumes: 1225 if not isinstance(volume, dict): 1226 return False, "volume patch item must be object" 1227 if set(volume) - {"name", "persistentVolumeClaim"}: 1228 return False, "volume patch only permits name and persistentVolumeClaim" 1229 claim = volume.get("persistentVolumeClaim") or {} 1230 if not volume.get("name") or not isinstance(claim, dict) or not claim.get("claimName"): 1231 return False, "volume patch requires name and persistentVolumeClaim.claimName" 1232 containers = pod_spec.get("containers") or [] 1233 if containers and not isinstance(containers, list): 1234 return False, "template.spec.containers must be list" 1235 for container in containers: 1236 if not isinstance(container, dict): 1237 return False, "container patch item must be object" 1238 illegal_container = set(container) - { 1239 "name", "image", "resources", "env", "livenessProbe", "readinessProbe", "startupProbe", "securityContext" 1240 } 1241 if illegal_container: 1242 return False, f"unsupported container fields: {sorted(illegal_container)}" 1243 if not container.get("name"): 1244 return False, "container patch must include name" 1245 if "image" in container and not str(container.get("image") or "").strip(): 1246 return False, "container image must be a non-empty immutable reference" 1247 csc = container.get("securityContext") or {} 1248 if csc and set(csc) - {"runAsUser", "runAsGroup", "runAsNonRoot", "allowPrivilegeEscalation", "readOnlyRootFilesystem"}: 1249 return False, "container.securityContext contains unsupported fields" 1250 return True, "" 1251 1252 1253@mcp.tool() 1254def patch_workload( 1255 namespace: str, 1256 workload_type: str, 1257 workload_name: str, 1258 patch: dict, 1259 dry_run: bool = True, 1260 high_risk_volume_patch: bool = False, 1261) -> dict: 1262 """Patch a common apps/v1 workload using Kubernetes merge patch. 1263 1264 Supported workload_type: Deployment, StatefulSet, DaemonSet. 1265 This intentionally accepts a prepared merge-patch object only; the caller 1266 must show it to a human before dry_run=False in production workflows. 1267 """ 1268 check_namespace(namespace) 1269 resource = WORKLOAD_RESOURCE_MAP.get(str(workload_type).lower()) 1270 if not resource: 1271 raise ValueError("workload_type must be Deployment, StatefulSet, or DaemonSet") 1272 if not isinstance(patch, dict) or not patch: 1273 raise ValueError("patch must be a non-empty object") 1274 valid, reason = _validate_workload_patch(patch, allow_volume_patch=high_risk_volume_patch) 1275 if not valid: 1276 raise ValueError(f"patch rejected by safety policy: {reason}") 1277 if dry_run: 1278 return { 1279 "action": "patch_workload", 1280 "namespace": namespace, 1281 "workload_type": workload_type, 1282 "workload_name": workload_name, 1283 "patch": patch, 1284 "dry_run": True, 1285 "message": "Dry run only. No workload patched.", 1286 } 1287 try: 1288 result = _k8s_patch( 1289 f"/apis/apps/v1/namespaces/{namespace}/{resource}/{workload_name}", 1290 patch, 1291 ) 1292 return { 1293 "action": "patch_workload", 1294 "namespace": namespace, 1295 "workload_type": workload_type, 1296 "workload_name": workload_name, 1297 "patch": patch, 1298 "dry_run": False, 1299 "message": f"{workload_type}/{workload_name} patched.", 1300 "resource_version": result.get("metadata", {}).get("resourceVersion"), 1301 } 1302 except Exception as e: 1303 return {"error": str(e)} 1304 1305 1306def _validate_create_workload_manifest(manifest: dict) -> tuple[bool, str]: 1307 if not isinstance(manifest, dict) or manifest.get("apiVersion") != "apps/v1": 1308 return False, "manifest apiVersion must be apps/v1" 1309 if manifest.get("kind") not in {"Deployment", "StatefulSet", "DaemonSet"}: