luxyai
§ 00 · Source-level Observatory

LuxyAI Flawless SRE 控制面

LuxyAI/Flawless 是一个面向 Kubernetes 和云基础设施的 AgenticOps 控制面:它把告警、证据、拓扑、人工授权、受控修复和恢复验证串成可审计闭环。

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

它解决什么,凭什么值得拆

LuxyAI 的公开仓库在 README 里已经改名为 Flawless,源码目标是 Kubernetes/cloud 场景下的 AgenticOps 控制面。它不是只把告警丢给 LLM,而是把动作目录、证据评分、MCP 只读探针、审批和执行后验证拆开:agents/remediation_engine.py:16、agents/remediation_engine.py:147、agents/remediation_engine.py:220、agents/remediation_engine.py:258、agents/remediation_engine.py:415 这些入口说明模型只做规划,稳定动作、证据权重和执行边界仍由平台代码维护。

真正值得拆的是它把 SRE workflow 写成可审计闭环。agents/sre_graph.py:69 定义状态,agents/sre_graph.py:158 建 trace,backend/app/application.py:7623 在失败后根据新证据重规划;这意味着“修复失败”不会只返回一句抱歉,而会进入新的证据分支。

▸核心数据

架构模式
SRE Graph + EvidenceRunbookEngine + Kubernetes MCP server + FastAPI 控制面。
核心抽象
Action catalog、runbook hypotheses、expert probes、runtime evidence、change fingerprint。
性能/精度数据
项目未给 benchmark;测试覆盖更强调故障分类、权限、patch 合法性和重规划去重。
下游依赖
Kubernetes API、MCP、LangFuse/observability、OpenAI-compatible LLM gateway、Helm/RBAC。
读源码的判断:它的亮点不是“AI 可以执行 kubectl”,而是 agents/remediation_engine.py:16 到 agents/remediation_engine.py:145 先定义允许动作,再由 mcp_servers/k8s_mcp_server.py:1160 做 patch schema 限制,最后由 tests/test_remediation_engine.py:186 验证高风险动作需要二次确认。
§ 01 · ARCHITECTURE

控制面拓扑:证据先行,动作后置

主路径可以概括为 Alert 进入 agents/sre_graph.py:69 的状态机,scope 由 agents/sre_graph.py:88 归一化,Pod 证据由 agents/sre_graph.py:103 和 agents/sre_graph.py:114 筛选,然后进入 runbook scoring 与 remediation plan。这个路径的设计避免了“模型看到一段日志就直接执行”的常见风险。

▸系统拓扑

Prometheus/Rancher Alert\n → SRE Graph state\n → MCP evidence probes\n → EvidenceRunbookEngine scoring\n → guarded remediation preview\n → approval / dry-run / execute\n → verify symptom and persist lineage

▸模块清单

模块边界比较清楚:agents/remediation_engine.py:415 负责证据到 runbook 的排序,mcp_servers/k8s_mcp_server.py:620 负责 Kubernetes 读写工具暴露,backend/app/application.py:7623 负责运行时失败后的重规划,tests/test_remediation_engine.py:37 则把分类和执行约束变成回归测试。

EvidenceRunbookEngine
从 alert、diagnosis、pod_status、events、logs 五类来源加权,输出 runbook_id、confidence、diagnostics 和 success criteria。
K8s MCP Server
在集群内优先 ServiceAccount,开发机回退 kubeconfig,所有写操作都有 dry_run 和局部 schema 校验。
Runtime Replan
执行失败后把 runtime evidence、失败指纹和动作目录重新喂给 planner,禁止重复同一失败动作。

▸动作目录是安全边界

动作目录在 agents/remediation_engine.py:16 开始,给每个动作绑定 risk、auto_allowed、rollback 和 description;后面的 backend/app/application.py:7708 又根据已证明根因收窄 allowed actions。两层约束让 LLM 即使提出动作,也必须落在平台能解释和审计的 vocabulary 内。

