diff --git a/README.md b/README.md index 8243043..2a3d019 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath \ kopsc docker ps # 列出运行中的容器 kopsc docker psall # 含已停止的容器 kopsc docker logs -f web # 查看日志(容器名必填,选项可透传) -kopsc docker exec -it web sh # 进入容器 +kopsc docker exec web sh # 进入容器(默认就是交互模式,无需 -it) kopsc docker compose up # 启动 Compose 服务 kopsc docker clearall --yes # 深度清理(破坏性命令,必须显式 --yes) kopsc docker save alpine:3.20 # 自动保存为 alpine_3.20.tar(传镜像 ID 会先解析出名称) @@ -135,8 +135,8 @@ $ kopsc disk usage /var - 所有 Docker 命令在三个平台都调用 `docker`(macOS / Windows 上即 Docker Desktop)。 - `logs`、`inspect`、`start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`load`、`cp` 需要位置参数,缺失时直接报错并给出用法。 -- 破坏性命令只有 `clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`): - 未带 `--yes` 时只打印影响范围与将执行的命令,并以退出码 2 结束。 +- 破坏性命令是 `rm`、`rmi`、`clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`): + 未带 `--yes` 时先列出将删除的对象、再打印将执行的命令,并以退出码 2 结束。 - `docker compose` 对应 Compose v2。 - `docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像), **不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。 @@ -162,6 +162,41 @@ kopsc docker load a.tar b.tar # 一次导入多个 - `docker load` 的归档文件是**位置参数**(对应原来的 `docker load < xxx.tar`),可一次给出多个; `-i/--input` 仍然可用,传 `-` 仍表示从标准输入读取。 +### 容器与镜像的简写(exec / rm / rmi / stop / restart) + +这几条命令按 kopsc 的意图做了解析,不再要求手写 docker 的细节: + +```bash +kopsc docker exec web sh # 等价 docker exec -it web -- sh(-it 已是默认) +kopsc docker exec -u 0 web id # 容器名之前的 -u/-w/-e 照常透传给 docker +kopsc docker stop 3f2a1b # 按 ID 前缀找到容器并停止 +kopsc docker rm db # 删除已停止的容器;运行中的会提示先 stop +kopsc docker rmi sha256:5ca7b1d5e2fc # 按 ID 或唯一前缀删除镜像 +``` + +- 容器可按名称、ID 或唯一 ID 前缀定位;镜像可按名称、ID、`sha256:ID` 或唯一前缀定位。 + 目标不存在或前缀有歧义时,kopsc 直接报错,不会调用 docker。 +- `exec` 默认交互,命令之后的参数一律作为容器内命令,因此命令自己的 `-i`、`-p` 不会被当成 docker 选项; + 显式传 `-i`/`-t`/`-it` 会报错提示「已默认交互」。 +- `rm`/`rmi` 属于破坏性命令:不带 `--yes` 时先列出将删除的对象(容器显示 ID/名称/镜像/状态, + 镜像显示 镜像/ID/创建/大小)再以退出码 2 结束;不再接受 `-f`,强删请用 `--yes`。 +- `rm` 遇到运行中的容器会提示先 `kopsc docker stop <名称>`,不会替你强删。 + +### 日志、占用与实时状态(logs / df / stats) + +```bash +kopsc docker logs web --since 2h # 只看最近 2 小时 +kopsc docker logs --grep ERROR web # 只看匹配行(kopsc 选项写在容器名之前) +kopsc docker logs --save /var/log/web.log web -f # 同时落盘,-f 仍交给 docker +kopsc docker df # 按占用大小排序的类型排行 +kopsc docker stats # 单次快照,默认统计全部运行中容器 +kopsc docker stats web --format '{{.CPUPerc}}' +``` + +- `--since`/`--grep`/`--save` 是 kopsc 私有选项,必须写在容器名之前;容器名之后的参数原样交给 docker。 +- `df` 输出「类型 / 项 / 可清理 / 大小 / 可回收」,末尾附总计与可回收提示。 +- `stats` 总是单次快照(`--no-stream`),不会再刷屏;给容器名时只统计这些,且要求容器正在运行。 + ### 容器/Pod 诊断(diagnose) `kopsc diagnose <名称>` 自动判断目标是 Docker 容器还是 Kubernetes Pod,输出状态、根因、依据、建议,并附最近日志与事件。只读,不修改任何对象。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 49f44be..7e868f7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -152,9 +152,7 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { plan := executor.Plan{Result: result, ExtraArgs: extra} if cmd.RequiresYes && !yes && !dryRun { - a.printConfirmation(cmd, plan) - a.exitCode = 2 - return nil + return a.printConfirmation(cmd, plan) } return plan.Run(context.Background(), executor.Options{ @@ -168,14 +166,27 @@ 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) { +// printConfirmation explains a destructive command and how to approve it. If +// the command has a preview builtin, the objects it would touch are listed +// first (and an unknown target is reported before asking for --yes). +func (a *App) printConfirmation(cmd *registry.Command, plan executor.Plan) error { + if err := executor.Preview(context.Background(), plan, a.Stdout); err != nil { + return err + } + 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()) + // Repeat the caller's own arguments so the suggestion is copy-pasteable. + suggested := cmd.Invocation() + " --yes" + if len(plan.ExtraArgs) > 0 { + suggested += " " + strings.Join(plan.ExtraArgs, " ") + } + fmt.Fprintf(w, "确认执行请追加 --yes:\n %s\n", suggested) + a.exitCode = 2 + return nil } // parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--` diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bc6e4bd..2d79595 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -631,3 +631,105 @@ func TestDockerLoadThroughCLIRequiresArchive(t *testing.T) { t.Errorf("stderr = %q", stderr.String()) } } + +// cliRuntime installs a fake docker answering both `images` and `ps`, and +// returns the path of the invocation log. +func cliRuntime(t *testing.T) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "calls.log") + script := "#!/bin/sh\n" + + "echo \"$*\" >> " + logPath + "\n" + + "case \"$1\" in\n" + + " images) cat <<'EOT'\nIMAGE ID\tREPOSITORY\tTAG\tDIGEST\n9c6f07244728\talpine\t3.20\t\nEOT\n" + + " ;;\n" + + " ps) cat <<'EOT'\n3f2a1b0c9d8e\tweb\tnginx:1.25\tUp 2 hours\nEOT\n" + + " ;;\n" + + "esac\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func TestDockerExecThroughCLIDefaultsToInteractive(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake runtime is a shell script") + } + logPath := cliRuntime(t) + + app, _, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"docker", "exec", "web", "sh", "-c", "echo hi"}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + data, _ := os.ReadFile(logPath) + if !strings.Contains(string(data), "exec -it web -- sh -c echo hi") { + t.Errorf("docker calls = %q, want the -it/-- form", data) + } +} + +func TestDockerExecRejectsDashItThroughCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake runtime is a shell script") + } + cliRuntime(t) + + app, _, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"docker", "exec", "-it", "web", "sh"}); code == 0 { + t.Fatal("expected a non-zero exit code for an explicit -it") + } + if !strings.Contains(stderr.String(), "已默认以交互方式") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestDockerStopThroughCLIResolvesContainerName(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake runtime is a shell script") + } + logPath := cliRuntime(t) + + app, _, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"docker", "stop", "3f2a1b"}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + data, _ := os.ReadFile(logPath) + if !strings.Contains(string(data), "stop web") { + t.Errorf("docker calls = %q, want `stop web`", data) + } +} + +func TestDockerRMWithoutYesListsTargetsAndExitsTwo(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake runtime is a shell script") + } + logPath := cliRuntime(t) + + app, stdout, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"docker", "rm", "web"}); code != 2 { + t.Fatalf("exit code = %d, want 2; stderr=%s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"将影响:", "3f2a1b0c9d8e", "web", "nginx:1.25", "Up 2 hours", "这是破坏性命令", "--yes"} { + if !strings.Contains(out, want) { + t.Errorf("confirmation missing %q:\n%s", want, out) + } + } + for _, call := range readCalls(logPath) { + if strings.HasPrefix(call, "rm ") { + t.Fatalf("docker rm must not run without --yes, calls = %v", readCalls(logPath)) + } + } +} + +func readCalls(path string) []string { + data, _ := os.ReadFile(path) + var out []string + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 900c8a7..4ec50be 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -1,7 +1,9 @@ package executor import ( + "bytes" "context" + "fmt" "io" "gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose" @@ -26,12 +28,22 @@ type BuiltinRequest struct { type Builtin func(ctx context.Context, req BuiltinRequest) error var builtins = map[string]Builtin{ - "disk.usage": runDiskUsage, - "diagnose": runDiagnose, - "docker.load": runDockerLoad, - "docker.save": runDockerSave, - "net.pids": runNetPIDs, - "proc.nohup": runNohup, + "disk.usage": runDiskUsage, + "diagnose": runDiagnose, + "docker.df": runDockerDF, + "docker.exec": runDockerExec, + "docker.load": runDockerLoad, + "docker.logs": runDockerLogs, + "docker.restart": runDockerRestart, + "docker.rm": runDockerRM, + "docker.rm.preview": runDockerPreviewRM, + "docker.rmi": runDockerRMI, + "docker.rmi.preview": runDockerPreviewRMI, + "docker.save": runDockerSave, + "docker.stats": runDockerStats, + "docker.stop": runDockerStop, + "net.pids": runNetPIDs, + "proc.nohup": runNohup, } // runDiagnose bridges the registry builtin to the diagnose package. @@ -40,6 +52,40 @@ func runDiagnose(ctx context.Context, req BuiltinRequest) error { return service.Run(ctx, req.Args) } +// Preview runs a command's ConfirmBuiltin (if any) and writes the list of +// objects the command would act on. It is used for the confirmation prompt and +// for --dry-run, so neither path needs to touch the objects themselves. +// +// The list is built from the container/image references kopsc resolves, which +// means every target is validated first: an unknown target is reported here +// rather than after the user typed --yes. Commands without a ConfirmBuiltin +// print nothing. +func Preview(ctx context.Context, plan Plan, w io.Writer) error { + name := plan.PreviewBuiltin() + if name == "" { + return nil + } + fn, ok := LookupBuiltin(name) + if !ok { + return fmt.Errorf("未注册的确认动作 %q", name) + } + // Build the list in memory first: a target that fails to resolve must not + // leave a dangling "将影响:" heading behind. + var buf bytes.Buffer + if err := fn(ctx, BuiltinRequest{ + Args: plan.ExtraArgs, + Stdout: &buf, + Stderr: io.Discard, + Info: plan.Result.Info, + Plan: plan, + }); err != nil { + return err + } + fmt.Fprintln(w, "将影响:") + _, err := w.Write(buf.Bytes()) + return err +} + // LookupBuiltin returns the implementation registered under name. func LookupBuiltin(name string) (Builtin, bool) { fn, ok := builtins[name] diff --git a/internal/executor/dockerdf.go b/internal/executor/dockerdf.go new file mode 100644 index 0000000..a613f6b --- /dev/null +++ b/internal/executor/dockerdf.go @@ -0,0 +1,173 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "text/tabwriter" +) + +// dfRow is one row of `docker system df`: a resource type, how many exist, +// how much space they use and how much of that is reclaimable. +type dfRow struct { + Type string + Total int64 + Active int64 + Size int64 + Reclaim int64 +} + +// runDockerDF implements `kopsc docker df`: `docker system df` rendered as a +// readable, sorted ranking with a total and a reclaim hint. +func runDockerDF(ctx context.Context, req BuiltinRequest) error { + rows, err := readDockerDF(ctx) + if err != nil { + return err + } + if len(rows) == 0 { + return errors.New("docker system df 没有返回任何数据") + } + renderDockerDF(rows, req.Stdout) + return nil +} + +func readDockerDF(ctx context.Context) ([]dfRow, error) { + out, err := dockerOutput(ctx, "system", "df") + if err != nil { + return nil, err + } + + var rows []dfRow + for _, line := range splitLines(out) { + if strings.HasPrefix(strings.TrimSpace(line), "TYPE") { + continue + } + fields := strings.Split(line, "\t") + if len(fields) < 2 { + continue + } + row := dfRow{Type: strings.TrimSpace(fields[0])} + row.Total = parseCount(fieldAt(fields, 1)) + row.Active = parseCount(fieldAt(fields, 2)) + row.Size, _ = parseDockerSize(fieldAt(fields, 3)) + reclaimable := fieldAt(fields, 4) + row.Reclaim, _ = parseDockerSize(reclaimable) + rows = append(rows, row) + } + + // Rank by size, largest first; unknown types sort last but are kept. + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Size != rows[j].Size { + return rows[i].Size > rows[j].Size + } + return rows[i].Type < rows[j].Type + }) + return rows, nil +} + +func fieldAt(fields []string, i int) string { + if i < len(fields) { + return strings.TrimSpace(fields[i]) + } + return "" +} + +// renderDockerDF writes the ranking. The trailing "可回收" column tells the +// user how much cleanup (kopsc docker clearsafe) would free. +func renderDockerDF(rows []dfRow, w io.Writer) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "类型\t项\t可清理\t大小\t可回收") + + var totalSize, totalReclaim, totalItems, totalFree int64 + for _, row := range rows { + free := row.Total - row.Active + if free < 0 { + free = 0 + } + fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\n", + row.Type, row.Total, free, humanSize(row.Size), humanSize(row.Reclaim)) + totalSize += row.Size + totalReclaim += row.Reclaim + totalItems += row.Total + totalFree += free + } + fmt.Fprintf(tw, "总计\t%d\t%d\t%s\t%s\n", totalItems, totalFree, humanSize(totalSize), humanSize(totalReclaim)) + _ = tw.Flush() + + if totalReclaim > 0 { + fmt.Fprintf(w, "\n可回收 %s;清理请用 kopsc docker clearsafe(不含全部未使用镜像与卷)\n", humanSize(totalReclaim)) + } else { + fmt.Fprintln(w, "\n没有可回收的空间") + } +} + +// parseCount reads an integer count such as "19" or "" (unknown). +func parseCount(s string) int64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} + +// parseDockerSize reads docker's size text back into bytes. Docker prints +// values such as "0B", "1.234GB", "512MB", "1.5GB (25%)" or "1.234GB (2.1GB)" +// depending on the format and version, so only the leading number and unit are +// used. A "-" or empty value means zero. +func parseDockerSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" || s == "-" || s == "N/A" { + return 0, nil + } + // Docker appends a percentage or a parenthesised detail; drop it. A space + // only separates number and unit (docker prints "3 MiB" in some formats), + // so it is removed but not used to cut. + if i := strings.IndexByte(s, '('); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + s = strings.ReplaceAll(s, " ", "") + + i := 0 + for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { + i++ + } + number, unit := s[:i], strings.ToUpper(strings.TrimSpace(s[i:])) + value, err := strconv.ParseFloat(number, 64) + if err != nil { + return 0, fmt.Errorf("无法解析大小 %q", s) + } + + multiplier := []struct { + unit string + base float64 + }{ + {"B", 1}, + {"KB", 1e3}, + {"MB", 1e6}, + {"GB", 1e9}, + {"TB", 1e12}, + {"PB", 1e15}, + {"KIB", 1 << 10}, + {"MIB", 1 << 20}, + {"GIB", 1 << 30}, + {"TIB", 1 << 40}, + } + if unit == "" { + // A bare number is bytes, as `--format {{.Size}}` may print. + unit = "B" + } + for _, m := range multiplier { + if unit == m.unit { + return int64(value * m.base), nil + } + } + return 0, fmt.Errorf("无法识别大小单位 %q", unit) +} diff --git a/internal/executor/dockerexec.go b/internal/executor/dockerexec.go new file mode 100644 index 0000000..cfad2dc --- /dev/null +++ b/internal/executor/dockerexec.go @@ -0,0 +1,106 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// execValueOption lists docker exec options that take a separate value, so the +// value is not mistaken for the container name or the command. +var execValueOption = map[string]bool{ + "u": true, + "user": true, + "w": true, + "workdir": true, + "e": true, + "env": true, + "env-file": true, + "detach-keys": true, +} + +// runDockerExec implements `kopsc docker exec <容器> <命令...>`. +// +// Interactive use is the default, so `-it` is no longer typed: kopsc always +// runs docker exec with -it and separates the command with `--`, which keeps +// the command's own flags from being read as docker options. Passing -i/-t/-it +// explicitly is an error, because the flag is now implied. +// +// Arguments before the container name are docker exec options and are +// forwarded; everything after the container name is the command, verbatim. +func runDockerExec(ctx context.Context, req BuiltinRequest) error { + args := req.Args + if err := rejectInteractiveFlags(args); err != nil { + return err + } + if len(args) == 0 { + return errors.New("缺少容器;用法: kopsc docker exec <容器> <命令...>") + } + + var opts []string + target := "" + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + // An explicit separator before the container name: what follows is + // still the container, then the command. + if i+1 < len(args) { + i++ + target = args[i] + return dockerRun(ctx, execArgv(opts, target, args[i+1:]...), req) + } + return errors.New("`--` 之后缺少容器;用法: kopsc docker exec <容器> <命令...>") + case !strings.HasPrefix(arg, "-") || arg == "-": + // The first positional argument is the container; the rest is the + // command, which may itself contain flags. + target = arg + return dockerRun(ctx, execArgv(opts, target, args[i+1:]...), req) + default: + // A docker exec option; keep its value attached to it. + opts = append(opts, arg) + if !strings.Contains(arg, "=") && execValueOption[strings.TrimLeft(arg, "-")] { + if i+1 >= len(args) { + return fmt.Errorf("选项 %s 缺少参数", arg) + } + i++ + opts = append(opts, args[i]) + } + } + } + + return errors.New("缺少容器;用法: kopsc docker exec <容器> <命令...>") +} + +// execArgv builds `exec -it <已有的 docker 选项> <容器> -- <命令...>`. +func execArgv(opts []string, target string, command ...string) []string { + argv := append([]string{"exec", "-it"}, opts...) + argv = append(argv, target, "--") + return append(argv, command...) +} + +// rejectInteractiveFlags refuses -i/-t/-it/--interactive/--tty: kopsc always +// provides them. +func rejectInteractiveFlags(args []string) error { + for _, arg := range args { + if arg == "--" || !strings.HasPrefix(arg, "-") || arg == "-" { + return nil + } + switch { + case arg == "--interactive" || arg == "--tty" || arg == "-i" || arg == "-t" || arg == "-it" || arg == "-ti": + return errors.New("kopsc docker exec 已默认以交互方式(-it)执行,无需再传 " + arg + + ";用法: kopsc docker exec <容器> <命令...>") + } + if strings.HasPrefix(arg, "--") { + continue + } + // Bundled short flags such as -di still carry -i or -t. + short := strings.TrimPrefix(arg, "-") + if strings.ContainsRune(short, 'i') || strings.ContainsRune(short, 't') { + return errors.New("kopsc docker exec 已默认以交互方式(-it)执行,无需再传 " + arg + + ";用法: kopsc docker exec <容器> <命令...>") + } + } + return nil +} diff --git a/internal/executor/dockerlifecycle.go b/internal/executor/dockerlifecycle.go new file mode 100644 index 0000000..d4a98ff --- /dev/null +++ b/internal/executor/dockerlifecycle.go @@ -0,0 +1,52 @@ +package executor + +import ( + "context" + "errors" +) + +// runDockerStop and runDockerRestart implement `docker stop` / `docker +// restart` over kopsc-resolved containers: a name, ID or unique ID prefix is +// mapped to the container's name before docker runs, so a typo is reported by +// kopsc (pointing at `kopsc docker psall`) instead of by docker. +func runDockerStop(ctx context.Context, req BuiltinRequest) error { + return runContainerLifecycle(ctx, req, "stop", "kopsc docker stop <容器>...") +} + +func runDockerRestart(ctx context.Context, req BuiltinRequest) error { + return runContainerLifecycle(ctx, req, "restart", "kopsc docker restart <容器>...") +} + +func runContainerLifecycle(ctx context.Context, req BuiltinRequest, subcommand, usage string) error { + targets, passthrough, err := splitDockerTargets(req.Args, lifecycleValueOption) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("缺少容器;用法: " + usage) + } + + names := make([]string, 0, len(targets)) + for _, target := range targets { + ref, err := resolveContainerRef(ctx, target) + if err != nil { + return err + } + names = append(names, ref.Names) + } + + args := append([]string{subcommand}, names...) + args = append(args, passthrough...) + return dockerRun(ctx, args, req) +} + +// lifecycleValueOption lists stop/restart options that take a separate value, +// keyed the way splitDockerTargets looks them up (short and long spellings). +var lifecycleValueOption = map[string]bool{ + "t": true, + "time": true, + "s": true, + "signal": true, + "timeout": true, + "signals": true, +} diff --git a/internal/executor/dockerlogs.go b/internal/executor/dockerlogs.go new file mode 100644 index 0000000..0efe291 --- /dev/null +++ b/internal/executor/dockerlogs.go @@ -0,0 +1,177 @@ +package executor + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// runDockerLogs implements `kopsc docker logs <容器> [docker 选项...]`. +// +// `--since`, `--grep` and `--save` are kopsc's own options and must appear +// before the container name; everything from the container name on is passed +// to docker verbatim, so `-f`, `--tail` and friends keep working. When no +// kopsc-only option is used the logs stream straight through; with --grep or +// --save they are filtered and/or written to a file while still being shown. +func runDockerLogs(ctx context.Context, req BuiltinRequest) error { + opts, target, rest, err := parseDockerLogs(req.Args) + if err != nil { + return err + } + if target == "" { + return errors.New("缺少容器;用法: kopsc docker logs <容器> [选项...]") + } + + args := append([]string{"logs", target}, rest...) + if opts.grep == nil && opts.save == "" { + return dockerRun(ctx, args, req) + } + return streamLogs(ctx, args, opts, req) +} + +// logsOptions are kopsc's private options for `docker logs`. +type logsOptions struct { + grep *regexp.Regexp // keep only lines matching this pattern + save string // append the displayed log to this file +} + +// parseDockerLogs splits kopsc's options (before the container) from the +// container name and the arguments forwarded to docker (after it). +// +// --since is accepted on either side of the container name; kopsc turns it +// into docker's own --since, so it reaches docker exactly once. +func parseDockerLogs(args []string) (opts logsOptions, target string, rest []string, err error) { + i := 0 + for ; i < len(args); i++ { + arg := args[i] + + if arg == "--" { + i++ + break + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + break + } + + name, value := arg, "" + if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") { + name, value = arg[:eq], arg[eq+1:] + } + + takeValue := func() (string, error) { + if value != "" { + return value, nil + } + if i+1 >= len(args) { + return "", fmt.Errorf("%s 缺少参数", name) + } + i++ + return args[i], nil + } + + switch name { + case "--grep": + pattern, err := takeValue() + if err != nil { + return opts, "", nil, err + } + re, err := regexp.Compile(pattern) + if err != nil { + return opts, "", nil, fmt.Errorf("--grep 的正则表达式无效: %w", err) + } + opts.grep = re + case "--save": + save, err := takeValue() + if err != nil { + return opts, "", nil, err + } + opts.save = save + case "--since": + since, err := takeValue() + if err != nil { + return opts, "", nil, err + } + rest = append(rest, "--since", since) + default: + // A docker option typed before the container name: hand it to + // docker, keeping any separate value attached to it. + rest = append(rest, arg) + if value == "" && dockerOptionTakesValue(name) { + next, err := takeValue() + if err != nil { + return opts, "", nil, err + } + rest = append(rest, next) + } + } + } + + if i >= len(args) { + return opts, "", rest, nil + } + return opts, args[i], append(rest, args[i+1:]...), nil +} + +// dockerOptionTakesValue lists docker logs options that take a separate +// value, so the value is not mistaken for the container name. +func dockerOptionTakesValue(arg string) bool { + switch arg { + case "--tail", "-n", "--since", "-s", "--until", "--details", "--timestamp": + return true + } + return false +} + +// streamLogs runs docker logs, relaying it line by line so --grep can filter +// and --save can capture the log while it is still displayed. +func streamLogs(ctx context.Context, args []string, opts logsOptions, req BuiltinRequest) error { + cmd, out, err := startDockerPipe(ctx, args, req.Stderr) + if err != nil { + return err + } + + var file *os.File + if opts.save != "" { + if file, err = openLogSink(opts.save); err != nil { + _ = cmd.Process.Kill() + return err + } + defer file.Close() + } + + scanner := bufio.NewScanner(out) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if opts.grep != nil && !opts.grep.MatchString(line) { + continue + } + fmt.Fprintln(req.Stdout, line) + if file != nil { + if _, err := fmt.Fprintln(file, line); err != nil { + return fmt.Errorf("写入 %q 失败: %w", opts.save, err) + } + } + } + + return waitDockerPipe(cmd, scanner.Err()) +} + +// openLogSink opens the --save file for appending, creating its directory. +func openLogSink(path string) (*os.File, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建目录 %q 失败: %w", dir, err) + } + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("打开日志文件 %q 失败: %w", path, err) + } + return f, nil +} diff --git a/internal/executor/dockerrm.go b/internal/executor/dockerrm.go new file mode 100644 index 0000000..3bcd5d1 --- /dev/null +++ b/internal/executor/dockerrm.go @@ -0,0 +1,230 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "strings" + "text/tabwriter" +) + +// runDockerRM implements `docker rm` over kopsc-resolved targets: containers +// are named by name or ID (or a unique ID prefix), and are resolved before +// docker is invoked so a typo fails with kopsc's own message instead of +// docker's. +// +// The `${targets}` placeholder in the command's BaseArgs is replaced with the +// resolved names; everything the caller typed after the targets is forwarded. +func runDockerRM(ctx context.Context, req BuiltinRequest) error { + targets, passthrough, err := splitDockerTargets(req.Args, rmValueOption) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("缺少容器;用法: kopsc docker rm <容器>...") + } + + names := make([]string, 0, len(targets)) + for _, target := range targets { + ref, err := resolveContainerRef(ctx, target) + if err != nil { + return err + } + if ref.Running() { + return &ContainerRunningError{Name: ref.Names} + } + names = append(names, ref.Names) + } + + args := append([]string{"rm"}, names...) + args = append(args, passthrough...) + return dockerRun(ctx, args, req) +} + +// runDockerRMI implements `docker rmi` over kopsc-resolved targets: images are +// named by name, ID, sha256:-prefixed ID or unique ID prefix. An image that is +// only known by ID without a tag (a dangling image) is still removed — by its +// ID, since it has no name. +func runDockerRMI(ctx context.Context, req BuiltinRequest) error { + targets, passthrough, err := splitDockerTargets(req.Args, rmiValueOption) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("缺少镜像;用法: kopsc docker rmi <镜像名|镜像ID>...") + } + + refs, err := listImages(ctx) + if err != nil { + return err + } + names := make([]string, 0, len(targets)) + for _, target := range targets { + ref, err := lookupImageRef(refs, target) + if err != nil { + return err + } + if ref.Name != "" { + names = append(names, ref.Name) + } else { + names = append(names, ref.ID) + } + } + + args := append([]string{"rmi"}, names...) + args = append(args, passthrough...) + return dockerRun(ctx, args, req) +} + +// ContainerRunningError reports a container that must be stopped before it +// can be removed. docker rm refuses running containers anyway; reporting it +// here gives the user the exact next command. +type ContainerRunningError struct { + Name string +} + +func (e *ContainerRunningError) Error() string { + return "容器 " + e.Name + " 正在运行,docker rm 不会强制删除;请先执行 kopsc docker stop " + e.Name +} + +// rmValueOption and rmiValueOption list options that take a separate value, so +// the value is not mistaken for a target. +var ( + rmValueOption = map[string]bool{} + rmiValueOption = map[string]bool{"platform": true} +) + +// splitDockerTargets separates positional targets from options. Options are +// collected in passthrough; an option that takes a value keeps its value with +// it. A `--` ends option parsing, so a target may not start with "-". +func splitDockerTargets(args []string, valueOptions map[string]bool) (targets, passthrough []string, err error) { + if err := rejectForce(args, "不再接受 -f;需要强制删除请改用 --yes(kopsc 会在执行前列出将删除的对象)"); err != nil { + return nil, nil, err + } + + afterDashDash := false + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case afterDashDash: + targets = append(targets, arg) + case arg == "--": + afterDashDash = true + case !strings.HasPrefix(arg, "-") || arg == "-": + targets = append(targets, arg) + default: + passthrough = append(passthrough, arg) + if !strings.Contains(arg, "=") && valueOptions[strings.TrimLeft(arg, "-")] { + if i+1 < len(args) { + i++ + passthrough = append(passthrough, args[i]) + } + } + } + } + return targets, passthrough, nil +} + +// rejectForce refuses docker's -f/--force up to the first `--`: kopsc shows +// what will be deleted and takes --yes as the confirmation, so a force flag +// would only duplicate (or, with -f, silently bypass) that contract. +func rejectForce(args []string, message string) error { + for _, arg := range args { + if arg == "--" { + return nil + } + // Bundled short flags such as -fv still carry the force flag. + if arg == "--force" || arg == "--force=true" || arg == "-f" || + (strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && strings.Contains(arg, "f")) { + return errors.New(message) + } + } + return nil +} + +// runDockerPreviewRM lists the containers `docker rm` would delete: ID, name, +// image and status, one per line. It never touches the containers, and it +// reports an unknown target before the user is asked for --yes. +func runDockerPreviewRM(ctx context.Context, req BuiltinRequest) error { + targets, _, err := splitDockerTargets(req.Args, rmValueOption) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("缺少容器;用法: kopsc docker rm <容器>...") + } + + refs, err := listContainers(ctx) + if err != nil { + return err + } + tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\t名称\t镜像\t状态") + for _, target := range targets { + ref, err := matchContainerRef(refs, target) + if err != nil { + return err + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", shortID(ref.ID), ref.Names, ref.Image, ref.Status) + if ref.Running() { + fmt.Fprintf(tw, "\t\t\t(运行中:需先 kopsc docker stop %s)\n", ref.Names) + } + } + return tw.Flush() +} + +// runDockerPreviewRMI lists the images `docker rmi` would delete: name, ID, +// creation time and size. +func runDockerPreviewRMI(ctx context.Context, req BuiltinRequest) error { + targets, _, err := splitDockerTargets(req.Args, rmiValueOption) + if err != nil { + return err + } + if len(targets) == 0 { + return errors.New("缺少镜像;用法: kopsc docker rmi <镜像名|镜像ID>...") + } + + refs, err := listImages(ctx) + if err != nil { + return err + } + tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "镜像\tID\t创建\t大小") + for _, target := range targets { + ref, err := lookupImageRef(refs, target) + if err != nil { + return err + } + name := ref.Name + if name == "" { + name = "(悬空镜像)" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", name, shortID(ref.ID), formatCreated(ref.CreatedAt), ref.Size) + } + return tw.Flush() +} + +// shortID trims docker's 12-character ID for display, keeping shorter IDs. +func shortID(id string) string { + id = strings.TrimPrefix(id, "sha256:") + if len(id) > 12 { + return id[:12] + } + return id +} + +// formatCreated renders docker's creation timestamp compactly. +func formatCreated(created string) string { + created = strings.TrimSpace(created) + if i := strings.IndexByte(created, '.'); i > 0 && strings.Contains(created[:i], "T") == false { + return created + } + // Docker prints "2024-10-01 12:34:56 +0800 CST"; drop the timezone suffix. + if i := strings.IndexByte(created, ' '); i > 0 { + rest := created[i+1:] + if j := strings.IndexByte(rest, ' '); j > 0 && (strings.Contains(rest, "+") || strings.Contains(rest, "-")) { + return created[:i+1+j] + } + } + return created +} diff --git a/internal/executor/dockersave.go b/internal/executor/dockersave.go index da0cf2c..7e6d55b 100644 --- a/internal/executor/dockersave.go +++ b/internal/executor/dockersave.go @@ -5,11 +5,8 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "strings" - - "gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose" ) // runDockerSave implements `docker save <镜像> > <镜像名>.tar`. @@ -43,20 +40,20 @@ func runDockerSave(ctx context.Context, req BuiltinRequest) error { return err } - ref, err := resolveImageRef(ctx, diagnose.ExecRunner{}, image) + ref, err := resolveImageRef(ctx, image) if err != nil { return err } - outPath := archiveName(ref) + outPath := archiveName(ref.Name) // Docker derives the archive's internal tags from the image itself, so the // caller's argument is passed through as given. args := append([]string{"save", "-o", outPath, image}, passthrough...) - if err := runRuntimeCommand(ctx, args, req); err != nil { + if err := dockerRun(ctx, args, req); err != nil { return err } - fmt.Fprintf(req.Stdout, "已保存: %s\n", ref) + fmt.Fprintf(req.Stdout, "已保存: %s\n", ref.Name) fmt.Fprintf(req.Stdout, "文件: %s\n", outPath) return nil } @@ -101,7 +98,7 @@ func runDockerLoad(ctx context.Context, req BuiltinRequest) error { if !stream { return errors.New("缺少归档文件;用法: kopsc docker load <镜像归档.tar>") } - return runRuntimeCommand(ctx, []string{"load"}, req) + return dockerRun(ctx, []string{"load"}, req) } var paths []string @@ -117,7 +114,7 @@ func runDockerLoad(ctx context.Context, req BuiltinRequest) error { for _, path := range paths { args = append(args, "-i", path) } - return runRuntimeCommand(ctx, args, req) + return dockerRun(ctx, args, req) } // saveValueOption lists docker `save` options that take a separate @@ -153,117 +150,6 @@ func existingArchive(archive string) (string, error) { return archive, nil } -// imageRef is the repository and tag (or digest) of one local image. -type imageRef struct { - Name string // repository[:tag][@digest], e.g. "alpine:3.20" - ID string // image ID, e.g. "sha256:abc..." -} - -// customFormat lets a single `images --format` call list IDs together with -// names (docker images -q prints IDs only). -const customFormat = `{{.ID}}{{"\t"}}{{if .Repository}}{{.Repository}}{{"\t"}}{{.Tag}}{{"\t"}}{{.Digest}}{{end}}` - -// resolveImageRef maps the user's argument to the image's repository[:tag]. -// -// A name is used as given once it is confirmed locally. An image ID — with or -// without the sha256: prefix, or a unique prefix — resolves to the name it is -// tagged with; an untagged image is an error, because the archive would have no -// name to take its file name from. Names always win over IDs, so a short name -// such as "alpine" is never mistaken for an ID prefix. -func resolveImageRef(ctx context.Context, runner diagnose.Runner, image string) (string, error) { - refs, err := listImages(ctx, runner) - if err != nil { - return "", err - } - - for _, ref := range refs { - if ref.Name == image { - return image, nil - } - } - - if !looksLikeImageID(image) { - return "", fmt.Errorf("本地没有镜像 %q,可用 kopsc docker images 查看", image) - } - - // Same image ID on several rows is normal (one row per repository:tag). - ids := map[string][]string{} - var order []string - for _, ref := range refs { - id := normalizeImageID(ref.ID) - if !strings.HasPrefix(id, normalizeImageID(image)) { - continue - } - if _, seen := ids[id]; !seen { - order = append(order, id) - } - ids[id] = append(ids[id], ref.Name) - } - - switch len(order) { - case 0: - return "", fmt.Errorf("本地没有镜像 ID %q,可用 kopsc docker images 查看", image) - case 1: - default: - return "", fmt.Errorf("镜像 ID %q 匹配多个本地镜像(%s),请改用镜像名称", - image, strings.Join(order, ", ")) - } - - names := ids[order[0]] - if len(names) > 1 { - return "", fmt.Errorf("镜像 ID %q 对应多个名称(%s),请直接指定要保存的名称", - image, strings.Join(names, ", ")) - } - if len(names) == 0 || names[0] == "" { - return "", fmt.Errorf("镜像 ID %q 存在但没有名称(未打标签),无法据此命名归档文件;请先 docker tag 再保存", image) - } - return names[0], nil -} - -// listImages asks docker for every local image. docker images prints a header -// line, which is dropped explicitly. -func listImages(ctx context.Context, runner diagnose.Runner) ([]imageRef, error) { - result, err := runner.Run(ctx, "docker", "images", "--format", customFormat) - if err != nil { - return nil, runtimeError("images", result, err) - } - - var refs []imageRef - for _, line := range strings.Split(string(result.Stdout), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "REPOSITORY") { - continue - } - fields := strings.Split(line, "\t") - ref := imageRef{ID: strings.TrimSpace(fields[0])} - if len(fields) >= 3 { - repo, tag, digest := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2]), "" - if len(fields) >= 4 { - digest = strings.TrimSpace(fields[3]) - } - ref.Name = formatImageName(repo, tag, digest) - } - refs = append(refs, ref) - } - return refs, nil -} - -// formatImageName renders repository[:tag][@digest]. "" is how both -// runtimes group untagged images. -func formatImageName(repo, tag, digest string) string { - if repo == "" { - return "" - } - name := repo - if tag != "" && tag != "" { - name += ":" + tag - } - if digest != "" && digest != "" { - name += "@" + digest - } - return name -} - // archiveName turns an image reference into a file name: every character that // cannot appear in a portable file name, including the ":" between repository // and tag, becomes "_". "alpine:3.20" -> "alpine_3.20.tar" and @@ -280,45 +166,3 @@ func archiveName(image string) string { } return b.String() + ".tar" } - -// looksLikeImageID reports whether s is an ID (optionally sha256:-prefixed) -// instead of a name. -func looksLikeImageID(s string) bool { - s = normalizeImageID(s) - if len(s) < 4 || len(s) > 64 { - return false - } - for _, r := range s { - if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { - return false - } - } - return true -} - -func normalizeImageID(s string) string { - return strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(s, "sha256:"), "sha256")) -} - -// runRuntimeCommand runs one docker command, streaming its output. -func runRuntimeCommand(ctx context.Context, args []string, req BuiltinRequest) error { - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = req.Stdout - cmd.Stderr = req.Stderr - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return &ExitError{Code: exitErr.ExitCode(), Err: err} - } - return fmt.Errorf("执行 docker 失败: %w", err) - } - return nil -} - -func runtimeError(subcommand string, result diagnose.CommandResult, err error) error { - detail := strings.TrimSpace(string(result.Stderr)) - if detail == "" { - detail = err.Error() - } - return fmt.Errorf("docker %s 失败: %s", subcommand, detail) -} diff --git a/internal/executor/dockersave_test.go b/internal/executor/dockersave_test.go index de78b33..b2d7d5a 100644 --- a/internal/executor/dockersave_test.go +++ b/internal/executor/dockersave_test.go @@ -49,6 +49,15 @@ func TestLooksLikeImageID(t *testing.T) { // `images --format` payload; every invocation is appended to the returned log // file as one line. func fakeRuntime(t *testing.T, images string) (logPath string) { + t.Helper() + return fakeRuntimeScript(t, images, "", "") +} + +// fakeRuntimeScript installs a fake `docker` on PATH. images and ps are the +// payloads of `docker images --format ...` and `docker ps -a --format ...`; +// every invocation is appended to the returned log file as one line. Empty +// payloads print nothing. +func fakeRuntimeScript(t *testing.T, images, ps, extraCase string) (logPath string) { t.Helper() dir := t.TempDir() logPath = filepath.Join(dir, "calls.log") @@ -59,6 +68,9 @@ func fakeRuntime(t *testing.T, images string) (logPath string) { "case \"$1\" in\n" + " images) cat <<'EOF'\n" + images + "\nEOF\n" + " ;;\n" + + " ps) cat <<'EOF'\n" + ps + "\nEOF\n" + + " ;;\n" + + extraCase + "esac\n" if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { t.Fatal(err) diff --git a/internal/executor/dockerstats.go b/internal/executor/dockerstats.go new file mode 100644 index 0000000..c413862 --- /dev/null +++ b/internal/executor/dockerstats.go @@ -0,0 +1,99 @@ +package executor + +import ( + "context" + "strings" +) + +// runDockerStats implements `kopsc docker stats [容器...]`. +// +// Plain `docker stats` prints an endless live table, which is rarely what +// anyone wants from a one-shot helper, so kopsc always asks for a single +// snapshot (`--no-stream`). With no container named, every running container +// is reported; with names, those are resolved to the running containers and +// only they are shown. Options such as --format are forwarded. +func runDockerStats(ctx context.Context, req BuiltinRequest) error { + opts, targets, passthrough, err := parseDockerStats(req.Args) + if err != nil { + return err + } + + if len(targets) > 0 { + refs, err := listContainers(ctx) + if err != nil { + return err + } + resolved := make([]string, 0, len(targets)) + for _, target := range targets { + ref, err := matchContainerRef(refs, target) + if err != nil { + return err + } + if !ref.Running() { + return &NotRunningError{Name: ref.Names} + } + resolved = append(resolved, ref.Names) + } + targets = resolved + } + + // docker stats takes the containers after its options; keeping the + // forwarded options last matches how a caller would type them. + args := append([]string{"stats"}, opts...) + args = append(args, targets...) + args = append(args, passthrough...) + return dockerRun(ctx, args, req) +} + +// NotRunningError reports a container docker stats cannot sample. +type NotRunningError struct { + Name string +} + +func (e *NotRunningError) Error() string { + return "容器 " + e.Name + " 未在运行,docker stats 只能统计运行中的容器;先启动请用 kopsc docker start " + e.Name +} + +// parseDockerStats separates options from container names. --no-stream is +// added by kopsc, so it is kept as an option and never duplicated. +func parseDockerStats(args []string) (opts, targets, passthrough []string, err error) { + opts = []string{"--no-stream"} + afterDashDash := false + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case afterDashDash: + targets = append(targets, arg) + case arg == "--": + afterDashDash = true + case !strings.HasPrefix(arg, "-") || arg == "-": + targets = append(targets, arg) + case arg == "--no-stream": + // Already requested by kopsc. + default: + passthrough = append(passthrough, arg) + if !strings.Contains(arg, "=") && statsValueOption[strings.TrimLeft(arg, "-")] { + if i+1 >= len(args) { + return nil, nil, nil, &MissingOptionValueError{Option: arg} + } + i++ + passthrough = append(passthrough, args[i]) + } + } + } + return opts, targets, passthrough, nil +} + +// statsValueOption lists docker stats options that take a separate value. +var statsValueOption = map[string]bool{ + "format": true, +} + +// MissingOptionValueError reports an option given without its value. +type MissingOptionValueError struct { + Option string +} + +func (e *MissingOptionValueError) Error() string { + return e.Option + " 缺少参数" +} diff --git a/internal/executor/dockertargets.go b/internal/executor/dockertargets.go new file mode 100644 index 0000000..d09df5a --- /dev/null +++ b/internal/executor/dockertargets.go @@ -0,0 +1,386 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose" +) + +// This file holds the shared plumbing for the docker commands kopsc implements +// itself: running docker, and resolving the image/container references a user +// types (a name, an ID, or a unique ID prefix) to the concrete object. + +// dockerRun streams one docker command's output to the caller's streams. +func dockerRun(ctx context.Context, args []string, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Stdin = nil + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &ExitError{Code: exitErr.ExitCode(), Err: err} + } + return fmt.Errorf("执行 docker 失败: %w", err) + } + return nil +} + +// dockerCapture runs one docker command and collects its output, so kopsc can +// parse it before deciding what to do. +func dockerCapture(ctx context.Context, args ...string) (diagnose.CommandResult, error) { + return diagnose.ExecRunner{}.Run(ctx, "docker", args...) +} + +// dockerOutput runs one docker command and returns its stdout, turning a +// failure into an error that carries docker's own message. +func dockerOutput(ctx context.Context, args ...string) (string, error) { + result, err := dockerCapture(ctx, args...) + if err != nil { + return "", dockerError(args[0], result, err) + } + return string(result.Stdout), nil +} + +// dockerError renders a failed docker invocation with its stderr. +func dockerError(subcommand string, result diagnose.CommandResult, err error) error { + detail := strings.TrimSpace(string(result.Stderr)) + if detail == "" { + detail = err.Error() + } + return fmt.Errorf("docker %s 失败: %s", subcommand, detail) +} + +// splitLines splits docker output into non-empty, trimmed lines. +func splitLines(out string) []string { + var lines []string + for _, line := range strings.Split(out, "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return lines +} + +// imageRef is one local image: its repository[:tag][@digest] and its ID. +type imageRef struct { + Name string // repository[:tag][@digest], e.g. "alpine:3.20"; empty when untagged + ID string // image ID, e.g. "sha256:abc..." + CreatedAt string // human-readable creation time, as docker lists it + Size string // human-readable size, as docker lists it + Repository string + Tag string +} + +// imageFormat lets a single `images` call list IDs together with names +// (docker images -q prints IDs only). +const imageFormat = `{{.ID}}{{"\t"}}{{if .Repository}}{{.Repository}}{{"\t"}}{{.Tag}}{{"\t"}}{{.Digest}}{{"\t"}}{{.CreatedAt}}{{"\t"}}{{.Size}}{{end}}` + +// listImages asks docker for every local image. docker images prints a header +// line, which is dropped explicitly. +func listImages(ctx context.Context) ([]imageRef, error) { + out, err := dockerOutput(ctx, "images", "--format", imageFormat) + if err != nil { + return nil, err + } + + var refs []imageRef + for _, line := range splitLines(out) { + if strings.HasPrefix(line, "REPOSITORY") { + continue + } + fields := strings.Split(line, "\t") + ref := imageRef{ID: strings.TrimSpace(fields[0])} + if len(fields) >= 3 { + repo, tag := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2]) + digest := "" + if len(fields) >= 4 { + digest = strings.TrimSpace(fields[3]) + } + if len(fields) >= 5 { + ref.CreatedAt = strings.TrimSpace(fields[4]) + } + if len(fields) >= 6 { + ref.Size = strings.TrimSpace(fields[5]) + } + ref.Repository = repo + ref.Tag = tag + ref.Name = formatImageName(repo, tag, digest) + } + refs = append(refs, ref) + } + return refs, nil +} + +// formatImageName renders repository[:tag][@digest]. "" is how docker +// groups untagged images. +func formatImageName(repo, tag, digest string) string { + if repo == "" { + return "" + } + name := repo + if tag != "" && tag != "" { + name += ":" + tag + } + if digest != "" && digest != "" { + name += "@" + digest + } + return name +} + +// resolveImageRef maps a user-supplied image name or ID to the local image. +// +// A name is used as given once it is confirmed locally. An image ID — with or +// without the sha256: prefix, or a unique prefix — resolves to the image that +// carries it. Names always win over IDs, so a short name such as "alpine" is +// never mistaken for an ID prefix. The returned ref always has a non-empty +// Name: every kopsc command that takes an image needs one to act on. +func resolveImageRef(ctx context.Context, image string) (imageRef, error) { + refs, err := listImages(ctx) + if err != nil { + return imageRef{}, err + } + return matchImageRef(refs, image) +} + +// matchImageRef is resolveImageRef over an already-fetched image list. +func matchImageRef(refs []imageRef, image string) (imageRef, error) { + ref, err := lookupImageRef(refs, image) + if err != nil { + return imageRef{}, err + } + if ref.Name == "" { + return imageRef{}, fmt.Errorf("镜像 %q 没有名称(未打标签),请先 docker tag %s <名称>:<标签>", image, ref.ID) + } + return ref, nil +} + +// lookupImageRef is matchImageRef without the naming requirement: it may +// return a ref with an empty Name for an untagged image. +func lookupImageRef(refs []imageRef, image string) (imageRef, error) { + for _, ref := range refs { + if ref.Name == image { + return ref, nil + } + } + + if !looksLikeImageID(image) { + return imageRef{}, fmt.Errorf("本地没有镜像 %q,可用 kopsc docker images 查看", image) + } + + // The same ID appears on one row per repository:tag, so group by ID. + wanted := normalizeImageID(image) + ids := map[string][]imageRef{} + var order []string + for _, ref := range refs { + id := normalizeImageID(ref.ID) + if !strings.HasPrefix(id, wanted) { + continue + } + if _, seen := ids[id]; !seen { + order = append(order, id) + } + ids[id] = append(ids[id], ref) + } + + switch len(order) { + case 0: + return imageRef{}, fmt.Errorf("本地没有镜像 ID %q,可用 kopsc docker images 查看", image) + case 1: + default: + return imageRef{}, fmt.Errorf("镜像 ID %q 匹配多个本地镜像(%s),请改用镜像名称", + image, strings.Join(order, ", ")) + } + + rows := ids[order[0]] + var names []string + for _, row := range rows { + if row.Name != "" { + names = append(names, row.Name) + } + } + resolved := rows[0] + if len(names) > 0 { + resolved.Name = names[0] + } + return resolved, nil +} + +// containerRef is one container as docker lists it. +type containerRef struct { + ID string // container ID, e.g. "3f2a..." + Names string // comma-separated names without docker's leading "/" + Image string + Status string // e.g. "Up 2 hours", "Exited (0) 3 days ago" +} + +// Running reports whether the container is up, judged from its status line. +func (c containerRef) Running() bool { return strings.HasPrefix(c.Status, "Up") } + +// containerFormat lists every container (including stopped ones) with the +// fields kopsc needs to identify it. +const containerFormat = `{{.ID}}{{"\t"}}{{.Names}}{{"\t"}}{{.Image}}{{"\t"}}{{.Status}}` + +// listContainers asks docker for every container, running or not. +func listContainers(ctx context.Context) ([]containerRef, error) { + out, err := dockerOutput(ctx, "ps", "-a", "--format", containerFormat) + if err != nil { + return nil, err + } + + var refs []containerRef + for _, line := range splitLines(out) { + fields := strings.Split(line, "\t") + if len(fields) < 4 { + continue + } + refs = append(refs, containerRef{ + ID: strings.TrimSpace(fields[0]), + Names: strings.TrimPrefix(strings.TrimSpace(fields[1]), "/"), + Image: strings.TrimSpace(fields[2]), + Status: strings.TrimSpace(fields[3]), + }) + } + return refs, nil +} + +// resolveContainerRef maps a user-supplied container name or ID to the local +// container, following docker's own precedence: an exact name, then an exact +// ID, then a unique ID prefix. +func resolveContainerRef(ctx context.Context, target string) (containerRef, error) { + refs, err := listContainers(ctx) + if err != nil { + return containerRef{}, err + } + return matchContainerRef(refs, target) +} + +// matchContainerRef is resolveContainerRef over an already-fetched list. +func matchContainerRef(refs []containerRef, target string) (containerRef, error) { + for _, ref := range refs { + if hasName(ref, target) { + return ref, nil + } + } + + wanted := normalizeContainerID(target) + if wanted != "" { + for _, ref := range refs { + if normalizeContainerID(ref.ID) == wanted { + return ref, nil + } + } + + var byPrefix []containerRef + for _, ref := range refs { + if strings.HasPrefix(normalizeContainerID(ref.ID), wanted) { + byPrefix = append(byPrefix, ref) + } + } + switch len(byPrefix) { + case 1: + return byPrefix[0], nil + case 0: + // Fall through to the "not found" error below. + default: + return containerRef{}, fmt.Errorf("容器 ID 前缀 %q 匹配多个容器(%s),请指定更长的 ID 或容器名称", + target, strings.Join(containerIDs(byPrefix), ", ")) + } + } + + return containerRef{}, fmt.Errorf("本地没有容器 %q,可用 kopsc docker psall 查看", target) +} + +// hasName reports whether ref carries exactly the given name (docker may list +// several aliases, comma-separated). +func hasName(ref containerRef, name string) bool { + for _, candidate := range strings.Split(ref.Names, ",") { + if strings.TrimSpace(candidate) == name { + return true + } + } + return false +} + +func containerIDs(refs []containerRef) []string { + ids := make([]string, len(refs)) + for i, ref := range refs { + ids[i] = ref.ID + } + return ids +} + +// looksLikeImageID reports whether s is an image ID (optionally +// sha256:-prefixed, lowercase hex) instead of a name. +func looksLikeImageID(s string) bool { + s = normalizeImageID(s) + if len(s) < 4 || len(s) > 64 { + return false + } + for _, r := range s { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +func normalizeImageID(s string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(s, "sha256:"), "sha256")) +} + +// normalizeContainerID strips the separators from a container ID so prefixes +// from `docker ps` and from the user compare equal. +func normalizeContainerID(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// captureDocker is a small helper for builtins that need docker's output in a +// buffer while still reporting failures uniformly. +func captureDocker(ctx context.Context, args ...string) ([]byte, error) { + result, err := dockerCapture(ctx, args...) + if err != nil { + return nil, dockerError(args[0], result, err) + } + return bytes.TrimSpace(result.Stdout), nil +} + +// startDockerPipe starts a docker command whose stdout is read line by line. +// Stderr goes straight to the caller so errors such as "No such container" +// are still shown. +func startDockerPipe(ctx context.Context, args []string, stderr io.Writer) (*exec.Cmd, io.ReadCloser, error) { + cmd := exec.CommandContext(ctx, "docker", args...) + out, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, fmt.Errorf("创建 docker 输出管道失败: %w", err) + } + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + return nil, nil, fmt.Errorf("启动 docker 失败: %w", err) + } + return cmd, out, nil +} + +// waitDockerPipe waits for a command started by startDockerPipe and forwards +// its exit code, preferring a scan error over the exit status when the read +// side failed first. +func waitDockerPipe(cmd *exec.Cmd, scanErr error) error { + err := cmd.Wait() + if scanErr != nil { + return fmt.Errorf("读取 docker 输出失败: %w", scanErr) + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &ExitError{Code: exitErr.ExitCode(), Err: err} + } + return fmt.Errorf("执行 docker 失败: %w", err) + } + return nil +} diff --git a/internal/executor/dockertargets_test.go b/internal/executor/dockertargets_test.go new file mode 100644 index 0000000..967657d --- /dev/null +++ b/internal/executor/dockertargets_test.go @@ -0,0 +1,773 @@ +//go:build !windows + +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve" +) + +func TestMatchContainerRefPrecedence(t *testing.T) { + refs := []containerRef{ + {ID: "3f2a1b0c9d8e", Names: "web", Image: "nginx:1.25", Status: "Up 2 hours"}, + {ID: "3f2a99aa0011", Names: "worker", Image: "busybox", Status: "Exited (0) 1 day ago"}, + {ID: "aabbccdd1122", Names: "db,primary", Image: "postgres:16", Status: "Exited (0) 3 days ago"}, + {ID: "deadbeef0001", Names: "deadbeef", Image: "alpine", Status: "Exited (1) 1 hour ago"}, + } + + tests := []struct { + name string + target string + wantID string + wantErr string + }{ + {name: "exact name", target: "web", wantID: "3f2a1b0c9d8e"}, + {name: "second alias", target: "primary", wantID: "aabbccdd1122"}, + {name: "exact id", target: "aabbccdd1122", wantID: "aabbccdd1122"}, + {name: "unique id prefix", target: "3f2a1b", wantID: "3f2a1b0c9d8e"}, + {name: "unique id prefix two", target: "3f2a99", wantID: "3f2a99aa0011"}, + {name: "name wins over id prefix", target: "deadbeef", wantID: "deadbeef0001"}, + {name: "unknown", target: "nope", wantErr: "本地没有容器"}, + {name: "ambiguous prefix", target: "3f2a", wantErr: "匹配多个容器"}, + {name: "empty target", target: "", wantErr: "本地没有容器"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ref, err := matchContainerRef(refs, tc.target) + 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 ref.ID != tc.wantID { + t.Fatalf("resolved %q to %q, want %q", tc.target, ref.ID, tc.wantID) + } + }) + } +} + +func TestContainerRefRunning(t *testing.T) { + cases := map[string]bool{ + "Up 2 hours": true, + "Up 3 seconds (healthy)": true, + "Exited (0) 3 days ago": false, + "Created": false, + "Restarting (1) 5 seconds ago": false, + "": false, + } + for status, want := range cases { + if got := (containerRef{Status: status}).Running(); got != want { + t.Errorf("Status %q: Running() = %v, want %v", status, got, want) + } + } +} + +func TestListContainersParsesDockerPs(t *testing.T) { + // The fake script answers `ps -a --format ...` with tab-separated rows, + // the same shape docker prints with that format. + logPath := fakeRuntimeScript(t, "", + "3f2a1b0c9d8e\t/web\tnginx:1.25\tUp 2 hours\n"+ + "aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "") + + refs, err := listContainers(context.Background()) + if err != nil { + t.Fatalf("listContainers: %v", err) + } + if len(refs) != 2 { + t.Fatalf("got %d containers, want 2: %+v", len(refs), refs) + } + if refs[0].Names != "web" || refs[1].Names != "db" { + t.Errorf("names = %q, %q; want web, db (leading / must be stripped)", refs[0].Names, refs[1].Names) + } + if !refs[0].Running() || refs[1].Running() { + t.Errorf("unexpected running state: %+v", refs) + } + if calls := calls(t, logPath); len(calls) == 0 || !strings.HasPrefix(calls[0], "ps -a --format") { + t.Errorf("docker calls = %q, want a `ps -a --format` invocation", calls) + } +} + +func TestDockerRunStreamsOutput(t *testing.T) { + fakeRuntimeScript(t, "", "", " version) echo ' Server: 27.0'\n ;;\n") + + var out bytes.Buffer + err := dockerRun(context.Background(), []string{"version"}, BuiltinRequest{Stdout: &out, Stderr: &out}) + if err != nil { + t.Fatalf("dockerRun: %v", err) + } + if !strings.Contains(out.String(), "Server: 27.0") { + t.Errorf("stdout not streamed: %q", out.String()) + } +} + +func TestDockerRunPropagatesExitCode(t *testing.T) { + fakeRuntimeScript(t, "", "", " version) echo boom >&2; exit 4\n ;;\n") + + var out bytes.Buffer + err := dockerRun(context.Background(), []string{"version"}, BuiltinRequest{Stdout: &out, Stderr: &out}) + if err == nil { + t.Fatal("expected an error") + } + if code := ExitCode(err); code != 4 { + t.Fatalf("exit code = %d, want 4", code) + } + if !strings.Contains(out.String(), "boom") { + t.Errorf("stderr not forwarded: %q", out.String()) + } +} + +func TestDockerCaptureReturnsOutput(t *testing.T) { + fakeRuntimeScript(t, "", "", " version) echo captured\n ;;\n") + + got, err := dockerOutput(context.Background(), "version") + if err != nil { + t.Fatalf("dockerOutput: %v", err) + } + if strings.TrimSpace(got) != "captured" { + t.Fatalf("output = %q, want captured", got) + } +} + +func TestDockerOutputReportsDockerError(t *testing.T) { + fakeRuntimeScript(t, "", "", " version) echo 'daemon not running' >&2; exit 1\n ;;\n") + + _, err := dockerOutput(context.Background(), "version") + if err == nil || !strings.Contains(err.Error(), "daemon not running") { + t.Fatalf("err = %v, want docker's stderr in the message", err) + } +} + +func rmFixture(t *testing.T) string { + t.Helper() + return fakeRuntimeScript(t, imageList(), + "3f2a1b0c9d8e\t/web\tnginx:1.25\tUp 2 hours\n"+ + "aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "") +} + +func TestDockerRMResolvesNameAndID(t *testing.T) { + logPath := rmFixture(t) + + // A container name and an ID prefix both reach docker as names. "web" is + // running in the fixture, so only the stopped container is removable. + if err := runDockerRM(context.Background(), BuiltinRequest{ + Args: []string{"db", "aabb"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("rm: %v", err) + } + + calls := calls(t, logPath) + joined := strings.Join(calls, " | ") + if !strings.Contains(joined, "rm db db") { + t.Fatalf("docker calls = %q, want `rm db db`", calls) + } +} + +func TestDockerRMUnknownTargetDoesNotCallDocker(t *testing.T) { + logPath := rmFixture(t) + + err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"nosuch"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "本地没有容器") { + t.Fatalf("err = %v, want unknown-container error", err) + } + for _, call := range calls(t, logPath) { + if strings.HasPrefix(call, "rm ") || call == "rm" { + t.Fatalf("docker rm must not run for an unknown target, calls = %q", calls(t, logPath)) + } + } +} + +func TestDockerRMRequiresTarget(t *testing.T) { + rmFixture(t) + if err := runDockerRM(context.Background(), BuiltinRequest{}); err == nil || + !strings.Contains(err.Error(), "缺少容器") { + t.Fatalf("err = %v, want missing-container error", err) + } +} + +func TestDockerRMIResolvesNameAndID(t *testing.T) { + logPath := rmFixture(t) + + if err := runDockerRMI(context.Background(), BuiltinRequest{ + Args: []string{"alpine:3.20"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("rmi by name: %v", err) + } + if err := runDockerRMI(context.Background(), BuiltinRequest{ + Args: []string{"sha256:5ca7b1d5e2fc"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("rmi by sha256 ID: %v", err) + } + if err := runDockerRMI(context.Background(), BuiltinRequest{ + Args: []string{"1b9a2c"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("rmi by ID prefix: %v", err) + } + + joined := strings.Join(calls(t, logPath), " | ") + for _, want := range []string{ + "rmi alpine:3.20", + "rmi nginx:1.25.3", // 5ca7… resolves to its name + "rmi registry.example.com:5000/team/app:1.0", // 1b9a… likewise + } { + if !strings.Contains(joined, want) { + t.Errorf("docker calls missing %q: %s", want, joined) + } + } +} + +func TestDockerRMIUntaggedUsesID(t *testing.T) { + logPath := rmFixture(t) + + // A dangling image has no name, so it is removed by ID. + if err := runDockerRMI(context.Background(), BuiltinRequest{ + Args: []string{"7d3e5f7a9b1c"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("rmi: %v", err) + } + if joined := strings.Join(calls(t, logPath), " | "); !strings.Contains(joined, "rmi 7d3e5f7a9b1c") { + t.Fatalf("docker calls = %s, want the ID", joined) + } +} + +func TestDockerRMIUnknownImageDoesNotCallDocker(t *testing.T) { + logPath := rmFixture(t) + + err := runDockerRMI(context.Background(), BuiltinRequest{Args: []string{"busybox:1"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "本地没有镜像") { + t.Fatalf("err = %v, want unknown-image error", err) + } + for _, call := range calls(t, logPath) { + if strings.HasPrefix(call, "rmi") { + t.Fatalf("docker rmi must not run for an unknown image, calls = %q", calls(t, logPath)) + } + } +} + +func TestRejectForce(t *testing.T) { + msg := "no -f" + for _, args := range [][]string{{"-f"}, {"--force"}, {"--force=true"}, {"-fv"}, {"x", "-f"}} { + if err := rejectForce(args, msg); err == nil { + t.Errorf("args %v: expected -f to be rejected", args) + } + } + for _, args := range [][]string{{"web"}, {"--force=false"}, {"--", "-f"}} { + if err := rejectForce(args, msg); err != nil { + t.Errorf("args %v: unexpected error %v", args, err) + } + } +} + +func TestDockerStopAndRestartResolveTargets(t *testing.T) { + logPath := rmFixture(t) + + if err := runDockerStop(context.Background(), BuiltinRequest{ + Args: []string{"3f2a1b", "-t", "5"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("stop: %v", err) + } + if err := runDockerRestart(context.Background(), BuiltinRequest{ + Args: []string{"db"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("restart: %v", err) + } + + joined := strings.Join(calls(t, logPath), " | ") + // The ID prefix resolves to the container name; -t keeps its value. + if !strings.Contains(joined, "stop web -t 5") { + t.Errorf("docker calls = %s, want `stop web -t 5`", joined) + } + if !strings.Contains(joined, "restart db") { + t.Errorf("docker calls = %s, want `restart db`", joined) + } +} + +func TestDockerStopUnknownContainerPointsAtPsall(t *testing.T) { + rmFixture(t) + + err := runDockerStop(context.Background(), BuiltinRequest{Args: []string{"ghost"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "kopsc docker psall") { + t.Fatalf("err = %v, want a hint to run `kopsc docker psall`", err) + } +} + +func TestDockerExecDefaultsToInteractive(t *testing.T) { + logPath := rmFixture(t) + + // The command's own flags (-p, -i) belong to the command, not to docker. + if err := runDockerExec(context.Background(), BuiltinRequest{ + Args: []string{"web", "sh", "-c", "echo hi"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("exec: %v", err) + } + joined := strings.Join(calls(t, logPath), " | ") + if !strings.Contains(joined, "exec -it web -- sh -c echo hi") { + t.Fatalf("docker calls = %s, want `exec -it web -- sh -c echo hi`", joined) + } +} + +func TestDockerExecKeepsUserOptions(t *testing.T) { + logPath := rmFixture(t) + + if err := runDockerExec(context.Background(), BuiltinRequest{ + Args: []string{"-u", "0", "-w", "/app", "web", "env"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("exec: %v", err) + } + joined := strings.Join(calls(t, logPath), " | ") + if !strings.Contains(joined, "exec -it -u 0 -w /app web -- env") { + t.Fatalf("docker calls = %s, want the options kept before the container", joined) + } +} + +func TestDockerExecRejectsExplicitInteractiveFlags(t *testing.T) { + rmFixture(t) + + for _, args := range [][]string{ + {"-it", "web", "sh"}, + {"-i", "web", "sh"}, + {"-t", "web", "sh"}, + {"--interactive", "web", "sh"}, + {"-ti", "web", "sh"}, + } { + err := runDockerExec(context.Background(), BuiltinRequest{Args: args, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "已默认以交互方式") { + t.Errorf("args %v: err = %v, want the interactive-flag rejection", args, err) + } + } +} + +func TestDockerExecRequiresContainer(t *testing.T) { + rmFixture(t) + if err := runDockerExec(context.Background(), BuiltinRequest{}); err == nil || + !strings.Contains(err.Error(), "缺少容器") { + t.Fatalf("err = %v, want missing-container error", err) + } +} + +func TestExecArgv(t *testing.T) { + got := strings.Join(execArgv([]string{"-u", "0"}, "web", "sh", "-c", "id"), " ") + if want := "exec -it -u 0 web -- sh -c id"; got != want { + t.Fatalf("execArgv = %q, want %q", got, want) + } +} + +// logsScript makes a fake docker print three log lines for `logs`. +func logsScript(t *testing.T) string { + t.Helper() + return fakeRuntimeScript(t, "", "", " logs) printf 'INFO start\\nERROR boom\\nINFO done\\n'\n ;;\n") +} + +func TestDockerLogsStreamsVerbatimWithoutKopscOptions(t *testing.T) { + logPath := logsScript(t) + + var out bytes.Buffer + if err := runDockerLogs(context.Background(), BuiltinRequest{ + Args: []string{"web", "-f", "--tail", "10"}, Stdout: &out, + }); err != nil { + t.Fatalf("logs: %v", err) + } + if !strings.Contains(out.String(), "ERROR boom") { + t.Errorf("all lines should be shown, got %q", out.String()) + } + joined := strings.Join(calls(t, logPath), " | ") + // -f and --tail come after the container and go to docker untouched. + if !strings.Contains(joined, "logs web -f --tail 10") { + t.Errorf("docker calls = %s, want `logs web -f --tail 10`", joined) + } +} + +func TestDockerLogsGrepFiltersLines(t *testing.T) { + logsScript(t) + + var out bytes.Buffer + if err := runDockerLogs(context.Background(), BuiltinRequest{ + Args: []string{"--grep", "^ERROR", "web"}, Stdout: &out, + }); err != nil { + t.Fatalf("logs: %v", err) + } + if strings.Contains(out.String(), "INFO") { + t.Errorf("INFO lines must be filtered out, got %q", out.String()) + } + if !strings.Contains(out.String(), "ERROR boom") { + t.Errorf("the matching line is missing: %q", out.String()) + } +} + +func TestDockerLogsSaveWritesFile(t *testing.T) { + logsScript(t) + path := filepath.Join(t.TempDir(), "sub", "web.log") + + var out bytes.Buffer + if err := runDockerLogs(context.Background(), BuiltinRequest{ + Args: []string{"--save", path, "web"}, Stdout: &out, + }); err != nil { + t.Fatalf("logs: %v", err) + } + if !strings.Contains(out.String(), "ERROR boom") { + t.Errorf("logs should still be shown, got %q", out.String()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("log file not written: %v", err) + } + if string(data) != "INFO start\nERROR boom\nINFO done\n" { + t.Errorf("log file = %q", data) + } +} + +func TestDockerLogsPassesSinceOnce(t *testing.T) { + logPath := logsScript(t) + + if err := runDockerLogs(context.Background(), BuiltinRequest{ + Args: []string{"--since", "2h", "--grep", "INFO", "web"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("logs: %v", err) + } + call := strings.Join(calls(t, logPath), " | ") + if strings.Count(call, "--since") != 1 || !strings.Contains(call, "logs web --since 2h") { + t.Errorf("docker calls = %s, want exactly one --since 2h", call) + } +} + +func TestDockerLogsRejectsBadArguments(t *testing.T) { + logsScript(t) + + tests := []struct { + args []string + want string + }{ + {nil, "缺少容器"}, + {[]string{"--grep"}, "--grep 缺少参数"}, + {[]string{"--grep", "[", "web"}, "正则表达式无效"}, + } + for _, tc := range tests { + err := runDockerLogs(context.Background(), BuiltinRequest{Args: tc.args, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want) + } + } +} + +func TestParseDockerSize(t *testing.T) { + cases := map[string]int64{ + "0B": 0, + "512B": 512, + "-": 0, + "": 0, + "1KB": 1000, + "1.234GB": 1234000000, + "1.5GB (25%)": 1500000000, + "2.1GB (2.1GB)": 2100000000, + "1KiB": 1024, + "3 MiB": 3 << 20, + "1024": 1024, + } + for in, want := range cases { + got, err := parseDockerSize(in) + if err != nil { + t.Errorf("parseDockerSize(%q): %v", in, err) + continue + } + if got != want { + t.Errorf("parseDockerSize(%q) = %d, want %d", in, got, want) + } + } + if _, err := parseDockerSize("lots"); err == nil { + t.Error("parseDockerSize should reject non-numeric sizes") + } +} + +func TestDockerDFRanksBySize(t *testing.T) { + logPath := fakeRuntimeScript(t, "", "", " system) printf 'TYPE\\tTOTAL\\tACTIVE\\tSIZE\\tRECLAIMABLE\\nContainers\\t12\\t3\\t512MB\\t256MB\\nImages\\t30\\t5\\t3.5GB\\t1.2GB\\nLocal Volumes\\t8\\t2\\t900MB\\t900MB\\nBuild Cache\\t40\\t10\\t1.1GB\\t1.1GB\\n'\n ;;\n") + + var out bytes.Buffer + if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &out, Stderr: &out}); err != nil { + t.Fatalf("df: %v", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + // header + 4 rows + total + blank + hint + if len(lines) < 6 { + t.Fatalf("unexpected output:\n%s", out.String()) + } + if !strings.Contains(lines[0], "类型") || !strings.Contains(lines[0], "可回收") { + t.Errorf("header = %q", lines[0]) + } + // Largest first: Images (3.5GB) > Build Cache (1.1GB) > Volumes (900MB) > Containers. + wantOrder := []string{"Images", "Build Cache", "Local Volumes", "Containers"} + for i, want := range wantOrder { + if !strings.Contains(lines[i+1], want) { + t.Errorf("line %d = %q, want %q first", i+1, lines[i+1], want) + } + } + if !strings.Contains(lines[5], "总计") { + t.Errorf("total line = %q", lines[5]) + } + if !strings.Contains(out.String(), "可回收") { + t.Errorf("missing reclaim hint:\n%s", out.String()) + } + if !strings.Contains(strings.Join(calls(t, logPath), " "), "system df") { + t.Error("df should call `docker system df`") + } +} + +func TestDockerDFNoReclaimHint(t *testing.T) { + fakeRuntimeScript(t, "", "", " system) printf 'TYPE\\tTOTAL\\tACTIVE\\tSIZE\\tRECLAIMABLE\\nImages\\t1\\t1\\t0B\\t0B\\n'\n ;;\n") + + var out bytes.Buffer + if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &out, Stderr: &out}); err != nil { + t.Fatalf("df: %v", err) + } + if !strings.Contains(out.String(), "没有可回收的空间") { + t.Errorf("output = %q", out.String()) + } +} + +func TestDockerDFEmptyOutputIsAnError(t *testing.T) { + fakeRuntimeScript(t, "", "", "") + if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &bytes.Buffer{}}); err == nil || + !strings.Contains(err.Error(), "没有返回任何数据") { + t.Fatalf("err = %v, want empty-output error", err) + } +} + +func statsFixture(t *testing.T) string { + t.Helper() + return fakeRuntimeScript(t, "", + "3f2a1b0c9d8e\tweb\tnginx:1.25\tUp 2 hours\n"+ + "aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "") +} + +func TestDockerStatsWithoutArgsUsesNoStream(t *testing.T) { + logPath := statsFixture(t) + + if err := runDockerStats(context.Background(), BuiltinRequest{Stdout: &bytes.Buffer{}}); err != nil { + t.Fatalf("stats: %v", err) + } + joined := strings.Join(calls(t, logPath), " | ") + if !strings.Contains(joined, "stats --no-stream") { + t.Fatalf("docker calls = %s, want `stats --no-stream`", joined) + } + // Without names docker reports every running container itself; kopsc must + // not invent a container list. + if strings.Contains(joined, " ps ") { + t.Errorf("stats without args should not list containers first: %s", joined) + } +} + +func TestDockerStatsResolvesNamedContainer(t *testing.T) { + logPath := statsFixture(t) + + if err := runDockerStats(context.Background(), BuiltinRequest{ + Args: []string{"3f2a1b", "--format", "{{.Name}}"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("stats: %v", err) + } + joined := strings.Join(calls(t, logPath), " | ") + if !strings.Contains(joined, "stats --no-stream web --format {{.Name}}") { + t.Fatalf("docker calls = %s, want the resolved name and forwarded --format", joined) + } +} + +func TestDockerStatsRejectsStoppedContainer(t *testing.T) { + statsFixture(t) + + err := runDockerStats(context.Background(), BuiltinRequest{Args: []string{"db"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "未在运行") { + t.Fatalf("err = %v, want not-running error", err) + } +} + +func TestDockerStatsDoesNotDuplicateNoStream(t *testing.T) { + logPath := statsFixture(t) + + if err := runDockerStats(context.Background(), BuiltinRequest{ + Args: []string{"--no-stream"}, Stdout: &bytes.Buffer{}, + }); err != nil { + t.Fatalf("stats: %v", err) + } + call := strings.Join(calls(t, logPath), " | ") + if strings.Count(call, "--no-stream") != 1 { + t.Errorf("--no-stream must appear once, calls = %s", call) + } +} + +func TestDockerPreviewRMListsContainers(t *testing.T) { + rmFixture(t) + + var out bytes.Buffer + if err := runDockerPreviewRM(context.Background(), BuiltinRequest{ + Args: []string{"web", "aabb"}, Stdout: &out, + }); err != nil { + t.Fatalf("preview: %v", err) + } + text := out.String() + header := strings.Split(strings.TrimSpace(text), "\n")[0] + for _, col := range []string{"ID", "名称", "镜像", "状态"} { + if !strings.Contains(header, col) { + t.Errorf("header %q missing column %q", header, col) + } + } + // Each target is one row: ID, name, image, status. + for _, want := range []string{"3f2a1b0c9d8e", "web", "nginx:1.25", "Up 2 hours", "db", "postgres:16", "Exited (0) 3 days ago"} { + if !strings.Contains(text, want) { + t.Errorf("preview missing %q:\n%s", want, text) + } + } +} + +func TestDockerPreviewRMRejectsUnknownBeforeYes(t *testing.T) { + rmFixture(t) + err := runDockerPreviewRM(context.Background(), BuiltinRequest{Args: []string{"ghost"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "本地没有容器") { + t.Fatalf("err = %v, want unknown-container error", err) + } +} + +func TestDockerPreviewRMIUsesNameIDCreatedSize(t *testing.T) { + rmFixture(t) + + var out bytes.Buffer + if err := runDockerPreviewRMI(context.Background(), BuiltinRequest{ + Args: []string{"alpine:3.20", "7d3e5f7a9b1c"}, Stdout: &out, + }); err != nil { + t.Fatalf("preview: %v", err) + } + text := out.String() + header := strings.Split(strings.TrimSpace(text), "\n")[0] + for _, col := range []string{"镜像", "ID", "创建", "大小"} { + if !strings.Contains(header, col) { + t.Errorf("header %q missing column %q", header, col) + } + } + if !strings.Contains(text, "alpine:3.20") || !strings.Contains(text, "9c6f07244728") { + t.Errorf("named image row missing:\n%s", text) + } + // The dangling image is shown as such rather than with a blank name. + if !strings.Contains(text, "悬空镜像") { + t.Errorf("dangling image not marked:\n%s", text) + } +} + +func TestShortID(t *testing.T) { + cases := map[string]string{ + "sha256:9c6f0724472892f3061e": "9c6f07244728", + "3f2a1b0c9d8e": "3f2a1b0c9d8e", + "abc": "abc", + } + for in, want := range cases { + if got := shortID(in); got != want { + t.Errorf("shortID(%q) = %q, want %q", in, got, want) + } + } +} + +func TestDockerRMAndRMIRejectForce(t *testing.T) { + for _, flag := range []string{"-f", "--force", "--force=true"} { + logPath := rmFixture(t) + err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{flag, "web"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "不再接受 -f") || !strings.Contains(err.Error(), "--yes") { + t.Errorf("rm %s: err = %v, want -f rejection pointing at --yes", flag, err) + } + for _, call := range calls(t, logPath) { + if call == "rm" || strings.HasPrefix(call, "rm ") { + t.Errorf("rm %s must not call docker, calls = %q", flag, calls(t, logPath)) + } + } + } + + logPath := rmFixture(t) + err := runDockerRMI(context.Background(), BuiltinRequest{Args: []string{"--force", "alpine:3.20"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "不再接受 -f") { + t.Fatalf("rmi --force: err = %v, want -f rejection", err) + } + for _, call := range calls(t, logPath) { + if strings.HasPrefix(call, "rmi") { + t.Errorf("rmi must not call docker, calls = %q", calls(t, logPath)) + } + } +} + +func TestDockerRMRejectsRunningContainer(t *testing.T) { + logPath := rmFixture(t) + + err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"web"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "正在运行") || !strings.Contains(err.Error(), "kopsc docker stop web") { + t.Fatalf("err = %v, want a hint to stop the container first", err) + } + for _, call := range calls(t, logPath) { + if call == "rm" || strings.HasPrefix(call, "rm ") { + t.Errorf("docker rm must not run for a running container, calls = %q", calls(t, logPath)) + } + } + + // A stopped container is removable. + if err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"db"}, Stdout: &bytes.Buffer{}}); err != nil { + t.Fatalf("removing a stopped container: %v", err) + } +} + +func TestDockerPreviewRMMarksRunningContainer(t *testing.T) { + rmFixture(t) + + var out bytes.Buffer + if err := runDockerPreviewRM(context.Background(), BuiltinRequest{Args: []string{"web"}, Stdout: &out}); err != nil { + t.Fatalf("preview: %v", err) + } + if !strings.Contains(out.String(), "需先 kopsc docker stop web") { + t.Errorf("preview should warn about the running container:\n%s", out.String()) + } +} + +func TestDryRunNeverTouchesDocker(t *testing.T) { + for _, name := range []string{"docker.rm", "docker.rmi", "docker.stop", "docker.exec", "docker.logs", "docker.logs --grep"} { + logPath := rmFixture(t) + builtinName, args := name, []string{"web"} + if name == "docker.rmi" { + args = []string{"alpine:3.20"} + } + if name == "docker.logs --grep" { + builtinName, args = "docker.logs", []string{"--grep", "ERROR", "web"} + } + + fn, ok := LookupBuiltin(builtinName) + if !ok { + t.Fatalf("builtin %q not registered", builtinName) + } + // A dry-run is handled before any builtin runs; verify the executor's + // contract directly. + plan := Plan{Result: resolveResultWithVariant(registryVariantBuiltinFor(builtinName)), ExtraArgs: args} + var out bytes.Buffer + if err := plan.Run(context.Background(), Options{DryRun: true, Stdout: &out}); err != nil { + t.Fatalf("%s dry-run: %v", builtinName, err) + } + if !strings.Contains(out.String(), "[dry-run]") { + t.Errorf("%s: dry-run output missing marker: %q", builtinName, out.String()) + } + if got := calls(t, logPath); len(got) != 0 { + t.Errorf("%s: dry-run must not call docker, calls = %q", builtinName, got) + } + _ = fn + } +} + +// registryVariantBuiltinFor builds the resolved variant kopsc would pick for +// the given builtin name, so direct Plan tests exercise the real path. +func registryVariantBuiltinFor(name string) registry.Variant { + return registry.Variant{Builtin: name, Runtime: "docker", Match: registry.Match{OS: "linux"}} +} + +func resolveResultWithVariant(v registry.Variant) resolve.Result { + return resolve.Result{Command: ®istry.Command{ID: "docker.test"}, Variant: v} +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7e91d1e..b5ecdf9 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -57,6 +57,12 @@ func (p Plan) Display() string { return strings.Join(argv, " ") } +// PreviewBuiltin returns the builtin advertised by the resolved command for +// listing what it would touch, or "" when the command has none. +func (p Plan) PreviewBuiltin() string { + return p.Result.Command.ConfirmBuiltin +} + // Explain writes why this variant was chosen and what will run. func (p Plan) Explain(w io.Writer) { if w == nil { diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index db76892..f1bfa40 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -27,18 +27,21 @@ func Builtin() *Registry { Variants: runtimeVariants("images"), }, { - ID: "docker.stats", - Group: "docker", - Summary: "一次性查看容器 CPU/内存/IO 占用", - Action: "只读:等价 docker stats --no-stream,输出一次后退出", - Variants: runtimeVariants("stats", "--no-stream"), + ID: "docker.stats", + Group: "docker", + Summary: "一次性查看容器 CPU/内存/IO 占用", + Action: "只读:等价 docker stats --no-stream,输出一次后退出;不给容器名时自动统计全部运行中的容器," + + "给了名称或 ID 时只统计这些(需正在运行);--format 等选项可透传", + Usage: "[容器...]", + Variants: runtimeBuiltinVariants("stats"), }, { - ID: "docker.df", - Group: "docker", - Summary: "查看镜像/容器/卷/构建缓存的空间占用", - Action: "只读:等价 docker system df", - Variants: runtimeVariants("system", "df"), + ID: "docker.df", + Group: "docker", + Summary: "磁盘占用排行(镜像/容器/卷/构建缓存)", + Action: "只读:在 docker system df 之上按占用大小降序排列,输出「类型 / 项 / 可清理 / 大小 / 可回收」," + + "并给出总计与一句可回收空间提示", + Variants: runtimeBuiltinVariants("df"), }, { ID: "docker.info", @@ -55,13 +58,14 @@ func Builtin() *Registry { Variants: runtimeVariants("version"), }, { - ID: "docker.logs", - Group: "docker", - Summary: "查看容器日志", - Action: "只读:等价 docker logs <容器>;-f、--tail 等选项可透传", - Usage: "<容器>", + ID: "docker.logs", + Group: "docker", + Summary: "查看容器日志(可过滤、可落盘)", + Action: "等价 docker logs <容器>,并增加 kopsc 选项:--since 只看某时间段、--grep 只显示匹配行、--save 把显示的日志追加写入文件;" + + "--since/--grep/--save 必须写在容器名之前,容器名之后的 -f、--tail 等原样交给 docker", + Usage: "<容器> [选项...]", MinArgs: 1, - Variants: runtimeVariants("logs"), + Variants: runtimeBuiltinVariants("logs"), }, { ID: "docker.inspect", @@ -98,49 +102,59 @@ func Builtin() *Registry { Variants: runtimeVariants("start"), }, { - ID: "docker.stop", - Group: "docker", - Summary: "停止运行中的容器", - Action: "向容器发送 SIGTERM,超时后由运行时强制结束", - Usage: "<容器>", + ID: "docker.stop", + Group: "docker", + Summary: "停止运行中的容器(按名称或 ID)", + Action: "等价 docker stop:容器按名称、ID 或唯一 ID 前缀解析,一次可停多个;" + + "先发送 SIGTERM,超时后由 docker 强制结束。-t/--time、-s/--signal 可透传", + Usage: "<容器>...", MinArgs: 1, - Variants: runtimeVariants("stop"), + Variants: runtimeBuiltinVariants("stop"), }, { - ID: "docker.restart", - Group: "docker", - Summary: "重启容器", - Action: "停止并重新启动一个或多个容器", - Usage: "<容器>", + ID: "docker.restart", + Group: "docker", + Summary: "重启容器(按名称或 ID)", + Action: "等价 docker restart:容器按名称、ID 或唯一 ID 前缀解析,一次可重启多个;" + + "-t/--time、-s/--signal 可透传", + Usage: "<容器>...", MinArgs: 1, - Variants: runtimeVariants("restart"), + Variants: runtimeBuiltinVariants("restart"), }, { - ID: "docker.rm", - Group: "docker", - Summary: "删除已停止的容器", - Action: "删除指定容器;运行中的容器需要先停止(不会自动加 -f)", - Usage: "<容器>", - MinArgs: 1, - Variants: runtimeVariants("rm"), + ID: "docker.rm", + Group: "docker", + Summary: "删除容器(按名称或 ID)", + Action: "等价 docker rm:容器按名称、ID 或唯一 ID 前缀解析,一次可删多个;" + + "运行中的容器不会被强制删除,需先 kopsc docker stop。不再接受 -f,需要强删请用 --yes 确认", + Usage: "<容器>...", + MinArgs: 1, + RequiresYes: true, + ConfirmBuiltin: "docker.rm.preview", + Variants: runtimeBuiltinVariants("rm"), }, { - ID: "docker.rmi", - Group: "docker", - Summary: "删除本地镜像", - Action: "删除指定镜像;被容器引用的镜像会被运行时拒绝删除", - Usage: "<镜像>", - MinArgs: 1, - Variants: runtimeVariants("rmi"), + ID: "docker.rmi", + Group: "docker", + Summary: "删除本地镜像(按名称或 ID)", + Action: "等价 docker rmi:镜像按名称、ID、sha256:ID 或唯一 ID 前缀解析,一次可删多个;" + + "未打标签的悬空镜像按 ID 删除。不再接受 -f,需要强删请用 --yes 确认", + Usage: "<镜像名|镜像ID>...", + MinArgs: 1, + RequiresYes: true, + ConfirmBuiltin: "docker.rmi.preview", + Variants: runtimeBuiltinVariants("rmi"), }, { - ID: "docker.exec", - Group: "docker", - Summary: "在运行中的容器内执行命令", - Action: "等价 docker exec <容器> <命令...>;交互场景可透传 -it", + ID: "docker.exec", + Group: "docker", + Summary: "在运行中的容器内执行命令(默认交互)", + Action: "等价 docker exec -it <容器> -- <命令...>:默认就是交互模式,无需再写 -it(显式传 -i/-t/-it 会报错);" + + "容器名之后的参数一律作为在容器内执行的命令,其中的 -i、-p 等不会被当成 docker 选项;" + + "容器名之前的选项(如 -u、-w、-e)原样透传给 docker", Usage: "<容器> <命令...>", MinArgs: 1, - Variants: runtimeVariants("exec"), + Variants: runtimeBuiltinVariants("exec"), }, { ID: "docker.pull", diff --git a/internal/registry/registry.go b/internal/registry/registry.go index acfc746..a1fb606 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -49,6 +49,11 @@ type Command struct { // RequiresYes marks destructive commands: without an explicit --yes they // print what they would do and exit non-zero instead of running. RequiresYes bool + // ConfirmBuiltin names a builtin that lists exactly what the command would + // touch (the containers or images about to be deleted). It runs before the + // confirmation prompt and for --dry-run; its output is shown to the + // caller. Empty means "no preview available". + ConfirmBuiltin string // Namespaced marks Kubernetes-aware commands where -n means --namespace // (kubectl style) instead of the global --dry-run shorthand. Namespaced bool diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 62bff6d..da6d421 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -213,6 +213,9 @@ func TestDockerCommandsWellFormed(t *testing.T) { destructive := map[string]bool{ "docker.clearall": true, "docker.prunevolumes": true, + // rm/rmi now list their targets and require an explicit --yes. + "docker.rm": true, + "docker.rmi": true, } needsArgs := map[string]int{ "docker.logs": 1, @@ -231,8 +234,16 @@ func TestDockerCommandsWellFormed(t *testing.T) { } // Built from kopsc's own implementation rather than a fixed argv. builtin := map[string]string{ - "docker.save": "docker.save", - "docker.load": "docker.load", + "docker.df": "docker.df", + "docker.exec": "docker.exec", + "docker.logs": "docker.logs", + "docker.stats": "docker.stats", + "docker.load": "docker.load", + "docker.restart": "docker.restart", + "docker.rm": "docker.rm", + "docker.rmi": "docker.rmi", + "docker.save": "docker.save", + "docker.stop": "docker.stop", } for i := range reg.Commands {