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
+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
}