luxyai
§ 00 · Source-level Observatory

LuxyAI Flawless SRE 控制面

本页聚焦 Kubernetes MCP server 如何把集群 API 包装成 namespace-aware、schema-aware、dry-run-first 的 Agent 工具层。

★ 556
GitHub Stars
46K
LOC
164
Files
2026-07-14
Last checked
Alert → Evidence → Plan → Approval → Execute → Verify
§ 00 · DETAIL

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 追踪真实调用路径。每个代码块都保留行号,后续风险判断也绑定到具体文件。

§ 01 · SOURCE

认证与 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 互相印证:前者展示实现,后者说明它在整体执行链里的位置。

src/mcp_servers/k8s_mcp_server.py · L40-119Python
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())
§ 02 · SOURCE

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 互相印证:前者展示实现,后者说明它在整体执行链里的位置。

src/mcp_servers/k8s_mcp_server.py · L153-205Python
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        )
§ 03 · SOURCE

诊断工具

▸诊断工具 的源码证据

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 互相印证:前者展示实现,后者说明它在整体执行链里的位置。

src/mcp_servers/k8s_mcp_server.py · L620-765Python
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
§ 04 · SOURCE

写操作 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 互相印证:前者展示实现,后者说明它在整体执行链里的位置。

src/mcp_servers/k8s_mcp_server.py · L1160-1309Python
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"}:
Generated · 2026-07-14Source · William-Lu-stack/LuxyAI← 返回 Trending 索引