feat: 将更多 Docker 命令改为内置实现,统一按名称/ID 解析目标

- exec/rm/rmi/stop/restart 支持名称、ID 或唯一 ID 前缀,目标不存在或
  前缀有歧义时在调用 docker 前报错;exec 默认 -it 并以 -- 分隔命令
- logs 新增 --since/--grep/--save;df 改为按占用排序的可读排行;
  stats 默认单次快照,无参数时统计全部运行中容器
- 破坏性确认:Command.ConfirmBuiltin 提供将删除对象的清单,
  rm/rmi 无 --yes 时列清单并退出码 2,拒绝 -f,运行中容器提示先 stop
- 抽出 executor/dockertargets.go 统一镜像/容器解析与 docker 调用
This commit is contained in:
2026-11-01 08:01:12 +08:00
parent bd6f20d7c5
commit f5ae80c1a8
18 changed files with 2308 additions and 226 deletions
+17 -6
View File
@@ -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 `--`
+102
View File
@@ -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
}
+52 -6
View File
@@ -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]
+173
View File
@@ -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)
}
+106
View File
@@ -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
}
+52
View File
@@ -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,
}
+177
View File
@@ -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
}
+230
View File
@@ -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 = "<none>(悬空镜像)"
}
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
}
+6 -162
View File
@@ -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]. "<none>" is how both
// runtimes group untagged images.
func formatImageName(repo, tag, digest string) string {
if repo == "<none>" {
return ""
}
name := repo
if tag != "" && tag != "<none>" {
name += ":" + tag
}
if digest != "" && digest != "<none>" {
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)
}
+12
View File
@@ -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)
+99
View File
@@ -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 + " 缺少参数"
}
+386
View File
@@ -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]. "<none>" is how docker
// groups untagged images.
func formatImageName(repo, tag, digest string) string {
if repo == "<none>" {
return ""
}
name := repo
if tag != "" && tag != "<none>" {
name += ":" + tag
}
if digest != "" && digest != "<none>" {
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
}
+773
View File
@@ -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: &registry.Command{ID: "docker.test"}, Variant: v}
}
+6
View File
@@ -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 {
+61 -47
View File
@@ -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",
+5
View File
@@ -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
+13 -2
View File
@@ -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 {