feat: 优化非 Docker 命令的易用性,修复带 digest 镜像无法按名称查找

- docker save/rmi:镜像名不再拼接 digest({{.Digest}} 对多数镜像输出
  sha256:…,导致 alpine:3.20 被存成 alpine:3.20@sha256:… 而查不到),
  digest 单独保存;同时修正自定义 --format 输出中 IMAGE ID 表头未被过滤
- diagnose:目标不存在时给出相似名称候选,无相似时列出当前存在的对象
- net.pids:无进程占用时向 stderr 提示且 stdout 保持为空,新增 --pid-file
- net.ports:默认输出跨平台一致的端口表格,--raw 保留原生命令输出
- disk.usage:路径可省略(默认当前目录),新增 --depth N 控制层级
This commit is contained in:
2026-11-01 08:37:44 +08:00
parent f5ae80c1a8
commit 6b6f8619df
15 changed files with 1317 additions and 130 deletions
+88 -18
View File
@@ -27,56 +27,100 @@ var pidPattern = regexp.MustCompile(`pid=(\d+)`)
// - macOS: lsof (the system ships it; no /proc fallback)
// - Windows: netstat -ano
func runNetPIDs(ctx context.Context, req BuiltinRequest) error {
port, err := parsePortArgs(req.Args)
opts, err := parseNetPIDsArgs(req.Args)
if err != nil {
return err
}
port := opts.port
var pids []string
if runtime.GOOS == "windows" {
return netPIDsWindows(ctx, port, req)
pids, err = windowsPIDs(ctx, port)
} else {
pids, err = unixPIDs(ctx, port)
}
return netPIDsUnix(ctx, port, req)
if err != nil {
return err
}
return reportPIDs(opts, pids, req)
}
// netPIDsOptions are the parsed arguments of `kopsc net pids`.
type netPIDsOptions struct {
port int
pidFile string // --pid-file: also write the PIDs to this file
}
func parsePortArgs(args []string) (int, error) {
opts, err := parseNetPIDsArgs(args)
return opts.port, err
}
func parseNetPIDsArgs(args []string) (netPIDsOptions, error) {
var opts netPIDsOptions
var value string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--port":
if i+1 >= len(args) {
return 0, fmt.Errorf("--port 缺少端口号")
return opts, fmt.Errorf("--port 缺少端口号")
}
i++
value = args[i]
case strings.HasPrefix(arg, "--port="):
value = strings.TrimPrefix(arg, "--port=")
case arg == "--pid-file" || arg == "--pidfile":
if i+1 >= len(args) {
return opts, fmt.Errorf("--pid-file 缺少文件路径,例如 --pid-file /tmp/port.pid")
}
i++
opts.pidFile = args[i]
case strings.HasPrefix(arg, "--pid-file="):
opts.pidFile = strings.TrimPrefix(arg, "--pid-file=")
case strings.HasPrefix(arg, "--pidfile="):
opts.pidFile = strings.TrimPrefix(arg, "--pidfile=")
case strings.HasPrefix(arg, "-") && arg != "-":
return 0, fmt.Errorf("未知选项 %q", arg)
return opts, fmt.Errorf("未知选项 %q", arg)
default:
if value != "" {
return 0, fmt.Errorf("一次只支持一个端口,收到多个参数")
return opts, fmt.Errorf("一次只支持一个端口,收到多个参数")
}
value = arg
}
}
if value == "" {
return 0, fmt.Errorf("必须提供端口,例如 kopsc net pids 8080")
return opts, fmt.Errorf("必须提供端口,例如 kopsc net pids 8080")
}
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > 65535 {
return 0, fmt.Errorf("端口必须是 1-65535 的整数,收到 %q", value)
return opts, fmt.Errorf("端口必须是 1-65535 的整数,收到 %q", value)
}
return port, nil
opts.port = port
if opts.pidFile == "" && strings.HasPrefix(args[len(args)-1], "--pid-file=") {
return opts, errors.New("--pid-file 缺少文件路径,例如 --pid-file /tmp/port.pid")
}
return opts, nil
}
func netPIDsUnix(ctx context.Context, port int, req BuiltinRequest) error {
pids, err := unixPIDs(ctx, port)
if err != nil {
return err
// writePIDFile writes the PIDs as one comma-separated line, so the file works
// with `kill $(cat file)` and is empty when nothing holds the port. The
// location is reported on stderr to keep stdout machine-readable.
func writePIDFile(path string, pids []string) error {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建目录 %q 失败: %w", dir, err)
}
}
content := strings.Join(pids, ",") + "\n"
if len(pids) == 0 {
content = ""
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("写入 %q 失败: %w", path, err)
}
writePIDs(req.Stdout, pids)
return nil
}
@@ -254,6 +298,15 @@ func procPIDs(port int, procRoot string) ([]string, error) {
}
func netPIDsWindows(ctx context.Context, port int, req BuiltinRequest) error {
pids, err := windowsPIDs(ctx, port)
if err != nil {
return err
}
return reportPIDs(netPIDsOptions{port: port}, pids, req)
}
// windowsPIDs parses `netstat -ano` for the PIDs holding port.
func windowsPIDs(ctx context.Context, port int) ([]string, error) {
cmd := exec.CommandContext(ctx, "netstat", "-ano")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
@@ -264,11 +317,9 @@ func netPIDsWindows(ctx context.Context, port int, req BuiltinRequest) error {
if message == "" {
message = err.Error()
}
return fmt.Errorf("netstat -ano 失败: %s", message)
return nil, fmt.Errorf("netstat -ano 失败: %s", message)
}
writePIDs(req.Stdout, parseNetstatPIDs(stdout.String(), port))
return nil
return parseNetstatPIDs(stdout.String(), port), nil
}
// parseNetstatPIDs extracts unique owning PIDs for a local port from
@@ -326,6 +377,25 @@ func normalizePIDs(pids []string) []string {
return out
}
// reportPIDs writes the PIDs to stdout, one per line, and keeps stdout empty
// when nothing holds the port. The "nothing is listening" note goes to stderr,
// so scripts reading stdout see no change while humans are told what happened.
func reportPIDs(opts netPIDsOptions, pids []string, req BuiltinRequest) error {
writePIDs(req.Stdout, pids)
if len(pids) == 0 && req.Stderr != nil {
fmt.Fprintf(req.Stderr, "没有进程占用端口 %d\n", opts.port)
}
if opts.pidFile != "" {
if err := writePIDFile(opts.pidFile, pids); err != nil {
return err
}
if req.Stderr != nil {
fmt.Fprintf(req.Stderr, "PID 已写入 %s\n", opts.pidFile)
}
}
return nil
}
func writePIDs(w interface{ Write([]byte) (int, error) }, pids []string) {
for _, pid := range pids {
fmt.Fprintln(w, pid)