src/agents/remediation_engine.py · L16-45Python
16ACTION_CATALOG: dict[str, dict[str, Any]] = {
17    "create_workload": {
18        "risk": "high", "auto_allowed": False, "rollback": "delete the newly created workload after approval",
19        "description": "Create one validated apps/v1 Deployment, StatefulSet or DaemonSet from the release gate.",
20    },
21    "patch_workload": {
22        "risk": "medium", "auto_allowed": True, "rollback": "restore the previous workload template",
23        "description": "Patch Deployment, StatefulSet or DaemonSet pod template/replicas.",
24    },
25    "restart": {
26        "risk": "medium", "auto_allowed": True, "rollback": "not applicable; rollout is convergent",
27        "description": "Trigger a controlled rolling restart.",
28    },
29    "scale_out": {
30        "risk": "medium", "auto_allowed": True, "rollback": "restore the previous replica count",
31        "description": "Increase workload replicas within the configured cap.",
32    },
33    "recreate_pod": {
34        "risk": "medium", "auto_allowed": True, "rollback": "controller recreates the pod from the unchanged template",
35        "description": "Delete one controller-owned unhealthy pod for clean rescheduling.",
36    },
37    "patch_hpa": {
38        "risk": "medium", "auto_allowed": True, "rollback": "restore previous HPA min/max replicas",
39        "description": "Adjust HPA bounds without changing its metric semantics.",
40    },
41    "expand_pvc": {
42        "risk": "high", "auto_allowed": False, "rollback": "volume expansion is normally irreversible",
43        "description": "Expand a bound PVC when its StorageClass supports expansion.",
44    },
45    "create_pvc": {
深度阅读 · 证据驱动修复引擎
§ 02 · REMEDIATION

Runbook scoring 如何避免重启万能药

▸证据源分层计分

agents/remediation_engine.py:415 的评分函数把用户文本、诊断信号、Pod 状态、Events 和 logs 分成不同权重,其中 logs 权重最高。这比简单关键词匹配更稳,因为 agents/remediation_engine.py:491、agents/remediation_engine.py:498、agents/remediation_engine.py:512、agents/remediation_engine.py:526 会把 PVC、OOM、镜像架构和权限错误提升到确定性根因。

src/agents/remediation_engine.py · L415-458Python
415def score_root_causes(alert: dict, diagnosis: dict, context: dict) -> list[dict[str, Any]]:
416    """Rank runbooks with a transparent weighted evidence score.
417
418    Alert/user text is weak evidence, status/Events are medium evidence, and
419    logs/last termination details are strong evidence. The sigmoid keeps the
420    reported confidence stable as the amount of evidence grows.
421    """
422    sources = [
423        ("alert", _flatten_text(alert), 0.7),
424        ("diagnosis", _flatten_text(diagnosis.get("root_cause"), diagnosis.get("signals")), 0.9),
425        ("pod_status", _flatten_text(context.get("pods"), context.get("pod")), 1.25),
426        ("events", _flatten_text(context.get("events")), 1.45),
427        ("logs", _flatten_text(context.get("logs"), context.get("previous_logs"), context.get("diagnostics")), 1.8),
428    ]
429    decisive_text = _flatten_text(
430        alert,
431        diagnosis,
432        context.get("events"),
433        context.get("storage"),
434        context.get("pod"),
435        context.get("pods"),
436        context.get("logs"),
437        context.get("previous_logs"),
438    )
439    storage_binding_proven = any(term in decisive_text for term in (
440        "unbound immediate persistentvolumeclaims",
441        "no persistent volumes available",
442        "persistentvolumeclaim is not bound",
443        "persistentvolumeclaim pending",
444    ))
445    oom_proven = any(term in decisive_text for term in (
446        "oomkilled",
447        "exit code 137",
448        "exitcode': 137",
449        '"exitcode": 137',
450        "last_terminated_reason': 'oomkilled",
451        '"last_terminated_reason": "oomkilled',
452    ))
453    image_arch_proven = any(term in decisive_text for term in (
454        "exec format error",
455        "no matching manifest for linux/amd64",
456        "no matching manifest for linux/arm64",
457        "image architecture",
458        "platform mismatch",

▸CrashLoop 会被强证据降级

常见系统会把 CrashLoopBackOff 当根因,LuxyAI 反过来在 agents/remediation_engine.py:505、agents/remediation_engine.py:519、agents/remediation_engine.py:533 降低 generic crash_unknown 权重,让具体证据优先。这个细节直接对应 tests/test_remediation_engine.py:127 和 tests/test_remediation_engine.py:152 的测试用例。

src/agents/remediation_engine.py · L489-558Python
489        # FailedScheduling is only the symptom here. An unbound PVC is a
490        # deterministic storage root cause and must outrank generic scheduling.
491        if runbook_id == "storage_mount" and storage_binding_proven:
492            raw_score += 3.2
493            matches.append({
494                "source": "kubernetes_storage_state",
495                "terms": ["pvc_unbound"],
496                "weight": 3.2,
497            })
498        if runbook_id == "oom" and oom_proven:
499            raw_score += 2.8
500            matches.append({
501                "source": "container_last_state",
502                "terms": ["oomkilled_or_exit_137"],
503                "weight": 2.8,
504            })
505        if runbook_id == "crash_unknown" and oom_proven:
506            raw_score = max(0.0, raw_score - 1.4)
507            matches.append({
508                "source": "container_last_state",
509                "terms": ["demoted_by_oom_evidence"],
510                "weight": -1.4,
511            })
512        if runbook_id == "image_architecture" and image_arch_proven:
513            raw_score += 2.8
514            matches.append({
515                "source": "runtime_platform_state",
516                "terms": ["exec_format_or_manifest_platform_mismatch"],
517                "weight": 2.8,
518            })
519        if runbook_id == "crash_unknown" and image_arch_proven:
520            raw_score = max(0.0, raw_score - 1.2)
521            matches.append({
522                "source": "runtime_platform_state",
523                "terms": ["demoted_by_architecture_evidence"],
524                "weight": -1.2,
525            })
526        if runbook_id == "storage_permission" and storage_permission_proven:
527            raw_score += 2.6
528            matches.append({
529                "source": "container_logs",
530                "terms": ["write_permission_denied"],
531                "weight": 2.6,
532            })
533        if runbook_id == "crash_unknown" and storage_permission_proven:
534            raw_score = max(0.0, raw_score - 1.2)
535            matches.append({
536                "source": "container_logs",
537                "terms": ["demoted_by_storage_permission_evidence"],
538                "weight": -1.2,
539            })
540        if runbook_id == "crash_unknown" and log_unavailable_proven and not storage_binding_proven:
541            raw_score += 1.4
542            matches.append({
543                "source": "log_probe",
544                "terms": ["logs_unavailable_need_diagnostic_recreate"],
545                "weight": 1.4,
546            })
547        if matches:
548            confidence = 1.0 / (1.0 + math.exp(-(raw_score - 1.65)))
549            ranked.append({
550                "id": runbook_id,
551                "title": runbook["title"],
552                "score": round(raw_score, 3),
553                "confidence": round(min(0.99, confidence), 3),
554                "matched_evidence": matches,
555                "diagnostics": list(runbook["diagnostics"]),
556                "success_criteria": list(runbook["success"]),
557            })
558    return sorted(ranked, key=lambda item: (-item["score"], item["id"]))

▸Plan 生成只选择最小变更

agents/remediation_engine.py:961 之后才开始构造 remediation plan。它会根据 runbook_id 选择 create_pvc、patch_workload、patch_service_account、rollback_workload 等受控动作,像 agents/remediation_engine.py:1011 的 OOM 分支只 patch memory,agents/remediation_engine.py:1021 的 probe 分支只复制已有 probe handler。

src/agents/remediation_engine.py · L961-1039Python
961def build_remediation_plan(alert: dict, diagnosis: dict, context: dict) -> dict[str, Any]:
962    hypotheses = score_root_causes(alert, diagnosis, context)
963    namespace, workload_type, workload_name, pod_name, pod = _target(alert, diagnosis, context)
964    primary = hypotheses[0] if hypotheses else None
965    runbook_id = primary["id"] if primary else "unknown"
966    container = _first_container(pod)
967    container_name = container.get("name", "")
968    changes: list[dict[str, Any]] = []
969    patchable = workload_name and str(workload_type).lower() in {"deployment", "statefulset", "daemonset"}
970    confidence = float((primary or {}).get("confidence") or 0.0)
971
972    def workload_patch(patch: dict, reason: str):
973        changes.append({
974            "type": "patch_workload", "namespace": namespace, "workload_type": workload_type,
975            "workload_name": workload_name, "patch": patch, "reason": reason,
976            "runbook_id": runbook_id, **ACTION_CATALOG["patch_workload"],
977        })
978
979    if runbook_id == "storage_mount":
980        issue = _first_storage_issue(context)
981        issue_text = _flatten_text(issue)
982        pvc_name = str(issue.get("pvc") or issue.get("pvc_name") or issue.get("claim") or "").strip()
983        missing_pvc = bool(
984            pvc_name
985            and (
986                issue.get("missing") is True
987                or "not found" in issue_text
988                or "404" in issue_text
989                or "does not exist" in issue_text
990            )
991        )
992        phase = str(issue.get("pvc_phase") or issue.get("phase") or "").lower()
993        if missing_pvc:
994            changes.append({
995                "type": "create_pvc", "namespace": namespace, "pvc_name": pvc_name,
996                "manifest": _pvc_manifest(namespace, pvc_name, issue),
997                "reason": "Kubernetes API 已确认 Pod 引用的 PVC 不存在;创建受存储策略约束的 PVC,并等待动态供卷后验证 Pod。",
998                "runbook_id": runbook_id, **ACTION_CATALOG["create_pvc"],
999            })
1000        elif pvc_name and phase in {"pending", "lost"}:
1001            pv_manifest = _static_pv_manifest(namespace, pvc_name, issue)
1002            if pv_manifest:
1003                changes.append({
1004                    "type": "create_pv", "namespace": namespace, "pvc_name": pvc_name,
1005                    "manifest": pv_manifest,
1006                    "reason": "Kubernetes API 已确认 PVC 未绑定,且平台配置了批准的静态存储模板;创建预绑定 PV 并验证 PVC=Bound。",
1007                    "runbook_id": runbook_id, **ACTION_CATALOG["create_pv"],
1008                })
1009
1010    if primary and confidence >= 0.62 and patchable:
1011        if runbook_id == "oom" and container_name:
1012            resources = container.get("resources") or {}
1013            requests, limits = resources.get("requests") or {}, resources.get("limits") or {}
1014            workload_patch({"spec": {"template": {"spec": {"containers": [{
1015                **_container_patch_base(container_name, container),
1016                "resources": {
1017                    "requests": {"cpu": requests.get("cpu") or "100m", "memory": requests.get("memory") or "256Mi"},
1018                    "limits": {"cpu": limits.get("cpu") or "1", "memory": _memory_growth(limits.get("memory"))},
1019                },
1020            }]}}}}, "OOM 证据达到执行阈值:提高内存上限并保留 CPU 约束,滚动后验证 OOM 是否消失。")
1021        elif runbook_id == "probe" and container_name:
1022            workload_patch({"spec": {"template": {"spec": {"containers": [{
1023                **_container_patch_base(container_name, container), **_startup_probe_patch(container)
1024            }]}}}}, "探针/慢启动证据达到执行阈值:增加 startupProbe 容错窗口并验证 Endpoint Ready。")
1025        elif runbook_id == "storage_permission":
1026            fs_group = _security_group_from_pod(pod)
1027            workload_patch({"spec": {"template": {"spec": {"securityContext": {
1028                "fsGroup": fs_group, "fsGroupChangePolicy": "OnRootMismatch"
1029            }}}}}, f"卷写入权限证据达到执行阈值:按容器运行用户/组选择 fsGroup={fs_group},并在 rollout 后复查挂载与写入错误。")
1030        elif runbook_id == "storage_mount" and not changes:
1031            issue = _first_storage_issue(context)
1032            issue_text = _flatten_text(issue)
1033            pvc_name = str(issue.get("pvc") or issue.get("pvc_name") or issue.get("claim") or "").strip()
1034            if pvc_name and ("not found" in issue_text or "404" in issue_text or issue.get("missing") is True):
1035                changes.append({
1036                    "type": "create_pvc", "namespace": namespace, "pvc_name": pvc_name,
1037                    "manifest": _pvc_manifest(namespace, pvc_name, issue),
1038                    "reason": "Pod 引用了不存在的 PVC;先创建受策略约束的 PVC,再等待存储插件动态供卷并验证 Pod 挂载。",
1039                    "runbook_id": runbook_id, **ACTION_CATALOG["create_pvc"],
深度阅读 · Kubernetes MCP guardrails
§ 03 · K8S MCP

MCP 工具层:把 Kubernetes API 收窄成可审计工具

MCP server 的关键不是包装 kubectl,而是用 Python 标准库直接访问 Kubernetes API。mcp_servers/k8s_mcp_server.py:40 先加载 kubeconfig 或 ServiceAccount,mcp_servers/k8s_mcp_server.py:94 统一认证 header,mcp_servers/k8s_mcp_server.py:153 和 mcp_servers/k8s_mcp_server.py:175 则把 ConfigMap 与 ServiceAccount patch 限制到白名单字段。

▸集群内外两种认证路径

src/mcp_servers/k8s_mcp_server.py · L40-91Python
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()

▸Namespace 和资源分类防止误伤

mcp_servers/k8s_mcp_server.py:196 读取 ALLOWED_NAMESPACES,mcp_servers/k8s_mcp_server.py:427 将 Pod 分成 application、infrastructure、data、batch,后续 impact 分析会按类别调整严重度。这里的工程价值是平台知道 kube-system、数据库和批任务不能用同一套可用性逻辑。

src/mcp_servers/k8s_mcp_server.py · L427-476Python
427def classify_pod(pod: dict) -> dict:
428    namespace = pod.get("namespace", "")
429    name = pod.get("name", "")
430    labels = pod.get("labels", {}) or {}
431    owners = pod.get("owner_references") or pod.get("ownerReferences") or []
432    containers = pod.get("containers", []) or []
433    phase = str(pod.get("phase") or "")
434    text = " ".join([
435        namespace,
436        name,
437        " ".join(labels.values()),
438        " ".join(o.get("kind", "") + " " + o.get("name", "") for o in owners),
439        " ".join(c.get("name", "") + " " + c.get("image", "") for c in containers),
440    ]).lower()
441
442    is_job_like = any(k in text for k in JOB_KEYWORDS) or any(o.get("kind") in ["Job", "CronJob"] for o in owners)
443    if phase in {"Succeeded", "Completed"} and is_job_like:
444        return {
445            "class": "batch",
446            "label": "批处理已完成",
447            "confidence": 0.94,
448            "reason": "Job/CronJob 一次性 Pod 已成功完成,不计入服务可用性风险",
449        }
450    if namespace in INFRA_NAMESPACES or any(k in text for k in INFRA_KEYWORDS):
451        return {
452            "class": "infrastructure",
453            "label": "基础服务",
454            "confidence": 0.9,
455            "reason": "系统命名空间或基础设施组件特征",
456        }
457    if any(k in text for k in DATA_KEYWORDS):
458        return {
459            "class": "data",
460            "label": "数据服务",
461            "confidence": 0.86,
462            "reason": "数据库/消息队列/缓存等有状态组件特征",
463        }
464    if is_job_like:
465        return {
466            "class": "batch",
467            "label": "批处理",
468            "confidence": 0.8,
469            "reason": "Job/CronJob/worker/batch 特征",
470        }
471    return {
472        "class": "application",
473        "label": "应用服务",
474        "confidence": 0.72,
475        "reason": "默认业务应用工作负载",
476    }

▸诊断工具先采证据再修改

从 mcp_servers/k8s_mcp_server.py:620 的 list_pods 到 mcp_servers/k8s_mcp_server.py:746 的 get_pod_diagnostics,工具层先返回 pod、events、logs、target state。写动作如 mcp_servers/k8s_mcp_server.py:1254 patch_workload 之后才出现,而且默认 dry_run,这和 AgenticOps 的“先证据、后变更”一致。

src/mcp_servers/k8s_mcp_server.py · L620-676Python
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)}"
深度阅读 · 修复引擎如何消费 MCP 证据
§ 04 · RUNTIME REPLAN

执行失败后,不重复同一招

▸失败指纹和候选动作去重

backend/app/application.py:7623 的 runtime replan 会读取上轮失败上下文、已尝试动作和 blocked fingerprints。backend/app/application.py:7724 正规化候选动作,backend/app/application.py:7728 计算指纹,backend/app/application.py:7730 拒绝重复失败动作,这比普通“让模型再试一次”安全很多。

src/backend/app/application.py · L7623-7663Python
7623async def _evidence_based_replan(
7624    plan: dict,
7625    steps: list[dict],
7626    attempted_actions: set[str] | None = None,
7627    *,
7628    include_llm: bool = True,
7629) -> list[dict]:
7630    deep = plan.get("_runtime_evidence") or {}
7631    failed_context = plan.get("_last_failure") or {}
7632    blocked_change_fingerprints = {
7633        str(value) for value in (plan.get("_attempted_change_fingerprints") or []) if value
7634    }
7635    blocked_change_fingerprints.update(
7636        _change_item_fingerprint(change)
7637        for change in (plan.get("changes") or [])
7638        if isinstance(change, dict)
7639    )
7640    alert = {
7641        "alert_name": plan.get("strategy_class") or plan.get("runbook_id") or "runtime_incident",
7642        "summary": plan.get("summary", ""),
7643        "namespace": plan.get("namespace", "default"),
7644        "workload_type": _workload_identity_from_plan(plan)[1],
7645        "workload_name": _workload_identity_from_plan(plan)[2],
7646        "pod": _target_pod_from_plan(plan),
7647    }
7648    diagnosis = {"root_cause": plan.get("summary", ""), "signals": deep.get("events", [])}
7649    engine_plan = build_remediation_plan(alert, diagnosis, {
7650        **deep,
7651        "pod": deep.get("pod") or ((plan.get("evidence") or {}).get("pod") or {}),
7652        "pods": [deep.get("pod")] if deep.get("pod") else [],
7653        "events": {"events": deep.get("events", [])},
7654    })
7655    primary = (engine_plan.get("hypotheses") or [{}])[0]
7656    plan["_runtime_replan"] = {
7657        "runbook_id": engine_plan.get("runbook_id"),
7658        "confidence": float(primary.get("confidence") or 0.0),
7659        "reason": engine_plan.get("reason"),
7660        "evidence_gap": engine_plan.get("evidence_gap"),
7661        "success_criteria": engine_plan.get("success_criteria") or [],
7662    }
7663    candidates = list(engine_plan.get("changes") or [])

▸已证明根因会收窄动作集合

当 backend/app/application.py:7717 的 confidence 足够高时,storage_mount、image_auth、oom、probe 等根因各自只能使用对应 action 集合。也就是说,模型不能在 PVC 已证明未绑定时提议“重启看看”,因为 backend/app/application.py:7719 会把冲突动作加入 rejected。

src/backend/app/application.py · L7705-7760Python
7705    normalized: list[dict] = []
7706    rejected: list[str] = []
7707    seen = set()
7708    constrained_actions = {
7709        "storage_mount": {"create_pvc", "create_pv", "expand_pvc", "patch_workload_volume"},
7710        "storage_permission": {"patch_workload", "patch_workload_runtime_security"},
7711        "config_missing": {"create_configmap"},
7712        "image_auth": {"patch_workload", "patch_service_account", "rollback_workload"},
7713        "image_architecture": {"rollback_workload", "patch_workload"},
7714        "oom": {"patch_workload"},
7715        "probe": {"patch_workload"},
7716        "node_pressure": {"cordon_node", "evict_pod"},
7717    }.get(str(engine_plan.get("runbook_id") or "")) if float(primary.get("confidence") or 0.0) >= 0.72 else None
7718    for raw in candidates:
7719        if constrained_actions and str((raw or {}).get("type") or "") not in constrained_actions:
7720            rejected.append(
7721                f"{(raw or {}).get('type') or 'unknown'} conflicts with proven {engine_plan.get('runbook_id')} evidence"
7722            )
7723            continue
7724        change, reason = _normalize_planner_change(raw, plan)
7725        if not change:
7726            rejected.append(reason)
7727            continue
7728        fingerprint = _change_item_fingerprint(change)
7729        if fingerprint in blocked_change_fingerprints:
7730            rejected.append(f"{change.get('type') or 'unknown'} repeats a previously failed action/target/parameter set")
7731            continue
7732        if fingerprint in seen:
7733            continue
7734        seen.add(fingerprint)
7735        normalized.append(change)
7736    if not normalized:
7737        return []
7738    namespace, workload_type, workload_name = _workload_identity_from_plan(plan)
7739    return [{
7740        "id": f"evidence-replan-{uuid.uuid4().hex[:8]}",
7741        "title": f"证据重规划:{engine_plan.get('runbook_id', 'expert-runbook')}",
7742        "namespace": namespace,
7743        "target": f"{workload_type}/{workload_name}" if workload_name else plan.get("target", namespace),
7744        "pod_name": _target_pod_from_plan(plan),
7745        "cluster": plan.get("cluster"),
7746        "cluster_id": plan.get("cluster_id"),
7747        "source": plan.get("source"),
7748        "evidence": plan.get("evidence") or {},
7749        "summary": planner_meta.get("reason") or engine_plan.get("reason") or "根据新证据生成差异化修复策略。",
7750        "steps": engine_plan.get("steps") or [],
7751        "changes": normalized[:2],
7752        "requires_confirmation": True,
7753        "requires_high_risk_confirmation": any(change.get("risk") == "high" for change in normalized[:2]),
7754        "strategy_source": "evidence_replan",
7755        "planning": planner_meta,
7756        "root_cause_hypotheses": engine_plan.get("hypotheses", []),
7757        "success_criteria": engine_plan.get("success_criteria", []),
7758        "verification_plan": _next_attempt_verification_plan(f"{workload_type}/{workload_name}" if workload_name else plan.get("target", "")),
7759        "rejected_candidates": rejected,
7760    }]
§ 05 · TESTS

测试覆盖的是安全语义而非表面输出

▸根因分类回归

tests/test_remediation_engine.py:37 把 OOM、probe、storage、image、scheduling、service、CNI 和 CPU saturation 映射到预期 runbook。它验证的是 evidence scoring 的行为,而不是 UI 文案。

src/tests/test_remediation_engine.py · L37-72Python
37    def test_root_cause_families_are_classified(self):
38        cases = {
39            "OOMKilled exit code 137": "oom",
40            "startup probe failed connection refused": "probe",
41            "volume permission denied": "storage_permission",
42            "ImagePullBackOff unauthorized": "image_auth",
43            "exec format error no matching manifest for linux/amd64": "image_architecture",
44            "FailedScheduling insufficient cpu": "scheduling_capacity",
45            "service has no endpoints": "network_service",
46            "FailedCreatePodSandBox CNI error": "dns_cni",
47            "high CPU usage": "cpu_saturation",
48        }
49        for evidence, expected in cases.items():
50            with self.subTest(evidence=evidence):
51                self.assertEqual(self.plan(evidence)["runbook_id"], expected)
52
53    def test_mkdir_permission_denied_generates_storage_permission_patch(self):
54        plan = build_remediation_plan(
55            self.alert,
56            {"root_cause": "CrashLoopBackOff"},
57            {
58                "pod": self.pod,
59                "pods": [self.pod],
60                "logs": {
61                    "api": {
62                        "current": "mkdir: can't create directory '/data/cache': Permission denied",
63                        "previous": "",
64                    }
65                },
66            },
67        )
68        self.assertEqual(plan["runbook_id"], "storage_permission")
69        self.assertEqual(plan["changes"][0]["type"], "patch_workload")
70        pod_spec = plan["changes"][0]["patch"]["spec"]["template"]["spec"]
71        self.assertEqual(pod_spec["securityContext"]["fsGroupChangePolicy"], "OnRootMismatch")
72

▸高风险动作确认

tests/test_remediation_engine.py:186 检查 cordon_node 在未确认时被 blocked,tests/test_remediation_engine.py:194 检查 403 时能返回最小 RBAC 指引;这说明平台把运维失败原因也产品化了。

src/tests/test_remediation_engine.py · L186-203Python
186    def test_high_risk_action_requires_second_confirmation(self):
187        result = asyncio.run(server._execute_change(
188            {"type": "cordon_node", "node_name": "worker-1", "risk": "high"},
189            {"cluster_id": "local", "namespace": "default", "high_risk_confirmed": False},
190        ))
191        self.assertEqual(result["status"], "blocked")
192        self.assertTrue(result["result"]["requires_high_risk_confirmation"])
193
194    def test_permission_guidance_is_action_specific(self):
195        guidance = server._permission_guidance(
196            {"error": "HTTP Error 403: Forbidden"},
197            {"cluster": "nonprod", "namespace": "k8s-agent", "source": "rancher"},
198            {"type": "recreate_pod"},
199        )
200        self.assertEqual(guidance["minimal_verbs"], ["get", "delete"])
201        self.assertEqual(guidance["minimal_resources"], ["pods"])
202        self.assertIn("Rancher Token", guidance["summary"])
203
§ 06 · RISKS

技术亮点之外,别忽略这些

第一,mcp_servers/k8s_mcp_server.py:63 允许 insecure-skip-tls-verify 的 kubeconfig 路径,开发便利但生产需要配置企业 CA。第二,backend/app/application.py:7672 的 LLM prompt 很长,复杂故障下仍可能受模型上下文和结构化 JSON 稳定性影响。第三,agents/remediation_engine.py:387 暴露完整 action catalog 给 planner,后续要持续维护动作语义和 rollback 说明。

亮点
动作目录、runbook scoring、MCP guardrails、失败指纹和恢复验证构成了较完整的 AgenticOps 闭环。
风险
仓库初始提交体量很大,生产成熟度还要看真实集群接入、RBAC 最小权限和长期运行记录。
Generated · 2026-07-14Source · William-Lu-stack/LuxyAI← 返回 Trending 索引