feat: 扩充 Docker 命令并新增 diagnose 状态诊断
- Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman - 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束 - 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数 - diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件 - diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run - 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README
This commit is contained in:
@@ -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` 展示探测结果,来源:
|
||||
|
||||
+77
-7
@@ -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 {
|
||||
|
||||
+156
-12
@@ -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" {
|
||||
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+315
-30
@@ -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 运行时"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user