- Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman - 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束 - 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数 - diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件 - diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run - 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README
172 lines
5.1 KiB
Go
172 lines
5.1 KiB
Go
package diagnose
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const crashLoopPodJSON = `{
|
|
"metadata":{"name":"api-0","namespace":"prod"},
|
|
"status":{
|
|
"phase":"Running",
|
|
"containerStatuses":[
|
|
{"name":"app","ready":false,"restartCount":5,
|
|
"state":{"waiting":{"reason":"CrashLoopBackOff","message":"back-off 5m0s"}},
|
|
"lastState":{"terminated":{"reason":"Error","exitCode":1,"message":"boom"}}}
|
|
]
|
|
}
|
|
}`
|
|
|
|
func waitingContainer(reason string, lastExit int) containerStatus {
|
|
cs := containerStatus{Name: "app", RestartCount: 2}
|
|
cs.State.Waiting = &podWaiting{Reason: reason, Message: "message"}
|
|
if lastExit >= 0 {
|
|
cs.LastState.Terminated = &podTerminated{Reason: "Error", ExitCode: lastExit}
|
|
}
|
|
return cs
|
|
}
|
|
|
|
func TestJudgeContainer(t *testing.T) {
|
|
oom := containerStatus{Name: "app"}
|
|
oom.State.Terminated = &podTerminated{Reason: "OOMKilled", ExitCode: 137}
|
|
|
|
completed := containerStatus{Name: "app"}
|
|
completed.State.Terminated = &podTerminated{Reason: "Completed", ExitCode: 0}
|
|
|
|
notReady := containerStatus{Name: "app"}
|
|
notReady.State.Running = &podRunning{}
|
|
|
|
ready := containerStatus{Name: "app", Ready: true}
|
|
ready.State.Running = &podRunning{}
|
|
|
|
cases := []struct {
|
|
name string
|
|
status containerStatus
|
|
wantSeverity string
|
|
wantContains string
|
|
}{
|
|
{"crash loop", waitingContainer("CrashLoopBackOff", 1), "error", "CrashLoopBackOff"},
|
|
{"image pull", waitingContainer("ImagePullBackOff", -1), "error", "镜像拉取"},
|
|
{"err image pull", waitingContainer("ErrImagePull", -1), "error", "镜像拉取"},
|
|
{"config error", waitingContainer("CreateContainerConfigError", -1), "error", "配置"},
|
|
{"creating", waitingContainer("ContainerCreating", -1), "warn", "创建"},
|
|
{"oom killed", oom, "error", "OOMKilled"},
|
|
{"completed", completed, "ok", "正常结束"},
|
|
{"not ready", notReady, "warn", "未就绪"},
|
|
{"ready", ready, "ok", "已就绪"},
|
|
{"unknown", containerStatus{Name: "app"}, "warn", "未知"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := judgeContainer(tc.status)
|
|
if f.Severity != tc.wantSeverity {
|
|
t.Errorf("severity = %q, want %q (verdict %q)", f.Severity, tc.wantSeverity, f.Verdict)
|
|
}
|
|
if !strings.Contains(f.Verdict, tc.wantContains) {
|
|
t.Errorf("verdict %q does not contain %q", f.Verdict, tc.wantContains)
|
|
}
|
|
if f.Evidence == "" {
|
|
t.Error("evidence must not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJudgePod(t *testing.T) {
|
|
cases := []struct {
|
|
phase string
|
|
wantSeverity string
|
|
}{
|
|
{"Pending", "warn"},
|
|
{"Failed", "error"},
|
|
{"Succeeded", "ok"},
|
|
{"Running", "ok"},
|
|
}
|
|
for _, tc := range cases {
|
|
var p pod
|
|
p.Status.Phase = tc.phase
|
|
if got := judgePod(p); got.Severity != tc.wantSeverity {
|
|
t.Errorf("phase %s: severity = %q, want %q", tc.phase, got.Severity, tc.wantSeverity)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiagnoseK8sOutput(t *testing.T) {
|
|
r := &fakeRunner{
|
|
available: map[string]bool{"kubectl": true},
|
|
respond: func(name string, args []string) (CommandResult, error) {
|
|
joined := strings.Join(args, " ")
|
|
switch {
|
|
case strings.HasPrefix(joined, "get pod"):
|
|
return CommandResult{Stdout: []byte(crashLoopPodJSON)}, nil
|
|
case strings.HasPrefix(joined, "logs"):
|
|
return CommandResult{Stdout: []byte("panic: boom\n")}, nil
|
|
case strings.HasPrefix(joined, "get events"):
|
|
return CommandResult{Stdout: []byte("0s Warning FailedScheduling insufficient cpu\n")}, nil
|
|
case strings.HasPrefix(joined, "config view"):
|
|
return CommandResult{}, nil
|
|
}
|
|
return CommandResult{}, fmt.Errorf("unexpected call %s", joined)
|
|
},
|
|
}
|
|
|
|
s, out, errOut := newTestService(r)
|
|
err := s.Run(context.Background(), []string{"api-0", "--kind", "k8s", "--namespace", "prod"})
|
|
if err != nil {
|
|
t.Fatalf("Run error: %v (stderr=%s)", err, errOut.String())
|
|
}
|
|
|
|
text := out.String()
|
|
for _, want := range []string{
|
|
"目标: api-0",
|
|
"来源: Kubernetes",
|
|
"namespace=prod",
|
|
"phase=Running",
|
|
"容器 app:",
|
|
"CrashLoopBackOff",
|
|
"lastState.exitCode=1",
|
|
"日志 (app, 最近 50 行)",
|
|
"panic: boom",
|
|
"日志 (app, 上一次)",
|
|
"FailedScheduling",
|
|
"事件提示",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Errorf("output missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiagnoseK8sFiltersContainer(t *testing.T) {
|
|
podJSON := `{
|
|
"metadata":{"name":"api-0","namespace":"prod"},
|
|
"status":{"phase":"Running","containerStatuses":[
|
|
{"name":"one","ready":true,"state":{"running":{}}},
|
|
{"name":"two","ready":true,"state":{"running":{}}}
|
|
]}
|
|
}`
|
|
|
|
r := &fakeRunner{
|
|
available: map[string]bool{"kubectl": true},
|
|
respond: func(name string, args []string) (CommandResult, error) {
|
|
joined := strings.Join(args, " ")
|
|
if strings.HasPrefix(joined, "get pod") {
|
|
return CommandResult{Stdout: []byte(podJSON)}, nil
|
|
}
|
|
return CommandResult{Stdout: []byte("log\n")}, nil
|
|
},
|
|
}
|
|
|
|
s, out, _ := newTestService(r)
|
|
if err := s.Run(context.Background(), []string{"api-0", "--kind", "k8s", "-c", "one"}); err != nil {
|
|
t.Fatalf("Run error: %v", err)
|
|
}
|
|
text := out.String()
|
|
if !strings.Contains(text, "容器 one:") || strings.Contains(text, "容器 two:") {
|
|
t.Errorf("container filter not applied:\n%s", text)
|
|
}
|
|
}
|