From e02f3010cf174e9b295ac83e575769f2e5583393 Mon Sep 17 00:00:00 2001 From: Odboy Date: Sat, 31 Oct 2026 14:09:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=85=85=20Docker=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=B9=B6=E6=96=B0=E5=A2=9E=20diagnose=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman - 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束 - 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数 - diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件 - diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run - 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README --- README.md | 80 ++++++- internal/cli/cli.go | 84 ++++++- internal/cli/cli_test.go | 168 ++++++++++++- internal/diagnose/docker.go | 248 +++++++++++++++++++ internal/diagnose/docker_test.go | 113 +++++++++ internal/diagnose/helpers_test.go | 60 +++++ internal/diagnose/k8s.go | 372 +++++++++++++++++++++++++++++ internal/diagnose/k8s_test.go | 171 +++++++++++++ internal/diagnose/options.go | 141 +++++++++++ internal/diagnose/options_test.go | 99 ++++++++ internal/diagnose/runner.go | 54 +++++ internal/diagnose/service.go | 230 ++++++++++++++++++ internal/diagnose/service_test.go | 140 +++++++++++ internal/executor/builtin.go | 11 + internal/executor/executor.go | 7 +- internal/registry/builtin.go | 345 +++++++++++++++++++++++--- internal/registry/registry.go | 80 ++++++- internal/registry/registry_test.go | 117 ++++++++- internal/resolve/resolve_test.go | 14 +- 19 files changed, 2465 insertions(+), 69 deletions(-) create mode 100644 internal/diagnose/docker.go create mode 100644 internal/diagnose/docker_test.go create mode 100644 internal/diagnose/helpers_test.go create mode 100644 internal/diagnose/k8s.go create mode 100644 internal/diagnose/k8s_test.go create mode 100644 internal/diagnose/options.go create mode 100644 internal/diagnose/options_test.go create mode 100644 internal/diagnose/runner.go create mode 100644 internal/diagnose/service.go create mode 100644 internal/diagnose/service_test.go diff --git a/README.md b/README.md index 75016a4..2879275 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,23 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath \ ## 用法 ```bash +kopsc docker ps # 列出运行中的容器 +kopsc docker psall # 含已停止的容器 +kopsc docker logs -f web # 查看日志(容器名必填,选项可透传) +kopsc docker exec -it web sh # 进入容器 +kopsc docker compose up # 启动 Compose 服务 +kopsc docker clearall --yes # 深度清理(破坏性命令,必须显式 --yes) + kopsc docker clearsafe # 直接执行当前平台对应的清理命令 kopsc docker clearsafe --dry-run # 只打印将执行的命令与判定依据 kopsc docker clearsafe -h # 该意图的说明、当前平台命中结果与选项 kopsc docker clearsafe -- --filter until=24h # -- 之后的参数原样透传给底层命令 +kopsc diagnose web # 自动识别 Docker 容器或 K8s Pod,给出根因 +kopsc diagnose api-0 -n prod # K8s:本命令里 -n 表示 namespace +kopsc diagnose api-0 --kind k8s --context staging +kopsc diagnose web --lines 200 --no-events + kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版) kopsc disk usage --path /var # 同上,用命名参数指定路径 kopsc disk usage /var --dry-run # 预览(内置命令同样支持) @@ -93,24 +105,82 @@ $ kopsc disk usage /var | --- | --- | | `-n`, `--dry-run`, `--dry-run=true` | 置为预览模式,不执行 | | `--dry-run=false` | 关闭预览模式 | +| `-y`, `--yes` | 确认执行破坏性命令;未提供时只打印影响范围并以退出码 2 结束 | | `-h`, `--help` | 打印该命令的帮助 | -| `--` | 其后的所有参数原样透传给底层命令 | +| `--` | 其后的所有参数原样透传给底层命令(并全部计入位置参数) | | 其它任何参数 | 原样透传给底层命令(包括未知 flag) | 因此底层命令的 flag 不会与 kopsc 自身的选项冲突。 +需要容器名/镜像名的命令会做必填校验(例如 `kopsc docker logs` 缺容器名时直接报错, +并给出 `kopsc docker logs <容器>` 的用法),避免把参数错误留给底层命令才发现。 + ## 内置命令 +### Docker(`kopsc docker <子命令>`) + +| 分组 | 命令 | +| --- | --- | +| 只读 | `ps`、`psall`、`images`、`stats`、`df`、`info`、`version`、`logs`、`inspect`、`networks`、`volumes` | +| 生命周期 | `start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`load`、`cp` | +| 清理 | `clearsafe`、`pruneimages`、`prunecontainers`、`prunenets`、`prunebuilder`、`prunevolumes`、`clearall` | +| Compose | `compose up`、`compose down`、`compose ps`、`compose logs` | + +- 所有 Docker 命令在 Linux 上同时提供 `docker` 与 `podman` 变体,且 podman 优先; + macOS / Windows 使用 Docker Desktop。 +- `logs`、`inspect`、`start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`cp` + 需要位置参数,缺失时直接报错并给出用法。 +- 破坏性命令只有 `clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`): + 未带 `--yes` 时只打印影响范围与将执行的命令,并以退出码 2 结束。 +- `docker compose` 对应 Compose v2;`podman compose` 需要 podman 4+。 +- `docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像), + **不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。 + +### 容器/Pod 诊断(diagnose) + +`kopsc diagnose <名称>` 自动判断目标是 Docker/Podman 容器还是 Kubernetes Pod,输出状态、根因、依据、建议,并附最近日志与事件。只读,不修改任何对象。 + +```text +目标: web +来源: Docker (docker) +状态: exited ExitCode=137 OOMKilled=true RestartCount=3 +镜像: nginx:1.25 +判定: ❌ 容器因内存超限被 OOM kill +依据: State.Status=exited, ExitCode=137, OOMKilled=true, RestartCount=3 +建议: 提高容器的内存限制,或降低应用峰值内存;可用 kopsc docker stats 观察 + +最近日志 (50 行): +... +最近事件 (1h): +... +``` + +| 参数 | 说明 | +| --- | --- | +| `--kind docker\|k8s` | 强制来源;默认自动识别:先 `docker inspect`,未命中再 `kubectl get pod` | +| `-n`, `-N`, `--namespace` | **仅 diagnose**:Kubernetes 命名空间。默认取 kubeconfig 当前上下文,未设置则为 `default` | +| `--context` | kubectl 上下文 | +| `-c`, `--container` | 多容器 Pod 只看指定容器(默认输出全部容器) | +| `--lines N` | 日志行数,默认 50 | +| `--no-logs` / `--no-events` | 跳过日志 / 事件采集 | +| `--dry-run` | 只预览将执行的诊断动作,不调用 docker/kubectl | + +> `-n` 只在 `kopsc diagnose` 中表示 namespace;其它命令里 `-n` 仍是 `--dry-run` 的简写。 +> Docker 模式下传 `--namespace`/`--context` 会直接报错,不会静默忽略。 + +判定覆盖:Docker 的 OOMKilled、ExitCode 137/143/126/127、正常退出、重启循环、unhealthy、created/paused/dead; +Kubernetes 的 CrashLoopBackOff、ImagePullBackOff/ErrImagePull、CreateContainerConfigError、OOMKilled、 +Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。 + +### 其它 + | 命令 | 说明 | 平台变体 | | --- | --- | --- | -| `docker.clearsafe` | 安全清理容器/网络/构建缓存 | Linux(centos)+podman、Linux(centos)+docker、Linux+podman、Linux+docker、darwin+docker、windows+docker,均为 `system prune -f` | +| `diagnose` | 容器/Pod 状态根因诊断(自动识别 Docker 与 Kubernetes) | Linux/macOS/Windows 均使用 kopsc 内置实现 | | `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 | | `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | | `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | -`docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像), -**不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。 - ## 平台识别 `kopsc platform` 展示探测结果,来源: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3af5323..49f44be 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -26,6 +26,10 @@ type App struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer + + // exitCode is set by commands that intentionally exit non-zero without an + // error (for example a destructive command awaiting --yes). + exitCode int } // New returns an App for the running process. @@ -42,13 +46,14 @@ func New(version string) *App { // Execute runs the command tree and returns the process exit code. func (a *App) Execute(args []string) int { + a.exitCode = 0 root := a.Root() root.SetArgs(args) if err := root.Execute(); err != nil { fmt.Fprintln(a.Stderr, "kopsc: "+err.Error()) return executor.ExitCode(err) } - return 0 + return a.exitCode } // Root builds the full cobra command tree. @@ -126,18 +131,32 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { Long: cmd.Summary + "\n\n动作: " + cmd.Action, DisableFlagParsing: true, RunE: func(c *cobra.Command, args []string) error { - dryRun, extra, help := parseLeafArgs(args) + dryRun, yes, extra, help, err := parseLeafArgs(cmd, args) + if err != nil { + return err + } if help { a.printLeafHelp(c.OutOrStdout(), cmd) return nil } + if err := cmd.ValidateArgs(extra); err != nil { + return err + } + result, err := resolve.Resolve(cmd, a.Info) if err != nil { return fmt.Errorf("%w(可用 `kopsc explain %s` 查看全部候选)", err, cmd.ID) } plan := executor.Plan{Result: result, ExtraArgs: extra} + + if cmd.RequiresYes && !yes && !dryRun { + a.printConfirmation(cmd, plan) + a.exitCode = 2 + return nil + } + return plan.Run(context.Background(), executor.Options{ DryRun: dryRun, Stdin: a.Stdin, @@ -149,34 +168,79 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { return leaf } +// printConfirmation explains a destructive command and how to approve it. +func (a *App) printConfirmation(cmd *registry.Command, plan executor.Plan) { + w := a.Stdout + fmt.Fprintln(w, "这是破坏性命令,未执行。") + fmt.Fprintf(w, "命令: %s\n", cmd.Invocation()) + fmt.Fprintf(w, "将执行: %s\n", plan.Display()) + fmt.Fprintf(w, "影响: %s\n\n", cmd.Action) + fmt.Fprintf(w, "确认执行请追加 --yes:\n %s --yes\n", cmd.Invocation()) +} + // parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--` // are consumed, everything else (including `--` and after) is forwarded. -func parseLeafArgs(args []string) (dryRun bool, extra []string, help bool) { +// +// For commands marked Namespaced (Kubernetes-aware), -n/-N mean --namespace +// (kubectl style) and are normalised to --namespace; --dry-run remains the +// only preview flag. On every other command -n keeps its --dry-run meaning. +func parseLeafArgs(cmd *registry.Command, args []string) (dryRun, yes bool, extra []string, help bool, err error) { passthrough := false - for _, arg := range args { + for i := 0; i < len(args); i++ { + arg := args[i] + if passthrough { extra = append(extra, arg) continue } + switch { case arg == "--": passthrough = true + + case cmd.Namespaced && (arg == "-n" || arg == "-N" || arg == "--namespace"): + if i+1 >= len(args) { + return false, false, nil, false, + fmt.Errorf("%s: %s 需要命名空间参数,例如 %s prod", cmd.Invocation(), arg, arg) + } + i++ + extra = append(extra, "--namespace", args[i]) + + case cmd.Namespaced && strings.HasPrefix(arg, "-n="): + extra = append(extra, "--namespace="+strings.TrimPrefix(arg, "-n=")) + + case cmd.Namespaced && strings.HasPrefix(arg, "-N="): + extra = append(extra, "--namespace="+strings.TrimPrefix(arg, "-N=")) + + case cmd.Namespaced && strings.HasPrefix(arg, "--namespace="): + extra = append(extra, arg) + case arg == "-n" || arg == "--dry-run" || arg == "--dry-run=true": dryRun = true + case arg == "--dry-run=false": dryRun = false + + case arg == "-y" || arg == "--yes": + yes = true + case arg == "-h" || arg == "--help": help = true + default: extra = append(extra, arg) } } - return dryRun, extra, help + return dryRun, yes, extra, help, nil } func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) { - invocation := "kopsc " + strings.ReplaceAll(cmd.ID, ".", " ") - fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [-- 透传参数...]\n\n", invocation) + invocation := cmd.Invocation() + argsHint := "" + if cmd.Usage != "" { + argsHint = " " + cmd.Usage + } + fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [--yes]%s [-- 透传参数...]\n\n", invocation, argsHint) fmt.Fprintf(w, "%s\n\n", cmd.Summary) fmt.Fprintf(w, "动作: %s\n\n", cmd.Action) fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info)) @@ -190,7 +254,13 @@ func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) { fmt.Fprint(w, "选项:\n") fmt.Fprint(w, " -n, --dry-run 仅打印将执行的命令与判定依据,不执行\n") fmt.Fprint(w, " -h, --help 显示本帮助\n") + if cmd.RequiresYes { + fmt.Fprint(w, " -y, --yes 确认执行破坏性命令(否则只打印影响并以状态码 2 退出)\n") + } fmt.Fprint(w, " -- 其后的参数原样透传给底层命令\n") + if cmd.Usage != "" { + fmt.Fprintf(w, "\n参数: %s(至少 %d 个)\n", cmd.Usage, cmd.MinArgs) + } } func (a *App) newListCmd() *cobra.Command { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index dc33ba0..82a8038 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -39,7 +39,7 @@ func TestLeafDryRun(t *testing.T) { t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) } out := stdout.String() - for _, want := range []string{"[dry-run]", "docker system prune -f", "CentOS 上的 Docker 运行时", "依据"} { + for _, want := range []string{"[dry-run]", "docker system prune -f", "Linux 上的 Docker 运行时", "依据"} { if !strings.Contains(out, want) { t.Errorf("dry-run output missing %q:\n%s", want, out) } @@ -212,28 +212,55 @@ func TestGroupHelp(t *testing.T) { } func TestParseLeafArgs(t *testing.T) { + plain := ®istry.Command{ID: "docker.logs"} + namespaced := ®istry.Command{ID: "diagnose", Namespaced: true} + tests := []struct { name string + cmd *registry.Command args []string wantDryRun bool + wantYes bool wantExtra []string wantHelp bool + wantErr bool }{ - {"empty", nil, false, nil, false}, - {"dry-run short", []string{"-n"}, true, nil, false}, - {"dry-run long", []string{"--dry-run"}, true, nil, false}, - {"dry-run false", []string{"--dry-run=false"}, false, nil, false}, - {"help", []string{"-h"}, false, nil, true}, - {"passthrough", []string{"--", "--dry-run", "-h"}, false, []string{"--dry-run", "-h"}, false}, - {"unknown flags forwarded", []string{"--filter", "until=1h"}, false, []string{"--filter", "until=1h"}, false}, - {"mixed", []string{"--dry-run", "arg", "--", "-n"}, true, []string{"arg", "-n"}, false}, + {"empty", plain, nil, false, false, nil, false, false}, + {"plain -n is dry-run", plain, []string{"-n"}, true, false, nil, false, false}, + {"dry-run long", plain, []string{"--dry-run"}, true, false, nil, false, false}, + {"dry-run false", plain, []string{"--dry-run=false"}, false, false, nil, false, false}, + {"yes long", plain, []string{"--yes"}, false, true, nil, false, false}, + {"yes short", plain, []string{"-y"}, false, true, nil, false, false}, + {"help", plain, []string{"-h"}, false, false, nil, true, false}, + {"passthrough", plain, []string{"--", "--dry-run", "-h", "--yes"}, false, false, []string{"--dry-run", "-h", "--yes"}, false, false}, + {"unknown flags forwarded", plain, []string{"--filter", "until=1h"}, false, false, []string{"--filter", "until=1h"}, false, false}, + {"mixed", plain, []string{"--dry-run", "arg", "--", "-n"}, true, false, []string{"arg", "-n"}, false, false}, + + {"namespaced -n", namespaced, []string{"api-0", "-n", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false}, + {"namespaced -n=value", namespaced, []string{"api-0", "-n=prod"}, false, false, []string{"api-0", "--namespace=prod"}, false, false}, + {"namespaced -N", namespaced, []string{"api-0", "-N", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false}, + {"namespaced --namespace", namespaced, []string{"api-0", "--namespace", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false}, + {"namespaced --namespace=value", namespaced, []string{"api-0", "--namespace=prod"}, false, false, []string{"api-0", "--namespace=prod"}, false, false}, + {"namespaced --dry-run still previews", namespaced, []string{"api-0", "--dry-run"}, true, false, []string{"api-0"}, false, false}, + {"namespaced -n without value", namespaced, []string{"api-0", "-n"}, false, false, nil, false, true}, + {"namespaced passthrough keeps -n", namespaced, []string{"--", "-n", "prod"}, false, false, []string{"-n", "prod"}, false, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - dryRun, extra, help := parseLeafArgs(tc.args) - if dryRun != tc.wantDryRun || help != tc.wantHelp { - t.Errorf("dryRun=%v help=%v, want %v/%v", dryRun, help, tc.wantDryRun, tc.wantHelp) + dryRun, yes, extra, help, err := parseLeafArgs(tc.cmd, tc.args) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dryRun != tc.wantDryRun || yes != tc.wantYes || help != tc.wantHelp { + t.Errorf("dryRun=%v yes=%v help=%v, want %v/%v/%v", + dryRun, yes, help, tc.wantDryRun, tc.wantYes, tc.wantHelp) } if strings.Join(extra, "|") != strings.Join(tc.wantExtra, "|") { t.Errorf("extra = %v, want %v", extra, tc.wantExtra) @@ -242,6 +269,87 @@ func TestParseLeafArgs(t *testing.T) { } } +func TestDestructiveCommandRequiresYes(t *testing.T) { + app, stdout, stderr := testApp(centos("docker")) + + code := app.Execute([]string{"docker", "clearall"}) + if code != 2 { + t.Fatalf("exit code = %d, want 2", code) + } + out := stdout.String() + for _, want := range []string{"破坏性命令", "将执行: docker system prune -a -f", "影响", "kopsc docker clearall --yes"} { + if !strings.Contains(out, want) { + t.Errorf("confirmation output missing %q:\n%s", want, out) + } + } + if stderr.Len() != 0 { + t.Errorf("unexpected stderr: %s", stderr.String()) + } +} + +func TestDestructiveCommandDryRunNeedsNoYes(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "prunevolumes", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "docker volume prune -f") { + t.Errorf("dry-run output:\n%s", stdout.String()) + } +} + +func TestYesIsNotForwarded(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "clearall", "--yes", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + out := stdout.String() + if strings.Contains(out, "--yes") { + t.Errorf("--yes leaked into the forwarded args:\n%s", out) + } + if !strings.Contains(out, "docker system prune -a -f") { + t.Errorf("unexpected dry-run output:\n%s", out) + } +} + +func TestMissingRequiredArgs(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "logs"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + msg := stderr.String() + for _, want := range []string{"缺少参数", "kopsc docker logs <容器>"} { + if !strings.Contains(msg, want) { + t.Errorf("stderr missing %q:\n%s", want, msg) + } + } +} + +func TestMissingArgsWhenOnlyOptionsGiven(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "rm", "-f"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "缺少参数") { + t.Errorf("stderr = %s", stderr.String()) + } +} + +func TestDestructiveHelpMentionsYes(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "clearall", "-h"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + if !strings.Contains(out, "--yes") || !strings.Contains(out, "状态码 2") { + t.Errorf("help missing --yes documentation:\n%s", out) + } +} + func TestDiskUsageCommandThroughCLI(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "a.bin"), bytes.Repeat([]byte("x"), 4096), 0o644); err != nil { @@ -316,6 +424,42 @@ func TestDiskUsageHelp(t *testing.T) { } } +func TestDiagnoseDryRun(t *testing.T) { + app, stdout, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"diagnose", "web", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"[dry-run]", "(内置) diagnose web", "内置实现"} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDiagnoseNamespaceTranslationThroughCLI(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"diagnose", "api-0", "-n", "prod", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "api-0 --namespace prod") { + t.Errorf("-n was not translated to --namespace:\n%s", stdout.String()) + } +} + +func TestDiagnoseRequiresTarget(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"diagnose"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "缺少参数") { + t.Errorf("stderr = %s", stderr.String()) + } +} + // TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough. func TestCLIHelperProcess(t *testing.T) { if os.Getenv("KOPSC_CLI_HELPER") != "1" { diff --git a/internal/diagnose/docker.go b/internal/diagnose/docker.go new file mode 100644 index 0000000..d1f85d0 --- /dev/null +++ b/internal/diagnose/docker.go @@ -0,0 +1,248 @@ +package diagnose + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +type dockerHealth struct { + Status string `json:"Status"` +} + +type dockerContainer struct { + ID string `json:"Id"` + Name string `json:"Name"` + RestartCount int `json:"RestartCount"` + Config struct { + Image string `json:"Image"` + } `json:"Config"` + State struct { + Status string `json:"Status"` + Running bool `json:"Running"` + Paused bool `json:"Paused"` + Restarting bool `json:"Restarting"` + OOMKilled bool `json:"OOMKilled"` + Dead bool `json:"Dead"` + ExitCode int `json:"ExitCode"` + Error string `json:"Error"` + StartedAt string `json:"StartedAt"` + FinishedAt string `json:"FinishedAt"` + Health *dockerHealth `json:"Health"` + } `json:"State"` +} + +func (s *Service) diagnoseDocker(ctx context.Context, src source, target string, opts Options) error { + res, err := s.run(ctx, src.program, "inspect", "--type", "container", target) + if err != nil { + return err + } + + var containers []dockerContainer + if err := json.Unmarshal(res.Stdout, &containers); err != nil { + return fmt.Errorf("解析 %s inspect 输出失败: %w", src.program, err) + } + if len(containers) == 0 { + return fmt.Errorf("%s 中没有名为 %q 的容器", src.program, target) + } + + c := containers[0] + name := strings.TrimPrefix(c.Name, "/") + if name == "" { + name = target + } + + fmt.Fprintf(s.out, "目标: %s\n", name) + fmt.Fprintf(s.out, "来源: Docker (%s)\n", src.program) + fmt.Fprintf(s.out, "状态: %s ExitCode=%d OOMKilled=%t RestartCount=%d\n", + c.State.Status, c.State.ExitCode, c.State.OOMKilled, c.RestartCount) + if c.Config.Image != "" { + fmt.Fprintf(s.out, "镜像: %s\n", c.Config.Image) + } + if c.State.Health != nil && c.State.Health.Status != "" { + fmt.Fprintf(s.out, "健康: %s\n", c.State.Health.Status) + } + if c.State.StartedAt != "" && !strings.HasPrefix(c.State.StartedAt, "0001-") { + fmt.Fprintf(s.out, "时间: %s", c.State.StartedAt) + if c.State.FinishedAt != "" && !strings.HasPrefix(c.State.FinishedAt, "0001-") && c.State.FinishedAt != c.State.StartedAt { + fmt.Fprintf(s.out, " → %s", c.State.FinishedAt) + } + fmt.Fprintln(s.out) + } + s.renderFinding("", judgeDocker(c)) + + if !opts.NoLogs { + logRes, err := s.run(ctx, src.program, "logs", "--tail", strconv.Itoa(opts.Lines), "--timestamps", target) + if err != nil { + fmt.Fprintf(s.errOut, "警告: 获取日志失败: %v\n", err) + } else { + s.writeSection(fmt.Sprintf("最近日志 (%d 行)", opts.Lines), string(logRes.Stdout)) + } + } + + if !opts.NoEvents { + now := s.now() + since := now.Add(-time.Hour).UTC().Format(time.RFC3339) + until := now.UTC().Format(time.RFC3339) + eventRes, err := s.run(ctx, src.program, "events", + "--since", since, "--until", until, "--filter", "container="+name) + if err != nil { + fmt.Fprintf(s.errOut, "警告: 获取事件失败: %v\n", err) + } else { + s.writeSection("最近事件 (1h)", tailLines(string(eventRes.Stdout), 20)) + } + } + + return nil +} + +// judgeDocker maps container state to a human-readable root cause. +func judgeDocker(c dockerContainer) Finding { + st := c.State + evidence := "State.Status=" + st.Status + if st.Status == "exited" || st.ExitCode != 0 { + evidence += fmt.Sprintf(", ExitCode=%d", st.ExitCode) + } + if st.OOMKilled { + evidence += ", OOMKilled=true" + } + if c.RestartCount > 0 { + evidence += fmt.Sprintf(", RestartCount=%d", c.RestartCount) + } + if st.Error != "" { + evidence += ", Error=" + truncate(st.Error, 200) + } + + switch { + case st.Restarting || st.Status == "restarting": + return Finding{ + Severity: "error", + Verdict: "容器处于重启循环,正在反复启动", + Evidence: evidence, + Advice: "查看最近日志与上一次退出码,确认应用是否启动即崩溃或依赖不可用", + } + + case st.Status == "exited": + switch { + case st.OOMKilled: + return Finding{ + Severity: "error", + Verdict: "容器因内存超限被 OOM kill", + Evidence: evidence, + Advice: "提高容器的内存限制,或降低应用峰值内存;可用 kopsc docker stats 观察", + } + case st.ExitCode == 137: + return Finding{ + Severity: "error", + Verdict: "容器收到 SIGKILL(137),可能被手动 kill 或 stop 超时后强杀", + Evidence: evidence, + Advice: "若并非人为操作,检查宿主机 OOM 日志与 stop 超时设置", + } + case st.ExitCode == 143: + return Finding{ + Severity: "warn", + Verdict: "容器收到 SIGTERM(143)后退出,通常是正常停止", + Evidence: evidence, + Advice: "若预期继续运行,检查是谁发送了停止信号或重启策略", + } + case st.ExitCode == 126: + return Finding{ + Severity: "error", + Verdict: "容器内命令不可执行(126):权限不足或可执行格式错误", + Evidence: evidence, + Advice: "检查 entrypoint/cmd 是否可执行、是否缺少可执行权限", + } + case st.ExitCode == 127: + return Finding{ + Severity: "error", + Verdict: "容器内命令不存在(127)", + Evidence: evidence, + Advice: "检查 entrypoint/cmd 与镜像内实际的二进制路径", + } + case st.ExitCode == 0: + if c.RestartCount > 0 { + return Finding{ + Severity: "warn", + Verdict: "容器正常退出,但已多次重启,重启策略可能导致循环", + Evidence: evidence, + Advice: "若是任务型容器,确认重启策略应为 no/on-failure 而不是 always", + } + } + return Finding{ + Severity: "ok", + Verdict: "容器已正常退出(ExitCode=0)", + Evidence: evidence, + } + default: + return Finding{ + Severity: "error", + Verdict: "容器以非零状态退出,应用自身报错", + Evidence: evidence, + Advice: "查看下方日志的末尾,定位应用错误;确认命令与配置参数", + } + } + + case st.Status == "running": + if st.Paused { + return Finding{ + Severity: "warn", + Verdict: "容器处于暂停(paused)状态", + Evidence: evidence, + Advice: "确认是否有人执行了 docker pause;唤醒使用 docker unpause", + } + } + if st.Health != nil && strings.EqualFold(st.Health.Status, "unhealthy") { + return Finding{ + Severity: "error", + Verdict: "容器健康检查失败(unhealthy)", + Evidence: evidence + ", State.Health.Status=unhealthy", + Advice: "查看日志与 healthcheck 命令;确认依赖服务可用、检查间隔与超时合理", + } + } + return Finding{ + Severity: "ok", + Verdict: "容器正在运行", + Evidence: evidence, + } + + case st.Status == "created": + return Finding{ + Severity: "warn", + Verdict: "容器已创建但尚未启动", + Evidence: evidence, + Advice: "使用 kopsc docker start <容器> 启动;若启动失败查看 docker events", + } + + case st.Status == "paused": + return Finding{ + Severity: "warn", + Verdict: "容器已暂停", + Evidence: evidence, + } + + case st.Status == "dead": + return Finding{ + Severity: "error", + Verdict: "容器处于 dead 状态,运行时未能正常清理", + Evidence: evidence, + Advice: "通常需要删除后重新创建容器;检查存储驱动与宿主资源", + } + } + + return Finding{ + Severity: "warn", + Verdict: "容器状态未识别:" + st.Status, + Evidence: evidence, + } +} + +func truncate(s string, max int) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + if len(s) <= max { + return s + } + return s[:max] + "…" +} diff --git a/internal/diagnose/docker_test.go b/internal/diagnose/docker_test.go new file mode 100644 index 0000000..88856fb --- /dev/null +++ b/internal/diagnose/docker_test.go @@ -0,0 +1,113 @@ +package diagnose + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestJudgeDocker(t *testing.T) { + unhealthy := sampleDocker("running", 0, false, 0) + unhealthy.State.Health = &dockerHealth{Status: "unhealthy"} + + cases := []struct { + name string + container dockerContainer + wantSeverity string + wantContains string + }{ + {"oom killed", sampleDocker("exited", 137, true, 3), "error", "OOM"}, + {"sigkill", sampleDocker("exited", 137, false, 0), "error", "SIGKILL"}, + {"sigterm", sampleDocker("exited", 143, false, 0), "warn", "SIGTERM"}, + {"normal exit", sampleDocker("exited", 0, false, 0), "ok", "正常退出"}, + {"normal exit with restarts", sampleDocker("exited", 0, false, 2), "warn", "重启"}, + {"application error", sampleDocker("exited", 1, false, 0), "error", "非零"}, + {"not executable", sampleDocker("exited", 126, false, 0), "error", "126"}, + {"missing command", sampleDocker("exited", 127, false, 0), "error", "127"}, + {"running", sampleDocker("running", 0, false, 0), "ok", "运行"}, + {"unhealthy", unhealthy, "error", "健康检查失败"}, + {"restarting", sampleDocker("restarting", 0, false, 5), "error", "重启循环"}, + {"created", sampleDocker("created", 0, false, 0), "warn", "未启动"}, + {"dead", sampleDocker("dead", 0, false, 0), "error", "dead"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := judgeDocker(tc.container) + 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 TestDiagnoseDockerOutput(t *testing.T) { + inspectJSON := `[{"Id":"abc","Name":"/web","RestartCount":3,"Config":{"Image":"nginx:1.25"},"State":{"Status":"exited","ExitCode":137,"OOMKilled":true,"StartedAt":"2026-10-31T10:00:00Z","FinishedAt":"2026-10-31T10:05:00Z"}}]` + + r := &fakeRunner{ + available: map[string]bool{"docker": true}, + respond: func(name string, args []string) (CommandResult, error) { + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte(inspectJSON)}, nil + case len(args) > 0 && args[0] == "logs": + return CommandResult{Stdout: []byte("line1\nline2\n")}, nil + case len(args) > 0 && args[0] == "events": + return CommandResult{Stdout: []byte("die event\n")}, nil + } + return CommandResult{}, fmt.Errorf("unexpected call %s %v", name, args) + }, + } + + s, out, errOut := newTestService(r) + if err := s.Run(context.Background(), []string{"web"}); err != nil { + t.Fatalf("Run error: %v (stderr=%s)", err, errOut.String()) + } + + text := out.String() + for _, want := range []string{ + "目标: web", + "来源: Docker (docker)", + "ExitCode=137", + "OOMKilled=true", + "OOM", + "依据:", + "建议:", + "最近日志 (50 行)", + "line1", + "最近事件 (1h)", + "die event", + } { + if !strings.Contains(text, want) { + t.Errorf("output missing %q:\n%s", want, text) + } + } +} + +func TestDiagnoseDockerHonoursNoLogsNoEvents(t *testing.T) { + inspectJSON := `[{"Id":"abc","Name":"/web","State":{"Status":"running"}}]` + r := &fakeRunner{ + available: map[string]bool{"docker": true}, + respond: func(name string, args []string) (CommandResult, error) { + return CommandResult{Stdout: []byte(inspectJSON)}, nil + }, + } + + s, _, _ := newTestService(r) + if err := s.Run(context.Background(), []string{"web", "--no-logs", "--no-events"}); err != nil { + t.Fatalf("Run error: %v", err) + } + if r.called("logs") { + t.Error("logs should not be collected") + } + if r.called("events") { + t.Error("events should not be collected") + } +} diff --git a/internal/diagnose/helpers_test.go b/internal/diagnose/helpers_test.go new file mode 100644 index 0000000..9d8fe40 --- /dev/null +++ b/internal/diagnose/helpers_test.go @@ -0,0 +1,60 @@ +package diagnose + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" +) + +// fakeRunner records calls and returns canned output. +type fakeRunner struct { + available map[string]bool + calls []string + respond func(name string, args []string) (CommandResult, error) +} + +func (f *fakeRunner) LookPath(name string) (string, error) { + if f.available[name] { + return "/usr/bin/" + name, nil + } + return "", fmt.Errorf("%s not found", name) +} + +func (f *fakeRunner) Run(ctx context.Context, name string, args ...string) (CommandResult, error) { + f.calls = append(f.calls, name+" "+strings.Join(args, " ")) + if f.respond == nil { + return CommandResult{}, nil + } + return f.respond(name, args) +} + +func (f *fakeRunner) called(substr string) bool { + for _, call := range f.calls { + if strings.Contains(call, substr) { + return true + } + } + return false +} + +func newTestService(r Runner) (*Service, *bytes.Buffer, *bytes.Buffer) { + out, errOut := &bytes.Buffer{}, &bytes.Buffer{} + s := New(platform.Info{OS: "linux", Arch: "amd64"}, r, out, errOut) + s.now = func() time.Time { return time.Date(2026, 10, 31, 12, 0, 0, 0, time.UTC) } + return s, out, errOut +} + +// sampleDocker builds an exited container. +func sampleDocker(status string, exitCode int, oom bool, restarts int) dockerContainer { + c := dockerContainer{ID: "abc", Name: "/web", RestartCount: restarts} + c.Config.Image = "nginx:1.25" + c.State.Status = status + c.State.ExitCode = exitCode + c.State.OOMKilled = oom + c.State.Running = status == "running" + return c +} diff --git a/internal/diagnose/k8s.go b/internal/diagnose/k8s.go new file mode 100644 index 0000000..ee398cc --- /dev/null +++ b/internal/diagnose/k8s.go @@ -0,0 +1,372 @@ +package diagnose + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type pod struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"metadata"` + Status struct { + Phase string `json:"phase"` + Reason string `json:"reason"` + Message string `json:"message"` + Conditions []podCondition `json:"conditions"` + ContainerStatuses []containerStatus `json:"containerStatuses"` + } `json:"status"` +} + +type podCondition struct { + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` +} + +type containerStatus struct { + Name string `json:"name"` + Ready bool `json:"ready"` + RestartCount int `json:"restartCount"` + State podState `json:"state"` + LastState podState `json:"lastState"` +} + +type podState struct { + Waiting *podWaiting `json:"waiting"` + Running *podRunning `json:"running"` + Terminated *podTerminated `json:"terminated"` +} + +type podWaiting struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type podRunning struct { + StartedAt string `json:"startedAt"` +} + +type podTerminated struct { + Reason string `json:"reason"` + ExitCode int `json:"exitCode"` + Message string `json:"message"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` +} + +func (s *Service) diagnoseK8s(ctx context.Context, src source, target string, opts Options) error { + if _, err := s.runner.LookPath("kubectl"); err != nil { + return fmt.Errorf("未找到 kubectl,请先安装并配置 kubeconfig") + } + + namespace := src.namespace + if namespace == "" { + namespace, _ = s.kubeNamespace(ctx, src.context) + } + + res, err := s.run(ctx, "kubectl", kubectlArgs(src.context, "get", "pod", target, "-n", namespace, "-o", "json")...) + if err != nil { + return err + } + + var p pod + if err := json.Unmarshal(res.Stdout, &p); err != nil { + return fmt.Errorf("解析 kubectl get pod 输出失败: %w", err) + } + + name := p.Metadata.Name + if name == "" { + name = target + } + if p.Metadata.Namespace != "" { + namespace = p.Metadata.Namespace + } + + contextLabel := src.context + if contextLabel == "" { + contextLabel = "当前上下文" + } + + ready, total, restarts := 0, len(p.Status.ContainerStatuses), 0 + for _, cs := range p.Status.ContainerStatuses { + if cs.Ready { + ready++ + } + restarts += cs.RestartCount + } + + fmt.Fprintf(s.out, "目标: %s\n", name) + fmt.Fprintf(s.out, "来源: Kubernetes context=%s namespace=%s\n", contextLabel, namespace) + fmt.Fprintf(s.out, "状态: phase=%s ready=%d/%d restarts=%d\n", p.Status.Phase, ready, total, restarts) + s.renderFinding("", judgePod(p)) + + selected := p.Status.ContainerStatuses + if opts.Container != "" { + selected = nil + for _, cs := range p.Status.ContainerStatuses { + if cs.Name == opts.Container { + selected = append(selected, cs) + } + } + if len(selected) == 0 { + fmt.Fprintf(s.errOut, "警告: Pod %s 中没有名为 %q 的容器\n", name, opts.Container) + } + } + + for _, cs := range selected { + fmt.Fprintf(s.out, "\n容器 %s:\n", cs.Name) + fmt.Fprintf(s.out, " 状态: %s ready=%t restarts=%d\n", containerState(cs), cs.Ready, cs.RestartCount) + s.renderFinding(" ", judgeContainer(cs)) + if !opts.NoLogs { + s.writeContainerLogs(ctx, src, name, namespace, cs, opts) + } + } + + if !opts.NoEvents { + eventRes, err := s.run(ctx, "kubectl", kubectlArgs(src.context, + "get", "events", "-n", namespace, + "--field-selector", "involvedObject.name="+name, + "--sort-by=.lastTimestamp")...) + if err != nil { + fmt.Fprintf(s.errOut, "警告: 获取事件失败: %v\n", err) + } else { + events := tailLines(string(eventRes.Stdout), 20) + s.writeSection(fmt.Sprintf("最近事件 (namespace=%s)", namespace), events) + if hint := eventHint(events); hint != "" { + fmt.Fprintf(s.out, "\n事件提示: %s\n", hint) + } + } + } + + return nil +} + +// judgePod reports Pod-level health; per-container verdicts follow separately. +func judgePod(p pod) Finding { + evidence := "status.phase=" + p.Status.Phase + if p.Status.Reason != "" { + evidence += ", reason=" + p.Status.Reason + } + + switch p.Status.Phase { + case "Pending": + return Finding{ + Severity: "warn", + Verdict: "Pod 处于 Pending:尚未成功调度或容器还未创建", + Evidence: evidence, + Advice: "查看下方事件:常见原因是资源不足、亲和性/污点不满足、PVC 未绑定或镜像拉取慢", + } + case "Failed": + return Finding{ + Severity: "error", + Verdict: "Pod 已失败", + Evidence: evidence, + Advice: "结合下方容器状态、上一次日志与事件定位失败原因", + } + case "Succeeded": + return Finding{ + Severity: "ok", + Verdict: "Pod 已正常完成", + Evidence: evidence, + } + case "Running": + return Finding{ + Severity: "ok", + Verdict: "Pod 已调度并在运行(容器级状态见下)", + Evidence: evidence, + } + default: + return Finding{ + Severity: "warn", + Verdict: "Pod 状态未识别:" + p.Status.Phase, + Evidence: evidence, + } + } +} + +// judgeContainer maps containerStatus to a root cause. +func judgeContainer(cs containerStatus) Finding { + if cs.State.Waiting != nil { + w := cs.State.Waiting + evidence := "state.waiting.reason=" + w.Reason + if w.Message != "" { + evidence += ", message=" + truncate(w.Message, 200) + } + + switch w.Reason { + case "CrashLoopBackOff": + if cs.LastState.Terminated != nil { + t := cs.LastState.Terminated + evidence += fmt.Sprintf(", lastState.reason=%s, lastState.exitCode=%d", t.Reason, t.ExitCode) + } + return Finding{ + Severity: "error", + Verdict: "容器反复崩溃并重启(CrashLoopBackOff)", + Evidence: evidence, + Advice: "查看下方“上一次”日志与退出码;检查启动命令、依赖服务与资源限制", + } + case "ImagePullBackOff", "ErrImagePull": + return Finding{ + Severity: "error", + Verdict: "镜像拉取失败(" + w.Reason + ")", + Evidence: evidence, + Advice: "检查镜像名与 tag、imagePullSecrets、仓库可达性与节点网络", + } + case "CreateContainerConfigError": + return Finding{ + Severity: "error", + Verdict: "容器配置错误:ConfigMap/Secret 缺失或键不存在", + Evidence: evidence, + Advice: "核对该 Pod 引用的 ConfigMap/Secret 是否存在,键名是否正确", + } + case "CreateContainerError", "RunContainerError": + return Finding{ + Severity: "error", + Verdict: "容器创建/启动失败(" + w.Reason + ")", + Evidence: evidence, + Advice: "检查容器运行时与事件中的具体错误(挂载、cgroup、权限)", + } + case "ContainerCreating": + return Finding{ + Severity: "warn", + Verdict: "容器正在创建", + Evidence: evidence, + Advice: "通常会自动完成;若长时间不变,查看事件中的挂载/镜像错误", + } + case "PodInitializing": + return Finding{ + Severity: "warn", + Verdict: "初始化容器尚未完成", + Evidence: evidence, + Advice: "查看 initContainer 的日志与状态", + } + default: + return Finding{ + Severity: "warn", + Verdict: "容器处于等待状态:" + w.Reason, + Evidence: evidence, + } + } + } + + if cs.State.Terminated != nil { + t := cs.State.Terminated + evidence := fmt.Sprintf("state.terminated.reason=%s, exitCode=%d", t.Reason, t.ExitCode) + if t.Message != "" { + evidence += ", message=" + truncate(t.Message, 200) + } + + switch t.Reason { + case "OOMKilled": + return Finding{ + Severity: "error", + Verdict: "容器因内存超限被 OOMKilled", + Evidence: evidence, + Advice: "提高 resources.limits.memory,或降低应用内存占用", + } + case "Completed": + return Finding{ + Severity: "ok", + Verdict: "容器已正常结束(Completed)", + Evidence: evidence, + } + case "Error": + return Finding{ + Severity: "error", + Verdict: "容器以非零状态退出(Error)", + Evidence: evidence, + Advice: "查看下方日志末尾定位应用错误", + } + default: + return Finding{ + Severity: "warn", + Verdict: "容器已终止:" + t.Reason, + Evidence: evidence, + } + } + } + + if cs.State.Running != nil { + evidence := "state.running" + if cs.LastState.Terminated != nil { + t := cs.LastState.Terminated + evidence += fmt.Sprintf(", lastState.reason=%s, lastState.exitCode=%d", t.Reason, t.ExitCode) + } + if !cs.Ready { + return Finding{ + Severity: "warn", + Verdict: "容器运行中但未就绪(就绪探针未通过)", + Evidence: evidence + ", ready=false", + Advice: "检查 readinessProbe 配置、启动时间与依赖服务", + } + } + return Finding{ + Severity: "ok", + Verdict: "容器运行中且已就绪", + Evidence: evidence + ", ready=true", + } + } + + return Finding{ + Severity: "warn", + Verdict: "容器状态未知(state 为空)", + Evidence: "state 为空", + Advice: "查看 kubectl describe pod 与事件", + } +} + +func containerState(cs containerStatus) string { + switch { + case cs.State.Waiting != nil: + return "waiting(" + cs.State.Waiting.Reason + ")" + case cs.State.Terminated != nil: + return "terminated(" + cs.State.Terminated.Reason + ")" + case cs.State.Running != nil: + return "running" + default: + return "unknown" + } +} + +func (s *Service) writeContainerLogs(ctx context.Context, src source, podName, namespace string, cs containerStatus, opts Options) { + base := []string{"logs", podName, "-n", namespace, "-c", cs.Name, "--tail", strconv.Itoa(opts.Lines)} + + res, err := s.run(ctx, "kubectl", kubectlArgs(src.context, base...)...) + if err != nil { + fmt.Fprintf(s.errOut, "警告: 获取容器 %s 日志失败: %v\n", cs.Name, err) + } else { + s.writeSection(fmt.Sprintf("日志 (%s, 最近 %d 行)", cs.Name, opts.Lines), string(res.Stdout)) + } + + if cs.LastState.Terminated != nil { + previous := append(append([]string{}, base...), "--previous") + res, err := s.run(ctx, "kubectl", kubectlArgs(src.context, previous...)...) + if err != nil { + fmt.Fprintf(s.errOut, "警告: 获取容器 %s 上一次日志失败: %v\n", cs.Name, err) + } else { + s.writeSection(fmt.Sprintf("日志 (%s, 上一次)", cs.Name), string(res.Stdout)) + } + } +} + +// eventHint turns common event fragments into an actionable hint. +func eventHint(events string) string { + lower := strings.ToLower(events) + switch { + case strings.Contains(lower, "failedscheduling"), strings.Contains(lower, "insufficient"): + return "事件显示调度失败:检查节点资源、亲和性/反亲和性、污点与 PVC 绑定" + case strings.Contains(lower, "failedmount"), strings.Contains(lower, "failedattachvolume"): + return "事件显示存储挂载失败:检查 PVC/PV、StorageClass 与 ConfigMap/Secret 挂载" + case strings.Contains(lower, "unhealthy"): + return "事件显示探针失败:检查 liveness/readiness 探针路径、端口与启动时间" + case strings.Contains(lower, "backoff"): + return "事件显示退避重试:结合容器退出码与日志确认根因" + } + return "" +} diff --git a/internal/diagnose/k8s_test.go b/internal/diagnose/k8s_test.go new file mode 100644 index 0000000..bd5866a --- /dev/null +++ b/internal/diagnose/k8s_test.go @@ -0,0 +1,171 @@ +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) + } +} diff --git a/internal/diagnose/options.go b/internal/diagnose/options.go new file mode 100644 index 0000000..be5c41b --- /dev/null +++ b/internal/diagnose/options.go @@ -0,0 +1,141 @@ +package diagnose + +import ( + "fmt" + "strconv" + "strings" +) + +// Options controls what a diagnosis collects. +type Options struct { + Kind string // "", "docker" or "k8s" + Namespace string + Context string + Lines int + NoLogs bool + NoEvents bool + Container string +} + +// DefaultLines is the number of log lines collected by default. +const DefaultLines = 50 + +// ParseArgs parses the arguments forwarded by the CLI. It returns the target +// (container or Pod name) and the collection options. +func ParseArgs(args []string) (string, Options, error) { + opts := Options{Lines: DefaultLines} + var targets []string + positionalOnly := false + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case positionalOnly: + targets = append(targets, arg) + + case arg == "--": + positionalOnly = true + + case arg == "--kind" || arg == "--kind=": + value, err := valueFor(arg, args, &i, "--kind") + if err != nil { + return "", opts, err + } + opts.Kind = strings.ToLower(value) + if opts.Kind != "docker" && opts.Kind != "k8s" { + return "", opts, fmt.Errorf("--kind 只支持 docker 或 k8s,收到 %q", value) + } + + case strings.HasPrefix(arg, "--kind="): + opts.Kind = strings.ToLower(strings.TrimPrefix(arg, "--kind=")) + if opts.Kind != "docker" && opts.Kind != "k8s" { + return "", opts, fmt.Errorf("--kind 只支持 docker 或 k8s,收到 %q", opts.Kind) + } + + case arg == "--namespace" || arg == "-N": + value, err := valueFor(arg, args, &i, arg) + if err != nil { + return "", opts, err + } + opts.Namespace = value + + case strings.HasPrefix(arg, "--namespace="): + opts.Namespace = strings.TrimPrefix(arg, "--namespace=") + + case arg == "--context": + value, err := valueFor(arg, args, &i, arg) + if err != nil { + return "", opts, err + } + opts.Context = value + + case strings.HasPrefix(arg, "--context="): + opts.Context = strings.TrimPrefix(arg, "--context=") + + case arg == "--lines": + value, err := valueFor(arg, args, &i, arg) + if err != nil { + return "", opts, err + } + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return "", opts, fmt.Errorf("--lines 需要正整数,收到 %q", value) + } + opts.Lines = n + + case strings.HasPrefix(arg, "--lines="): + n, err := strconv.Atoi(strings.TrimPrefix(arg, "--lines=")) + if err != nil || n <= 0 { + return "", opts, fmt.Errorf("--lines 需要正整数,收到 %q", strings.TrimPrefix(arg, "--lines=")) + } + opts.Lines = n + + case arg == "--no-logs": + opts.NoLogs = true + + case arg == "--no-events": + opts.NoEvents = true + + case arg == "--container" || arg == "-c": + value, err := valueFor(arg, args, &i, arg) + if err != nil { + return "", opts, err + } + opts.Container = value + + case strings.HasPrefix(arg, "--container="): + opts.Container = strings.TrimPrefix(arg, "--container=") + + case strings.HasPrefix(arg, "-") && arg != "-": + return "", opts, fmt.Errorf("未知选项 %q", arg) + + default: + targets = append(targets, arg) + } + } + + if len(targets) == 0 { + return "", opts, fmt.Errorf("必须提供容器名或 Pod 名,例如 kopsc diagnose web") + } + if len(targets) > 1 { + return "", opts, fmt.Errorf("一次只能诊断一个目标,收到 %d 个:%s", len(targets), strings.Join(targets, ", ")) + } + + if opts.Kind == "docker" && (opts.Namespace != "" || opts.Context != "") { + return "", opts, fmt.Errorf("Docker 诊断不支持 --namespace/--context;请改用 --kind k8s") + } + + return targets[0], opts, nil +} + +// valueFor reads the value of a flag written as "--flag value" or "--flag=". +func valueFor(flag string, args []string, index *int, name string) (string, error) { + if strings.HasSuffix(flag, "=") { + return "", fmt.Errorf("%s 缺少参数", name) + } + if *index+1 >= len(args) { + return "", fmt.Errorf("%s 缺少参数,例如 %s value", name, name) + } + *index = *index + 1 + return args[*index], nil +} diff --git a/internal/diagnose/options_test.go b/internal/diagnose/options_test.go new file mode 100644 index 0000000..79d26f1 --- /dev/null +++ b/internal/diagnose/options_test.go @@ -0,0 +1,99 @@ +package diagnose + +import ( + "strings" + "testing" +) + +func TestParseArgs(t *testing.T) { + cases := []struct { + name string + args []string + wantTarget string + wantOpts Options + wantErr string + }{ + { + name: "target only", + args: []string{"web"}, + wantTarget: "web", + wantOpts: Options{Lines: DefaultLines}, + }, + { + name: "namespace", + args: []string{"api-0", "--namespace", "prod"}, + wantTarget: "api-0", + wantOpts: Options{Lines: DefaultLines, Namespace: "prod"}, + }, + { + name: "short namespace", + args: []string{"api-0", "-N", "prod"}, + wantTarget: "api-0", + wantOpts: Options{Lines: DefaultLines, Namespace: "prod"}, + }, + { + name: "namespace equals", + args: []string{"api-0", "--namespace=prod"}, + wantTarget: "api-0", + wantOpts: Options{Lines: DefaultLines, Namespace: "prod"}, + }, + { + name: "context and lines", + args: []string{"api-0", "--context", "staging", "--lines", "200"}, + wantTarget: "api-0", + wantOpts: Options{Lines: 200, Context: "staging"}, + }, + { + name: "flags", + args: []string{"web", "--no-logs", "--no-events", "--container", "app"}, + wantTarget: "web", + wantOpts: Options{Lines: DefaultLines, NoLogs: true, NoEvents: true, Container: "app"}, + }, + { + name: "short container", + args: []string{"web", "-c", "app"}, + wantTarget: "web", + wantOpts: Options{Lines: DefaultLines, Container: "app"}, + }, + { + name: "kind", + args: []string{"web", "--kind", "docker"}, + wantTarget: "web", + wantOpts: Options{Lines: DefaultLines, Kind: "docker"}, + }, + { + name: "after separator", + args: []string{"--", "-n"}, + wantTarget: "-n", + wantOpts: Options{Lines: DefaultLines}, + }, + {name: "missing target", args: nil, wantErr: "必须提供"}, + {name: "two targets", args: []string{"a", "b"}, wantErr: "一次只能诊断一个"}, + {name: "bad kind", args: []string{"a", "--kind", "nomad"}, wantErr: "只支持 docker 或 k8s"}, + {name: "docker with namespace", args: []string{"a", "--kind", "docker", "-N", "prod"}, wantErr: "不支持 --namespace"}, + {name: "bad lines", args: []string{"a", "--lines", "0"}, wantErr: "正整数"}, + {name: "unknown option", args: []string{"a", "--wat"}, wantErr: "未知选项"}, + {name: "missing value", args: []string{"a", "--namespace"}, wantErr: "缺少参数"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + target, opts, err := ParseArgs(tc.args) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target != tc.wantTarget { + t.Errorf("target = %q, want %q", target, tc.wantTarget) + } + if opts != tc.wantOpts { + t.Errorf("options = %+v, want %+v", opts, tc.wantOpts) + } + }) + } +} diff --git a/internal/diagnose/runner.go b/internal/diagnose/runner.go new file mode 100644 index 0000000..60033e1 --- /dev/null +++ b/internal/diagnose/runner.go @@ -0,0 +1,54 @@ +// Package diagnose explains why a container or Pod is in its current state. +// It knows how to inspect Docker/Podman containers and Kubernetes Pods, and +// falls back to a built-in implementation so the output is identical on every +// platform. +package diagnose + +import ( + "bytes" + "context" + "errors" + "os/exec" +) + +// CommandResult is the output of one external command. +type CommandResult struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +// Runner executes external programs. It is an interface so diagnoses can be +// tested without docker or kubectl. +type Runner interface { + LookPath(name string) (string, error) + Run(ctx context.Context, name string, args ...string) (CommandResult, error) +} + +// ExecRunner runs commands on the host. +type ExecRunner struct{} + +// LookPath reports whether a program is available on PATH. +func (ExecRunner) LookPath(name string) (string, error) { + return exec.LookPath(name) +} + +// Run executes a command and captures its output. +func (ExecRunner) Run(ctx context.Context, name string, args ...string) (CommandResult, error) { + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + if err == nil { + return result, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } + return result, err +} diff --git a/internal/diagnose/service.go b/internal/diagnose/service.go new file mode 100644 index 0000000..4522c61 --- /dev/null +++ b/internal/diagnose/service.go @@ -0,0 +1,230 @@ +package diagnose + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" +) + +// Finding is one judgement about a container. +type Finding struct { + Severity string // "ok", "warn" or "error" + Verdict string + Evidence string + Advice string +} + +// Service collects and renders diagnoses. +type Service struct { + info platform.Info + runner Runner + out io.Writer + errOut io.Writer + now func() time.Time +} + +// New builds a Service. runner is usually ExecRunner; tests inject a fake. +func New(info platform.Info, runner Runner, out, errOut io.Writer) *Service { + if runner == nil { + runner = ExecRunner{} + } + if out == nil { + out = io.Discard + } + if errOut == nil { + errOut = io.Discard + } + return &Service{info: info, runner: runner, out: out, errOut: errOut, now: time.Now} +} + +// Run parses the arguments forwarded by kopsc and diagnoses the target. +func (s *Service) Run(ctx context.Context, args []string) error { + target, opts, err := ParseArgs(args) + if err != nil { + return err + } + return s.diagnose(ctx, target, opts) +} + +type source struct { + kind string // "docker" or "k8s" + program string // docker or podman + namespace string + context string +} + +func (s *Service) diagnose(ctx context.Context, target string, opts Options) error { + src, err := s.resolve(ctx, target, opts) + if err != nil { + return err + } + switch src.kind { + case "docker": + return s.diagnoseDocker(ctx, src, target, opts) + case "k8s": + return s.diagnoseK8s(ctx, src, target, opts) + default: + return fmt.Errorf("未知诊断来源 %q", src.kind) + } +} + +// resolve decides whether the target is a container or a Pod. +func (s *Service) resolve(ctx context.Context, target string, opts Options) (source, error) { + switch opts.Kind { + case "docker": + program, err := s.dockerProgram() + if err != nil { + return source{}, err + } + return source{kind: "docker", program: program}, nil + + case "k8s": + ns := opts.Namespace + if ns == "" { + ns, _ = s.kubeNamespace(ctx, opts.Context) + } + return source{kind: "k8s", namespace: ns, context: opts.Context}, nil + } + + // Providing namespace/context is an explicit signal for Kubernetes. + if opts.Namespace != "" || opts.Context != "" { + ns := opts.Namespace + if ns == "" { + ns, _ = s.kubeNamespace(ctx, opts.Context) + } + return source{kind: "k8s", namespace: ns, context: opts.Context}, nil + } + + var dockerErr error + if program, err := s.dockerProgram(); err == nil { + if res, err := s.run(ctx, program, "inspect", "--type", "container", target); err == nil { + var items []json.RawMessage + if json.Unmarshal(res.Stdout, &items) == nil && len(items) > 0 { + return source{kind: "docker", program: program}, nil + } + dockerErr = fmt.Errorf("Docker 中没有名为 %q 的容器", target) + } else { + dockerErr = err + } + } else { + dockerErr = err + } + + if _, err := s.runner.LookPath("kubectl"); err == nil { + ns, _ := s.kubeNamespace(ctx, opts.Context) + if res, err := s.run(ctx, "kubectl", kubectlArgs(opts.Context, "get", "pod", target, "-n", ns, "-o", "name")...); err == nil && + strings.TrimSpace(string(res.Stdout)) != "" { + return source{kind: "k8s", namespace: ns, context: opts.Context}, nil + } + return source{}, fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns) + } + + return source{}, fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr) +} + +func (s *Service) dockerProgram() (string, error) { + for _, name := range []string{"docker", "podman"} { + if _, err := s.runner.LookPath(name); err == nil { + return name, nil + } + } + for _, name := range s.info.Runtimes { + if name == "docker" || name == "podman" { + return name, nil + } + } + return "", fmt.Errorf("未找到 docker 或 podman") +} + +// run executes a command with a per-command timeout. +func (s *Service) run(ctx context.Context, name string, args ...string) (CommandResult, error) { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + res, err := s.runner.Run(callCtx, name, args...) + if err != nil { + message := summarizeStderr(string(res.Stderr)) + if message == "" { + message = err.Error() + } + return res, fmt.Errorf("%s %s 失败: %s", name, strings.Join(args, " "), message) + } + return res, nil +} + +// summarizeStderr keeps the actionable last line of a multi-line error (kubectl +// prints its connection error last) and trims the rest. +func summarizeStderr(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + lines := strings.Split(raw, "\n") + return truncate(strings.TrimSpace(lines[len(lines)-1]), 300) +} + +func kubectlArgs(context string, args ...string) []string { + if context == "" { + return args + } + out := make([]string, 0, 2+len(args)) + out = append(out, "--context", context) + return append(out, args...) +} + +// kubeNamespace resolves the effective namespace: --namespace, then the +// context's kubeconfig namespace, then "default". +func (s *Service) kubeNamespace(ctx context.Context, context string) (string, error) { + res, err := s.run(ctx, "kubectl", kubectlArgs(context, "config", "view", "--minify", "-o", "jsonpath={..namespace}")...) + if err != nil { + return "default", err + } + ns := strings.TrimSpace(string(res.Stdout)) + if ns == "" { + return "default", nil + } + return ns, nil +} + +func (s *Service) renderFinding(indent string, f Finding) { + icon := "✅" + switch f.Severity { + case "error": + icon = "❌" + case "warn": + icon = "⚠️" + } + fmt.Fprintf(s.out, "%s判定: %s %s\n", indent, icon, f.Verdict) + if f.Evidence != "" { + fmt.Fprintf(s.out, "%s依据: %s\n", indent, f.Evidence) + } + if f.Advice != "" { + fmt.Fprintf(s.out, "%s建议: %s\n", indent, f.Advice) + } +} + +func (s *Service) writeSection(title, content string) { + fmt.Fprintf(s.out, "\n%s:\n", title) + if strings.TrimSpace(content) == "" { + fmt.Fprintln(s.out, "(无)") + return + } + fmt.Fprintln(s.out, content) +} + +func tailLines(text string, n int) string { + text = strings.TrimRight(text, "\n") + if strings.TrimSpace(text) == "" { + return "" + } + lines := strings.Split(text, "\n") + if n > 0 && len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/internal/diagnose/service_test.go b/internal/diagnose/service_test.go new file mode 100644 index 0000000..63db7ed --- /dev/null +++ b/internal/diagnose/service_test.go @@ -0,0 +1,140 @@ +package diagnose + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestResolveAutoDocker(t *testing.T) { + inspectJSON := `[{"Id":"abc","Name":"/web","State":{"Status":"running"}}]` + r := &fakeRunner{ + available: map[string]bool{"docker": true, "kubectl": true}, + respond: func(name string, args []string) (CommandResult, error) { + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte(inspectJSON)}, nil + case len(args) > 0 && args[0] == "logs": + return CommandResult{Stdout: []byte("ok\n")}, nil + case len(args) > 0 && args[0] == "events": + return CommandResult{}, nil + } + return CommandResult{}, fmt.Errorf("unexpected call %s %v", name, args) + }, + } + + s, out, _ := newTestService(r) + if err := s.Run(context.Background(), []string{"web"}); err != nil { + t.Fatalf("Run error: %v", err) + } + if !strings.Contains(out.String(), "来源: Docker (docker)") { + t.Errorf("expected docker source:\n%s", out.String()) + } + if r.called("kubectl") { + t.Error("kubectl should not be consulted when docker hits") + } +} + +func TestResolveAutoKubernetes(t *testing.T) { + r := &fakeRunner{ + available: map[string]bool{"docker": true, "kubectl": true}, + respond: func(name string, args []string) (CommandResult, error) { + joined := strings.Join(args, " ") + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte("[]")}, nil + case strings.HasPrefix(joined, "config view"): + return CommandResult{}, nil + case strings.HasPrefix(joined, "get pod"): + return CommandResult{Stdout: []byte("pod/api-0\n")}, nil + case strings.HasPrefix(joined, "logs"): + return CommandResult{Stdout: []byte("log\n")}, nil + case strings.HasPrefix(joined, "get events"): + return CommandResult{}, nil + } + return CommandResult{}, fmt.Errorf("unexpected call %s", joined) + }, + } + + s, out, _ := newTestService(r) + // The fake returns name output for the first pod probe but diagnoseK8s + // needs JSON; swap the responder after resolve would be brittle, so return + // JSON for every get pod call instead. + r.respond = func(name string, args []string) (CommandResult, error) { + joined := strings.Join(args, " ") + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte("[]")}, nil + case strings.HasPrefix(joined, "config view"): + return CommandResult{}, nil + case strings.HasPrefix(joined, "get pod"): + return CommandResult{Stdout: []byte(crashLoopPodJSON)}, nil + case strings.HasPrefix(joined, "logs"): + return CommandResult{Stdout: []byte("log\n")}, nil + case strings.HasPrefix(joined, "get events"): + return CommandResult{}, nil + } + return CommandResult{}, fmt.Errorf("unexpected call %s", joined) + } + + if err := s.Run(context.Background(), []string{"api-0"}); err != nil { + t.Fatalf("Run error: %v", err) + } + if !strings.Contains(out.String(), "来源: Kubernetes") { + t.Errorf("expected kubernetes source:\n%s", out.String()) + } +} + +func TestResolveNotFound(t *testing.T) { + r := &fakeRunner{ + available: map[string]bool{"docker": true, "kubectl": true}, + respond: func(name string, args []string) (CommandResult, error) { + joined := strings.Join(args, " ") + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte("[]")}, nil + case strings.HasPrefix(joined, "config view"): + return CommandResult{}, nil + case strings.HasPrefix(joined, "get pod"): + return CommandResult{Stderr: []byte("not found")}, fmt.Errorf("exit status 1") + } + return CommandResult{}, nil + }, + } + + s, _, _ := newTestService(r) + err := s.Run(context.Background(), []string{"ghost"}) + if err == nil || !strings.Contains(err.Error(), "未找到目标") { + t.Fatalf("err = %v, want not-found error", err) + } +} + +func TestResolveNamespaceSkipsDocker(t *testing.T) { + r := &fakeRunner{ + available: map[string]bool{"docker": true, "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{}, nil + case strings.HasPrefix(joined, "get events"): + return CommandResult{}, nil + } + return CommandResult{}, fmt.Errorf("unexpected call %s", joined) + }, + } + + s, out, _ := newTestService(r) + if err := s.Run(context.Background(), []string{"api-0", "--namespace", "prod"}); err != nil { + t.Fatalf("Run error: %v", err) + } + if r.called("inspect") { + t.Error("namespace should skip the docker probe") + } + if !strings.Contains(out.String(), "namespace=prod") { + t.Errorf("namespace missing from output:\n%s", out.String()) + } +} diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 4630887..f65c7e1 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -3,6 +3,9 @@ package executor import ( "context" "io" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" ) // BuiltinRequest carries the caller's arguments and output streams to an @@ -11,6 +14,7 @@ type BuiltinRequest struct { Args []string Stdout io.Writer Stderr io.Writer + Info platform.Info } // Builtin executes a command inside kopsc instead of spawning a child. It is @@ -19,6 +23,13 @@ type Builtin func(ctx context.Context, req BuiltinRequest) error var builtins = map[string]Builtin{ "disk.usage": runDiskUsage, + "diagnose": runDiagnose, +} + +// runDiagnose bridges the registry builtin to the diagnose package. +func runDiagnose(ctx context.Context, req BuiltinRequest) error { + service := diagnose.New(req.Info, diagnose.ExecRunner{}, req.Stdout, req.Stderr) + return service.Run(ctx, req.Args) } // LookupBuiltin returns the implementation registered under name. diff --git a/internal/executor/executor.go b/internal/executor/executor.go index ceb8b3a..81ec262 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -106,7 +106,12 @@ func (p Plan) Run(ctx context.Context, opts Options) error { if !ok { return fmt.Errorf("未注册的内置动作 %q", variant.Builtin) } - return fn(ctx, BuiltinRequest{Args: p.ExtraArgs, Stdout: opts.Stdout, Stderr: opts.Stderr}) + return fn(ctx, BuiltinRequest{ + Args: p.ExtraArgs, + Stdout: opts.Stdout, + Stderr: opts.Stderr, + Info: p.Result.Info, + }) } name, args := p.command() diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 2b7bf6e..7bf04d4 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -4,46 +4,301 @@ package registry // about the platform it targets; resolution picks the most specific match. func Builtin() *Registry { return &Registry{Commands: []Command{ + // ----- read-only ----- { - ID: "docker.clearsafe", - Aliases: []string{"docker.prunesafe"}, - Group: "docker", - Summary: "安全清理未使用的容器、网络与构建缓存", - Action: "执行容器运行时的默认清理作用域:不清理全部未使用镜像(-a)," + - "不删除卷(--volumes),仅移除停止的容器、未使用的网络和悬空镜像", + ID: "docker.ps", + Group: "docker", + Summary: "列出运行中的容器", + Action: "只读:等价 docker ps,参数可透传(如 --format、--filter)", + Variants: runtimeVariants("ps"), + }, + { + ID: "docker.psall", + Group: "docker", + Summary: "列出全部容器(含已停止)", + Action: "只读:等价 docker ps -a", + Variants: runtimeVariants("ps", "-a"), + }, + { + ID: "docker.images", + Group: "docker", + Summary: "列出本地镜像", + Action: "只读:等价 docker images", + Variants: runtimeVariants("images"), + }, + { + ID: "docker.stats", + Group: "docker", + Summary: "一次性查看容器 CPU/内存/IO 占用", + Action: "只读:等价 docker stats --no-stream,输出一次后退出", + Variants: runtimeVariants("stats", "--no-stream"), + }, + { + ID: "docker.df", + Group: "docker", + Summary: "查看镜像/容器/卷/构建缓存的空间占用", + Action: "只读:等价 docker system df", + Variants: runtimeVariants("system", "df"), + }, + { + ID: "docker.info", + Group: "docker", + Summary: "查看容器运行时与主机环境信息", + Action: "只读:等价 docker info", + Variants: runtimeVariants("info"), + }, + { + ID: "docker.version", + Group: "docker", + Summary: "查看容器运行时版本", + Action: "只读:等价 docker version", + Variants: runtimeVariants("version"), + }, + { + ID: "docker.logs", + Group: "docker", + Summary: "查看容器日志", + Action: "只读:等价 docker logs <容器>;-f、--tail 等选项可透传", + Usage: "<容器>", + MinArgs: 1, + Variants: runtimeVariants("logs"), + }, + { + ID: "docker.inspect", + Group: "docker", + Summary: "查看容器/镜像/网络/卷的详细信息", + Action: "只读:等价 docker inspect <对象>", + Usage: "<对象>", + MinArgs: 1, + Variants: runtimeVariants("inspect"), + }, + { + ID: "docker.networks", + Group: "docker", + Summary: "列出容器网络", + Action: "只读:等价 docker network ls", + Variants: runtimeVariants("network", "ls"), + }, + { + ID: "docker.volumes", + Group: "docker", + Summary: "列出数据卷", + Action: "只读:等价 docker volume ls", + Variants: runtimeVariants("volume", "ls"), + }, + + // ----- lifecycle ----- + { + ID: "docker.start", + Group: "docker", + Summary: "启动已停止的容器", + Action: "启动一个或多个已存在的容器,不创建新容器", + Usage: "<容器>", + MinArgs: 1, + Variants: runtimeVariants("start"), + }, + { + ID: "docker.stop", + Group: "docker", + Summary: "停止运行中的容器", + Action: "向容器发送 SIGTERM,超时后由运行时强制结束", + Usage: "<容器>", + MinArgs: 1, + Variants: runtimeVariants("stop"), + }, + { + ID: "docker.restart", + Group: "docker", + Summary: "重启容器", + Action: "停止并重新启动一个或多个容器", + Usage: "<容器>", + MinArgs: 1, + Variants: runtimeVariants("restart"), + }, + { + ID: "docker.rm", + Group: "docker", + Summary: "删除已停止的容器", + Action: "删除指定容器;运行中的容器需要先停止(不会自动加 -f)", + Usage: "<容器>", + MinArgs: 1, + Variants: runtimeVariants("rm"), + }, + { + ID: "docker.rmi", + Group: "docker", + Summary: "删除本地镜像", + Action: "删除指定镜像;被容器引用的镜像会被运行时拒绝删除", + Usage: "<镜像>", + MinArgs: 1, + Variants: runtimeVariants("rmi"), + }, + { + ID: "docker.exec", + Group: "docker", + Summary: "在运行中的容器内执行命令", + Action: "等价 docker exec <容器> <命令...>;交互场景可透传 -it", + Usage: "<容器> <命令...>", + MinArgs: 1, + Variants: runtimeVariants("exec"), + }, + { + ID: "docker.pull", + Group: "docker", + Summary: "拉取镜像", + Action: "从镜像仓库拉取指定镜像", + Usage: "<镜像>", + MinArgs: 1, + Variants: runtimeVariants("pull"), + }, + { + ID: "docker.tag", + Group: "docker", + Summary: "给镜像打标签", + Action: "把已有镜像标记为新的名称与标签", + Usage: "<源镜像> <目标镜像>", + MinArgs: 2, + Variants: runtimeVariants("tag"), + }, + { + ID: "docker.save", + Group: "docker", + Summary: "把镜像导出为归档文件", + Action: "等价 docker save;通常配合 -o 透传输出文件", + Usage: "<镜像>", + MinArgs: 1, + Variants: runtimeVariants("save"), + }, + { + ID: "docker.load", + Group: "docker", + Summary: "从归档文件导入镜像", + Action: "等价 docker load;通常配合 -i 透传输入文件", + Variants: runtimeVariants("load"), + }, + { + ID: "docker.cp", + Group: "docker", + Summary: "在容器与主机之间复制文件", + Action: "等价 docker cp <源> <目标>,任一方向均可", + Usage: "<源> <目标>", + MinArgs: 2, + Variants: runtimeVariants("cp"), + }, + + // ----- cleanup ----- + { + ID: "docker.clearsafe", + Aliases: []string{"docker.prunesafe"}, + Group: "docker", + Summary: "安全清理未使用的容器、网络与构建缓存", + Action: "执行容器运行时的默认清理作用域:不清理全部未使用镜像(-a),不删除卷(--volumes),仅移除停止的容器、未使用的网络和悬空镜像", + Variants: runtimeVariants("system", "prune", "-f"), + }, + { + ID: "docker.pruneimages", + Group: "docker", + Summary: "清理悬空镜像(dangling)", + Action: "等价 docker image prune -f,只删除没有标签且未被引用的悬空镜像", + Variants: runtimeVariants("image", "prune", "-f"), + }, + { + ID: "docker.prunecontainers", + Group: "docker", + Summary: "清理已停止的容器", + Action: "等价 docker container prune -f,删除所有已停止的容器", + Variants: runtimeVariants("container", "prune", "-f"), + }, + { + ID: "docker.prunenets", + Group: "docker", + Summary: "清理未使用的网络", + Action: "等价 docker network prune -f,删除未被容器使用的网络", + Variants: runtimeVariants("network", "prune", "-f"), + }, + { + ID: "docker.prunebuilder", + Group: "docker", + Summary: "清理构建缓存", + Action: "等价 docker builder prune -f,删除未被使用的构建缓存", + Variants: runtimeVariants("builder", "prune", "-f"), + }, + { + ID: "docker.prunevolumes", + Group: "docker", + Summary: "清理未使用的数据卷(破坏性)", + Action: "等价 docker volume prune -f:删除所有未被容器使用的卷,卷内数据不可恢复;必须显式加 --yes 才会执行", + RequiresYes: true, + Variants: runtimeVariants("volume", "prune", "-f"), + }, + { + ID: "docker.clearall", + Group: "docker", + Summary: "深度清理全部未使用镜像与资源(破坏性)", + Action: "等价 docker system prune -a -f:删除所有未使用镜像(含已打标签但未被容器引用的镜像)、停止的容器、未使用网络与构建缓存,但不删除卷;必须显式加 --yes 才会执行", + RequiresYes: true, + Variants: runtimeVariants("system", "prune", "-a", "-f"), + }, + + // ----- compose ----- + { + ID: "docker.compose.up", + Group: "docker", + Summary: "以守护方式启动 Compose 服务", + Action: "等价 docker compose up -d;服务名可透传指定", + Variants: runtimeVariants("compose", "up", "-d"), + }, + { + ID: "docker.compose.down", + Group: "docker", + Summary: "停止并移除 Compose 服务与网络", + Action: "等价 docker compose down;默认保留卷,删除卷需自行透传 -v", + Variants: runtimeVariants("compose", "down"), + }, + { + ID: "docker.compose.ps", + Group: "docker", + Summary: "列出 Compose 服务状态", + Action: "只读:等价 docker compose ps", + Variants: runtimeVariants("compose", "ps"), + }, + { + ID: "docker.compose.logs", + Group: "docker", + Summary: "查看 Compose 服务日志", + Action: "只读:等价 docker compose logs;-f、服务名可透传", + Variants: runtimeVariants("compose", "logs"), + }, + + // ----- cross-runtime diagnosis ----- + { + ID: "diagnose", + Group: "diagnose", + Summary: "诊断容器或 Pod 当前状态的根因", + Action: "自动识别 Docker/Podman 容器或 Kubernetes Pod,采集状态、最近日志与事件,给出原因、依据与建议;只读,不修改任何对象。--kind 可强制来源,K8s 下 -n/--namespace 指定命名空间", + Usage: "<容器名或 Pod 名>", + MinArgs: 1, + Namespaced: true, Variants: []Variant{ { - Match: Match{OS: "linux", Distro: "centos", Runtime: "podman"}, - Argv: []string{"podman", "system", "prune", "-f"}, - Note: "CentOS 上的 Podman 运行时", + Match: Match{OS: "linux"}, + Builtin: "diagnose", + Note: "Linux 内置诊断(docker/podman 与 kubectl 自动识别)", }, { - Match: Match{OS: "linux", Distro: "centos", Runtime: "docker"}, - Argv: []string{"docker", "system", "prune", "-f"}, - Note: "CentOS 上的 Docker 运行时", + Match: Match{OS: "darwin"}, + Builtin: "diagnose", + Note: "macOS 内置诊断(Docker Desktop 与 kubectl)", }, { - Match: Match{OS: "linux", Runtime: "podman"}, - Argv: []string{"podman", "system", "prune", "-f"}, - Note: "任意 Linux 发行版上的 Podman 运行时", - }, - { - Match: Match{OS: "linux", Runtime: "docker"}, - Argv: []string{"docker", "system", "prune", "-f"}, - Note: "任意 Linux 发行版上的 Docker 运行时", - }, - { - Match: Match{OS: "darwin", Runtime: "docker"}, - Argv: []string{"docker", "system", "prune", "-f"}, - Note: "macOS 上的 Docker Desktop 运行时", - }, - { - Match: Match{OS: "windows", Runtime: "docker"}, - Argv: []string{"docker", "system", "prune", "-f"}, - Note: "Windows 上的 Docker Desktop 运行时", + Match: Match{OS: "windows"}, + Builtin: "diagnose", + Note: "Windows 内置诊断(Docker Desktop 与 kubectl)", }, }, }, + + // ----- non-container examples ----- { ID: "disk.usage", Aliases: []string{"disk.du"}, @@ -133,3 +388,33 @@ func Builtin() *Registry { }, }} } + +// runtimeVariants builds the platform matrix for a container command. Linux +// gets both podman and docker variants (resolution prefers podman when both +// exist); macOS and Windows only have Docker Desktop. +func runtimeVariants(args ...string) []Variant { + program := func(runtime string) string { + if runtime == "podman" { + return "podman" + } + return "docker" + } + + makeVariant := func(osName, runtime, note string) Variant { + argv := make([]string, 0, 1+len(args)) + argv = append(argv, program(runtime)) + argv = append(argv, args...) + return Variant{ + Match: Match{OS: osName, Runtime: runtime}, + Argv: argv, + Note: note, + } + } + + return []Variant{ + makeVariant("linux", "podman", "Linux 上的 Podman 运行时"), + makeVariant("linux", "docker", "Linux 上的 Docker 运行时"), + makeVariant("darwin", "docker", "macOS 上的 Docker Desktop 运行时"), + makeVariant("windows", "docker", "Windows 上的 Docker Desktop 运行时"), + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 99b3538..54003f0 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -31,14 +31,75 @@ type Variant struct { // Command is a platform-neutral intent, e.g. "docker.clearsafe". type Command struct { - ID string // hierarchical, dot-separated: "docker.clearsafe" - Aliases []string - Summary string - Group string // CLI grouping, e.g. "docker" - Action string // what the command does, including its safety contract + ID string // hierarchical, dot-separated: "docker.clearsafe" + Aliases []string + Summary string + Group string // CLI grouping, e.g. "docker" + Action string // what the command does, including its safety contract + + // Usage describes the positional arguments, e.g. "<容器>". + Usage string + // MinArgs is the number of positional arguments required before the + // command is allowed to run. Options before "--" are not counted. + MinArgs int + // RequiresYes marks destructive commands: without an explicit --yes they + // print what they would do and exit non-zero instead of running. + RequiresYes bool + // Namespaced marks Kubernetes-aware commands where -n means --namespace + // (kubectl style) instead of the global --dry-run shorthand. + Namespaced bool + Variants []Variant } +// ValidateArgs reports whether the caller supplied enough positional +// arguments. Everything after "--" counts as positional; before it, arguments +// starting with "-" are treated as options and skipped. +func (c *Command) ValidateArgs(args []string) error { + if c.MinArgs <= 0 { + return nil + } + + count := 0 + positionalOnly := false + for _, arg := range args { + switch { + case positionalOnly: + count++ + case arg == "--": + positionalOnly = true + case strings.HasPrefix(arg, "-") && arg != "-": + // Option; value-taking options are not distinguishable, so their + // values may be counted later in the list. This is deliberately a + // loose check: the underlying tool still validates precisely. + default: + count++ + } + } + + if count < c.MinArgs { + return &MissingArgsError{Command: c, Got: count} + } + return nil +} + +// Invocation renders the CLI form of a command, e.g. "kopsc docker logs". +func (c *Command) Invocation() string { + return "kopsc " + strings.ReplaceAll(c.ID, ".", " ") +} + +// MissingArgsError reports too few positional arguments. +type MissingArgsError struct { + Command *Command + Got int +} + +func (e *MissingArgsError) Error() string { + usage := strings.TrimSpace(e.Command.Usage) + return fmt.Sprintf("命令 %s 缺少参数:需要至少 %d 个(%s),实际 %d 个;用法: %s %s", + e.Command.ID, e.Command.MinArgs, usage, e.Got, e.Command.Invocation(), usage) +} + // Registry is an ordered set of commands. type Registry struct { Commands []Command @@ -65,6 +126,15 @@ func (r *Registry) Validate() error { if len(cmd.Variants) == 0 { return fmt.Errorf("command %q: no variants", cmd.ID) } + if cmd.MinArgs < 0 { + return fmt.Errorf("command %q: MinArgs must not be negative", cmd.ID) + } + if cmd.MinArgs > 0 && strings.TrimSpace(cmd.Usage) == "" { + return fmt.Errorf("command %q: MinArgs requires a Usage string", cmd.ID) + } + if cmd.RequiresYes && strings.TrimSpace(cmd.Action) == "" { + return fmt.Errorf("command %q: RequiresYes requires an Action description", cmd.ID) + } for _, alias := range cmd.Aliases { if strings.TrimSpace(alias) == "" { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 9ea2bfa..9fbbe2b 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -15,9 +15,20 @@ func TestBuiltinIsValid(t *testing.T) { func TestBuiltinIDsStable(t *testing.T) { reg := Builtin() - want := []string{"docker.clearsafe", "disk.usage", "net.ports", "pkg.refreshindex"} + want := []string{ + "docker.ps", "docker.psall", "docker.images", "docker.stats", "docker.df", + "docker.info", "docker.version", "docker.logs", "docker.inspect", + "docker.networks", "docker.volumes", + "docker.start", "docker.stop", "docker.restart", "docker.rm", "docker.rmi", + "docker.exec", "docker.pull", "docker.tag", "docker.save", "docker.load", "docker.cp", + "docker.clearsafe", "docker.pruneimages", "docker.prunecontainers", "docker.prunenets", + "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", + "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", + "diagnose", + "disk.usage", "net.ports", "pkg.refreshindex", + } if got := reg.IDs(); !reflect.DeepEqual(got, want) { - t.Fatalf("IDs() = %v, want %v", got, want) + t.Fatalf("IDs() =\n%v\nwant\n%v", got, want) } } @@ -171,6 +182,108 @@ func TestDiskUsageIsBuiltinEverywhere(t *testing.T) { } } +func TestDockerCommandsWellFormed(t *testing.T) { + reg := Builtin() + destructive := map[string]bool{ + "docker.clearall": true, + "docker.prunevolumes": true, + } + needsArgs := map[string]int{ + "docker.logs": 1, + "docker.inspect": 1, + "docker.start": 1, + "docker.stop": 1, + "docker.restart": 1, + "docker.rm": 1, + "docker.rmi": 1, + "docker.exec": 1, + "docker.pull": 1, + "docker.tag": 2, + "docker.save": 1, + "docker.cp": 2, + } + + for i := range reg.Commands { + cmd := ®.Commands[i] + if cmd.Group != "docker" { + continue + } + + if destructive[cmd.ID] != cmd.RequiresYes { + t.Errorf("%s: RequiresYes = %v, want %v", cmd.ID, cmd.RequiresYes, destructive[cmd.ID]) + } + if want, ok := needsArgs[cmd.ID]; ok && cmd.MinArgs != want { + t.Errorf("%s: MinArgs = %d, want %d", cmd.ID, cmd.MinArgs, want) + } + if cmd.MinArgs > 0 && strings.TrimSpace(cmd.Usage) == "" { + t.Errorf("%s: missing Usage for MinArgs=%d", cmd.ID, cmd.MinArgs) + } + + var podman, docker int + for _, v := range cmd.Variants { + if len(v.Argv) == 0 { + t.Errorf("%s: docker command must have an argv variant", cmd.ID) + continue + } + switch v.Argv[0] { + case "podman": + podman++ + case "docker": + docker++ + } + if v.Match.Runtime != v.Argv[0] { + t.Errorf("%s: runtime %q does not match program %q", cmd.ID, v.Match.Runtime, v.Argv[0]) + } + } + if podman != 1 || docker != 3 { + t.Errorf("%s: variants = %d podman / %d docker, want 1/3", cmd.ID, podman, docker) + } + } +} + +func TestValidateArgs(t *testing.T) { + logs := &Command{ID: "docker.logs", Usage: "<容器>", MinArgs: 1} + tag := &Command{ID: "docker.tag", Usage: "<源> <目标>", MinArgs: 2} + + cases := []struct { + name string + cmd *Command + args []string + wantErr bool + }{ + {"no args", logs, nil, true}, + {"only option", logs, []string{"-f"}, true}, + {"positional", logs, []string{"web"}, false}, + {"option then positional", logs, []string{"-f", "web"}, false}, + {"option value counted loosely", logs, []string{"--tail", "100"}, false}, + {"interactive option then args", logs, []string{"-it", "web", "sh"}, false}, + {"path flag", &Command{ID: "disk.usage", Usage: "<路径>", MinArgs: 1}, []string{"--path", "/var"}, false}, + {"after double dash", logs, []string{"--", "-weird-name"}, false}, + {"two required", tag, []string{"old:1", "new:1"}, false}, + {"two required short", tag, []string{"old:1"}, true}, + {"option does not satisfy requirement", tag, []string{"-f", "old:1"}, true}, + {"no requirement", &Command{ID: "docker.ps"}, nil, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.ValidateArgs(tc.args) + if tc.wantErr && err == nil { + t.Fatalf("expected an error for %v", tc.args) + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err != nil { + msg := err.Error() + if !strings.Contains(msg, "kopsc "+strings.ReplaceAll(tc.cmd.ID, ".", " ")) { + t.Errorf("error lacks usage hint: %q", msg) + } + } + }) + } +} + func TestMatchIsComparable(t *testing.T) { a := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"} b := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"} diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 86c34ec..b61c807 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -39,11 +39,11 @@ func TestResolveBuiltin(t *testing.T) { wantIdx int }{ { - name: "centos with podman and docker prefers the distro-specific podman variant", + name: "centos with podman and docker prefers podman", id: "docker.clearsafe", info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker", "podman"), wantArgv: []string{"podman", "system", "prune", "-f"}, - wantScore: 5, + wantScore: 2, wantIdx: 0, }, { @@ -51,7 +51,7 @@ func TestResolveBuiltin(t *testing.T) { id: "docker.clearsafe", info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, - wantScore: 5, + wantScore: 2, wantIdx: 1, }, { @@ -60,7 +60,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker", "podman"), wantArgv: []string{"podman", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 2, + wantIdx: 0, }, { name: "rocky inherits the generic linux docker variant", @@ -68,7 +68,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 3, + wantIdx: 1, }, { name: "darwin docker", @@ -76,7 +76,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("darwin", "arm64", "", nil, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 4, + wantIdx: 2, }, { name: "windows docker", @@ -84,7 +84,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("windows", "amd64", "", nil, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 5, + wantIdx: 3, }, { name: "centos refreshes with dnf exactly",