feat: Agent 支持 etcdctl/etcdutl 诊断命令
etcd 是 K8S 控制平面的关键组件,缺少 etcd 诊断会导致很多故障无法 继续排查。本次将 etcdctl 和 etcdutl 纳入命令白名单: 只读 (自动执行): - etcdctl endpoint status/health/hashkv - 端点健康 - etcdctl member list - 成员列表 - etcdctl alarm list - 告警 - etcdctl auth/user/role status|list - 认证与权限查看 - etcdctl check perf/datascale - 性能检查 - etcdctl get/watch - 读取 key - etcdctl version - etcdutl snapshot status/hash - 快照检查 - etcdutl version 写操作 (人工审批): - etcdctl put/del/compact/txn - 数据修改 - etcdctl snapshot save - 保存快照 - etcdctl member add/remove/update - 成员管理 - etcdctl alarm disarm - 解除告警 - etcdctl auth enable/disable - 认证开关 - etcdctl user/role add/delete/grant/revoke - 权限管理 - etcdutl snapshot restore - 恢复快照 实现细节: - etcdctl/etcdutl 命令支持全局 flag (--endpoints/--cacert 等在子命令前) - 两段式命令按 (verb, subverb) 组合精确分类,避免 member list (只读) 和 member remove (写) 混淆 - 新增 12 个 etcd 命令分类测试,全部 56 个测试通过
This commit is contained in:
@@ -127,6 +127,34 @@ SHELL_READ_ONLY_COMMANDS: dict[str, set[str] | None] = {
|
|||||||
"timedatectl": None, "date": None, "stat": None, "file": None, "blkid": None,
|
"timedatectl": None, "date": None, "stat": None, "file": None, "blkid": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# etcdctl / etcdutl 由 classify_shell_command 中的专用块处理 (两段式命令)
|
||||||
|
# 从通用白名单中移除,避免被单段式逻辑误判
|
||||||
|
|
||||||
|
# etcdctl 单段式只读 verb
|
||||||
|
_ETCDCTL_READ_VERBS = {"get", "watch", "version", "lock", "lease", "move-leader", "leader"}
|
||||||
|
# etcdctl 单段式写 verb (需人工审批)
|
||||||
|
_ETCDCTL_WRITE_VERBS = {"put", "del", "delete", "compact", "txn", "snapshot"}
|
||||||
|
# etcdctl 两段式 (verb, subverb) 只读组合
|
||||||
|
_ETCDCTL_READ_SUBVERBS = {
|
||||||
|
"endpoint": {"status", "health", "hashkv"},
|
||||||
|
"member": {"list", "list-urls"},
|
||||||
|
"alarm": {"list"},
|
||||||
|
"auth": {"status"},
|
||||||
|
"user": {"list", "get"},
|
||||||
|
"role": {"list", "get"},
|
||||||
|
"check": {"perf", "datascale"},
|
||||||
|
}
|
||||||
|
# etcdctl 两段式 (verb, subverb) 写组合 (需人工审批)
|
||||||
|
_ETCDCTL_WRITE_SUBVERBS = {
|
||||||
|
"member": {"add", "remove", "update"},
|
||||||
|
"alarm": {"disarm"},
|
||||||
|
"auth": {"enable", "disable"},
|
||||||
|
"user": {"add", "delete", "grant-role", "revoke-role", "passwd"},
|
||||||
|
"role": {"add", "delete", "grant-permission", "revoke-permission"},
|
||||||
|
}
|
||||||
|
# etcdutl snapshot 只读子命令
|
||||||
|
_ETCDUTL_SNAPSHOT_READ = {"status", "hash"}
|
||||||
|
|
||||||
# 系统修改命令白名单:需要人工审批
|
# 系统修改命令白名单:需要人工审批
|
||||||
SHELL_WRITE_COMMANDS: dict[str, set[str] | None] = {
|
SHELL_WRITE_COMMANDS: dict[str, set[str] | None] = {
|
||||||
"systemctl": {"restart", "start", "stop", "reload", "enable", "disable", "daemon-reload"},
|
"systemctl": {"restart", "start", "stop", "reload", "enable", "disable", "daemon-reload"},
|
||||||
@@ -178,6 +206,38 @@ def classify_shell_command(command: str) -> CommandDecision:
|
|||||||
if allowed is None or (verb is not None and verb in allowed):
|
if allowed is None or (verb is not None and verb in allowed):
|
||||||
return CommandDecision("write", True, binary, parts[1:])
|
return CommandDecision("write", True, binary, parts[1:])
|
||||||
|
|
||||||
|
# etcdctl: 单段式或两段式命令,verb/subverb 均跳过全局 flag (--endpoints 等)
|
||||||
|
# member/alarm/auth 等同时有只读和写子命令,需按 (verb, subverb) 组合精确判断
|
||||||
|
if binary == "etcdctl":
|
||||||
|
non_flags = [a for a in parts[1:] if not a.startswith("-")]
|
||||||
|
ev = non_flags[0].lower() if non_flags else None
|
||||||
|
esub = non_flags[1].lower() if len(non_flags) > 1 else None
|
||||||
|
# 单段式 verb
|
||||||
|
if ev in _ETCDCTL_READ_VERBS:
|
||||||
|
return CommandDecision("read", False, ev, parts[1:])
|
||||||
|
if ev in _ETCDCTL_WRITE_VERBS:
|
||||||
|
return CommandDecision("write", True, ev, parts[1:])
|
||||||
|
# 两段式: 先查写子命令 (精确匹配 subverb),再查只读子命令
|
||||||
|
if ev in _ETCDCTL_WRITE_SUBVERBS and esub in _ETCDCTL_WRITE_SUBVERBS[ev]:
|
||||||
|
return CommandDecision("write", True, ev, parts[1:])
|
||||||
|
if ev in _ETCDCTL_READ_SUBVERBS:
|
||||||
|
if esub is None or esub in _ETCDCTL_READ_SUBVERBS[ev]:
|
||||||
|
return CommandDecision("read", False, ev, parts[1:])
|
||||||
|
raise ValueError(f"etcdctl {ev} 不支持子命令: {esub}")
|
||||||
|
raise ValueError(f"etcdctl 不支持该子命令: {ev}")
|
||||||
|
|
||||||
|
if binary == "etcdutl":
|
||||||
|
non_flags = [a for a in parts[1:] if not a.startswith("-")]
|
||||||
|
ev = non_flags[0].lower() if non_flags else None
|
||||||
|
esub = non_flags[1].lower() if len(non_flags) > 1 else None
|
||||||
|
if ev == "snapshot":
|
||||||
|
if esub in _ETCDUTL_SNAPSHOT_READ:
|
||||||
|
return CommandDecision("read", False, ev, parts[1:])
|
||||||
|
return CommandDecision("write", True, ev, parts[1:])
|
||||||
|
if ev == "version":
|
||||||
|
return CommandDecision("read", False, ev, parts[1:])
|
||||||
|
raise ValueError(f"etcdutl 不支持该子命令: {ev}")
|
||||||
|
|
||||||
if binary in SHELL_READ_ONLY_COMMANDS:
|
if binary in SHELL_READ_ONLY_COMMANDS:
|
||||||
allowed = SHELL_READ_ONLY_COMMANDS[binary]
|
allowed = SHELL_READ_ONLY_COMMANDS[binary]
|
||||||
if allowed is not None and (verb is None or verb not in allowed):
|
if allowed is not None and (verb is None or verb not in allowed):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ AGENT_SYSTEM_PROMPT = """你是 Kubernetes 自主诊断 Agent。你的首要职
|
|||||||
可用工具:
|
可用工具:
|
||||||
1. kubectl 命令 - 查询集群内资源状态。例如: kubectl get pods -A, kubectl describe node <name>, kubectl logs <pod> -n <ns>
|
1. kubectl 命令 - 查询集群内资源状态。例如: kubectl get pods -A, kubectl describe node <name>, kubectl logs <pod> -n <ns>
|
||||||
2. 系统诊断 shell 命令 - 查询节点宿主机状态。例如: systemctl status kubelet, journalctl -u kubelet -n 100 --no-pager, crictl ps, df -h, free -m, ss -tlnp, ip addr
|
2. 系统诊断 shell 命令 - 查询节点宿主机状态。例如: systemctl status kubelet, journalctl -u kubelet -n 100 --no-pager, crictl ps, df -h, free -m, ss -tlnp, ip addr
|
||||||
|
3. etcd 诊断命令 - 检查 etcd 集群健康与数据。例如: etcdctl endpoint status, etcdctl member list, etcdctl alarm list, etcdctl get /kubernetes --prefix, etcdutl snapshot status <file>
|
||||||
|
|
||||||
行为要求:
|
行为要求:
|
||||||
1. 如果证据不足,必须返回 command 动作并逐条调查;至少检查与故障资源直接相关的状态、describe、events 或 logs。
|
1. 如果证据不足,必须返回 command 动作并逐条调查;至少检查与故障资源直接相关的状态、describe、events 或 logs。
|
||||||
|
|||||||
@@ -102,6 +102,71 @@ class ShellCommandPolicyTest(unittest.TestCase):
|
|||||||
classify_shell_command("cat /etc/shadow")
|
classify_shell_command("cat /etc/shadow")
|
||||||
|
|
||||||
|
|
||||||
|
class EtcdCommandPolicyTest(unittest.TestCase):
|
||||||
|
def test_etcdctl_endpoint_status_is_read_only(self):
|
||||||
|
decision = classify_shell_command("etcdctl endpoint status")
|
||||||
|
self.assertEqual(decision.mode, "read")
|
||||||
|
self.assertFalse(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_with_global_flags_still_classifies(self):
|
||||||
|
decision = classify_shell_command(
|
||||||
|
"etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/ssl/ca.pem endpoint health"
|
||||||
|
)
|
||||||
|
self.assertEqual(decision.mode, "read")
|
||||||
|
self.assertFalse(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_member_list_is_read_only(self):
|
||||||
|
decision = classify_shell_command("etcdctl member list")
|
||||||
|
self.assertEqual(decision.mode, "read")
|
||||||
|
self.assertFalse(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_get_and_version_are_read_only(self):
|
||||||
|
for command in ("etcdctl get /kubernetes", "etcdctl version", "etcdctl alarm list"):
|
||||||
|
with self.subTest(command=command):
|
||||||
|
decision = classify_shell_command(command)
|
||||||
|
self.assertEqual(decision.mode, "read")
|
||||||
|
self.assertFalse(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdutl_snapshot_status_is_read_only(self):
|
||||||
|
decision = classify_shell_command("etcdutl snapshot status /tmp/etcd.db")
|
||||||
|
self.assertEqual(decision.mode, "read")
|
||||||
|
self.assertFalse(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_put_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdctl put key value")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_member_remove_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdctl member remove abc123")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_snapshot_save_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdctl snapshot save /tmp/etcd.db")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_compact_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdctl compact 12345")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_auth_enable_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdctl auth enable")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdutl_snapshot_restore_requires_approval(self):
|
||||||
|
decision = classify_shell_command("etcdutl snapshot restore /tmp/etcd.db")
|
||||||
|
self.assertEqual(decision.mode, "write")
|
||||||
|
self.assertTrue(decision.requires_approval)
|
||||||
|
|
||||||
|
def test_etcdctl_rejects_unknown_subcommand(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
classify_shell_command("etcdctl bogus command")
|
||||||
|
|
||||||
|
|
||||||
class AgentCommandDispatchTest(unittest.TestCase):
|
class AgentCommandDispatchTest(unittest.TestCase):
|
||||||
def test_dispatches_kubectl_command_to_kubectl_classifier(self):
|
def test_dispatches_kubectl_command_to_kubectl_classifier(self):
|
||||||
decision = classify_agent_command("kubectl get pods -A")
|
decision = classify_agent_command("kubectl get pods -A")
|
||||||
|
|||||||
Reference in New Issue
Block a user