diff --git a/README.md b/README.md index 2a3d019..45ce99e 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,13 @@ kopsc diagnose web # 自动识别 Docker 容器或 K8s Pod,给 kopsc diagnose api-0 -n prod # K8s:本命令里 -n 表示 namespace kopsc diagnose api-0 --kind k8s --context staging kopsc diagnose web --lines 200 --no-events +# 目标名写错时不用自己查:kopsc 会提示「你可能要找的是: web-1, web-2」 kopsc net pids 8080 # 等价 lsof -ti:8080,输出占用端口的 PID -kopsc net ports # 列出监听中的端口 +kopsc net ports # 列出监听中的端口(统一表格,--raw 看原始输出) -kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版) +kopsc disk usage # 当前目录的占用排行(路径可省略) +kopsc disk usage /var --depth 2 # 下钻一层(du -x --max-depth=2 -h | sort -hr 的跨平台版) kopsc disk usage --path /var # 同上,用命名参数指定路径 kopsc disk usage /var --dry-run # 预览(内置命令同样支持) kopsc proc nohup ./server # 后台运行命令,输出写日志(等价 nohup ./server > server.out 2>&1 &) @@ -96,8 +98,10 @@ $ kopsc disk usage /var 总计 2.0G /var ``` -- 路径**必填**:位置参数(`/var`)或 `--path /var`、`--path=/var` 均可,一次一个。 -- 统计每个直接子项:普通文件按自身大小,目录递归累计其下普通文件的大小。 +- 路径**可省略**,缺省为当前目录;也可用位置参数(`/var`)或 `--path /var`、`--path=/var`,一次一个。 +- `--depth N`(`-d N` / `--depth=N`)控制列出到第几层,默认 1(只看直接子项); + `--depth 2` 会下钻一层,未下钻的目录仍按递归大小算。 +- 统计每个条目:普通文件按自身大小,目录递归累计其下普通文件的大小;每个文件只计一次,合计不会因加深度而重复。 - 不跟随符号链接;Unix 上通过设备号实现 `-x`(不跨文件系统),Windows 上不跟随 reparse point,目录遍历本身不会跨越卷。 - 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。 @@ -235,14 +239,20 @@ Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。 ### 端口占用查询(net.pids) -`kopsc net pids <端口>` 等价 `lsof -ti:<端口>`:输出占用该端口的进程 PID,每行一个、升序、去重;没有进程占用时无输出并正常退出。 +`kopsc net pids <端口>` 等价 `lsof -ti:<端口>`:输出占用该端口的进程 PID,每行一个、升序、去重。 ```bash kopsc net pids 8080 kopsc net pids --port 8080 +kopsc net pids 8080 --pid-file /tmp/web.pid # 同时写出逗号分隔的 PID kopsc net pids 8080 --dry-run ``` +- 没有进程占用时 **stdout 仍为空**、退出码仍为 0,但会在 stderr 说明「没有进程占用端口 N」, + 避免命令行下对着空输出发懵;脚本读 stdout 不受影响。 +- `--pid-file <路径>`:把 PID 以逗号分隔写进文件(如 `123,456`),无占用时写空文件, + 便于 `kill $(cat web.pid)`;写完会在 stderr 提示位置。 + 底层按平台自动选择,但对外的命令与输出保持一致: | 平台 | 依次尝试的底层来源 | @@ -255,6 +265,23 @@ kopsc net pids 8080 --dry-run - `lsof` 无匹配时退出码为 1 且无输出,kopsc 视为“没有进程占用”,返回 0。 - 只读命令,不需要 `--yes`。 +### 监听端口(net.ports) + +`kopsc net ports` 列出本机正在监听的端口,**默认输出跨平台一致的表格**(按端口升序): + +```text +$ kopsc net ports +端口 协议 地址 PID 进程 +22 TCP 0.0.0.0 901 sshd +6379 TCP 127.0.0.1 1500 redis-server +53 UDP 0.0.0.0 - - +``` + +- 三个平台底层分别使用 `ss -tulnp`(Linux)、`lsof -iTCP -sTCP:LISTEN -nP`(macOS)、 + `netstat -ano`(Windows),kopsc 解析后统一成同一张表;取不到属主时显示 `-`。 +- `--raw` 保留平台原生命令的原始输出,供依赖原生格式的脚本使用。 +- 没有监听端口时 stdout 为空,stderr 提示「没有正在监听的端口」。 + ### 其它 | 命令 | 说明 | 平台变体 | @@ -262,7 +289,7 @@ kopsc net pids 8080 --dry-run | `diagnose` | 容器/Pod 状态根因诊断(自动识别 Docker 与 Kubernetes) | Linux/macOS/Windows 均使用 kopsc 内置实现 | | `net.pids` | 查找占用指定端口的进程 PID(等价 `lsof -ti:<端口>`) | Linux `lsof` → `ss` → `/proc`;macOS `lsof`;Windows `netstat -ano`。三平台命令与输出一致(PID 升序、每行一个) | | `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 | -| `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | +| `net.ports` | 列出监听中的端口(默认表格,`--raw` 保留原生输出) | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano`,均由 kopsc 解析为统一表格 | | `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | | `proc.nohup` | 后台运行命令,输出写入日志(等价 `nohup <命令> > output.log 2>&1 &`) | Linux/macOS 内置实现(新会话 + 日志追加)、Windows 内置实现(`DETACHED_PROCESS` + 日志追加) | diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 2d79595..37e8960 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -73,14 +73,17 @@ func TestLeafHelp(t *testing.T) { } func TestLeafPassesThroughArgs(t *testing.T) { + // net.ports is a builtin now, so use a docker command whose argv is fixed + // and whose passthrough the dry-run output shows. app, stdout, _ := testApp(centos("docker")) - code := app.Execute([]string{"net", "ports", "--dry-run", "--", "-l"}) + code := app.Execute([]string{"docker", "ps", "--dry-run", "--", "--filter", "status=running"}) if code != 0 { t.Fatalf("exit code = %d", code) } - if !strings.Contains(stdout.String(), "ss -tulnp -l") { - t.Errorf("passthrough args missing:\n%s", stdout.String()) + out := stdout.String() + if !strings.Contains(out, "docker ps") || !strings.Contains(out, "--filter status=running") { + t.Errorf("passthrough args missing:\n%s", out) } } @@ -384,14 +387,26 @@ func TestDiskUsageAcceptsPathFlagThroughCLI(t *testing.T) { } } -func TestDiskUsageRequiresPathThroughCLI(t *testing.T) { - app, _, stderr := testApp(centos("docker")) - - if code := app.Execute([]string{"disk", "usage"}); code != 1 { - t.Fatalf("exit code = %d, want 1", code) +func TestDiskUsageDefaultsToCurrentDirectoryThroughCLI(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "payload.bin"), bytes.Repeat([]byte("x"), 2048), 0o644); err != nil { + t.Fatal(err) } - if !strings.Contains(stderr.String(), "必须提供路径") { - t.Errorf("stderr = %s", stderr.String()) + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(old) }() + + app, stdout, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"disk", "usage"}); code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "payload.bin") { + t.Errorf("omitting the path should analyse the current directory:\n%s", stdout.String()) } } diff --git a/internal/diagnose/service.go b/internal/diagnose/service.go index 333d7f2..52810c0 100644 --- a/internal/diagnose/service.go +++ b/internal/diagnose/service.go @@ -121,10 +121,125 @@ func (s *Service) resolve(ctx context.Context, target string, opts Options) (sou strings.TrimSpace(string(res.Stdout)) != "" { return source{kind: "k8s", namespace: ns, context: opts.Context}, nil } - return source{}, fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns) + base := fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns) + return source{}, s.withCandidates(ctx, base, target, opts) } - return source{}, fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr) + base := fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr) + return source{}, s.withCandidates(ctx, base, target, opts) +} + +// withCandidates appends similar names to a "target not found" error, so the +// user does not have to run docker ps / kubectl get pods to find out that the +// container is called web-1. +// +// When nothing is similar, the few objects that do exist are listed instead — +// knowing that the only containers are "redis" and "db" is usually enough to +// spot the typo. With no objects at all the error keeps its original text. +func (s *Service) withCandidates(ctx context.Context, base error, target string, opts Options) error { + all := s.candidateNames(ctx, target, opts) + + var hits, existing []string + for _, name := range all { + if name.matched { + hits = append(hits, name.name) + } + existing = append(existing, name.name) + } + if len(hits) > 0 { + return fmt.Errorf("%w\n你可能要找的是: %s\n(设置 --kind docker 或 --kind k8s 可缩小查找范围)", + base, strings.Join(hits, ", ")) + } + if len(existing) == 0 { + return base + } + return fmt.Errorf("%w\n当前存在的对象: %s\n(可用 kopsc docker psall 或 kubectl get pods 查看详情)", + base, strings.Join(existing, ", ")) +} + +// nameCandidate is one existing name and whether it resembles the target. +type nameCandidate struct { + name string + matched bool +} + +// candidateNames collects the names of existing Docker containers and Pods, +// marking those that resemble the target (prefix or substring, ignoring case) +// and ordering the similar ones first. +func (s *Service) candidateNames(ctx context.Context, target string, opts Options) []nameCandidate { + if opts.Kind == "k8s" { + return rankNames(s.podNames(ctx, opts), target) + } + + var names []string + if program, err := s.dockerProgram(); err == nil { + if res, err := s.run(ctx, program, "ps", "-a", "--format", "{{.Names}}"); err == nil { + names = append(names, strings.Fields(string(res.Stdout))...) + } + } + if opts.Kind != "docker" { + names = append(names, s.podNames(ctx, opts)...) + } + return rankNames(names, target) +} + +// podNames lists the Pods in the effective namespace. +func (s *Service) podNames(ctx context.Context, opts Options) []string { + if _, err := s.runner.LookPath("kubectl"); err != nil { + return nil + } + ns, _ := s.kubeNamespace(ctx, opts.Context) + res, err := s.run(ctx, "kubectl", kubectlArgs(opts.Context, "get", "pods", "-n", ns, "-o", "name")...) + if err != nil { + return nil + } + var names []string + for _, field := range strings.Fields(string(res.Stdout)) { + names = append(names, strings.TrimPrefix(field, "pod/")) + } + return names +} + +// rankNames marks the names that resemble target — prefix hits before +// substring hits, comparison ignoring case — and returns at most maxNames of +// them, followed by the remaining names so callers can also show what exists +// when nothing is similar. +func rankNames(names []string, target string) []nameCandidate { + const maxNames = 5 + lower := strings.ToLower(target) + + var prefixHits, containsHits, others []nameCandidate + for _, name := range names { + n := strings.ToLower(name) + switch { + case strings.HasPrefix(n, lower), strings.HasPrefix(lower, n): + prefixHits = append(prefixHits, nameCandidate{name: name, matched: true}) + case strings.Contains(n, lower): + containsHits = append(containsHits, nameCandidate{name: name, matched: true}) + default: + others = append(others, nameCandidate{name: name}) + } + } + + hits := append(prefixHits, containsHits...) + if len(hits) > maxNames { + hits = hits[:maxNames] + } + if len(others) > maxNames { + others = others[:maxNames] + } + return append(hits, others...) +} + +// matchNames is rankNames reduced to the similar names only. +func matchNames(names []string, target string) []string { + var hits []string + for _, c := range rankNames(names, target) { + if c.matched { + hits = append(hits, c.name) + } + } + return hits } func (s *Service) dockerProgram() (string, error) { diff --git a/internal/diagnose/service_test.go b/internal/diagnose/service_test.go index 63db7ed..87f0347 100644 --- a/internal/diagnose/service_test.go +++ b/internal/diagnose/service_test.go @@ -138,3 +138,79 @@ func TestResolveNamespaceSkipsDocker(t *testing.T) { t.Errorf("namespace missing from output:\n%s", out.String()) } } + +func TestResolveNotFoundSuggestsSimilarContainers(t *testing.T) { + r := &fakeRunner{ + available: map[string]bool{"docker": true, "kubectl": true}, + respond: func(name string, args []string) (CommandResult, error) { + joined := strings.Join(args, " ") + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte("[]")}, nil + case strings.HasPrefix(joined, "ps -a"): + return CommandResult{Stdout: []byte("web-1\nweb-2\ndb\nnginx-web\n")}, nil + case strings.HasPrefix(joined, "config view"): + return CommandResult{}, nil + case strings.HasPrefix(joined, "get pod"): + return CommandResult{Stderr: []byte("not found")}, fmt.Errorf("exit status 1") + } + return CommandResult{}, nil + }, + } + + s, _, _ := newTestService(r) + err := s.Run(context.Background(), []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "未找到目标") { + t.Fatalf("err = %v, want not-found error", err) + } + msg := err.Error() + for _, want := range []string{"你可能要找的是", "web-1", "web-2"} { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q:\n%s", want, msg) + } + } +} + +func TestResolveNotFoundWithoutCandidatesKeepsOriginalText(t *testing.T) { + r := &fakeRunner{ + available: map[string]bool{"docker": true}, + respond: func(name string, args []string) (CommandResult, error) { + switch { + case len(args) > 0 && args[0] == "inspect": + return CommandResult{Stdout: []byte("[]")}, nil + case strings.HasPrefix(strings.Join(args, " "), "ps -a"): + return CommandResult{Stdout: []byte("alpha\nbeta\n")}, nil + } + return CommandResult{}, nil + }, + } + + s, _, _ := newTestService(r) + err := s.Run(context.Background(), []string{"ghost"}) + if err == nil || !strings.Contains(err.Error(), "未找到目标") { + t.Fatalf("err = %v, want not-found error", err) + } + if strings.Contains(err.Error(), "你可能要找的是") { + t.Errorf("unexpected candidate hint:\n%s", err.Error()) + } + // Nothing is similar, so the existing objects are listed instead. + for _, want := range []string{"当前存在的对象", "alpha", "beta"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q:\n%s", want, err.Error()) + } + } +} + +func TestMatchNamesRanking(t *testing.T) { + names := []string{"nginx-web", "web-1", "WEB-2", "api", "myweb"} + got := matchNames(names, "web") + want := []string{"web-1", "WEB-2", "nginx-web", "myweb"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("matchNames() = %v, want %v (prefix hits first, case-insensitive)", got, want) + } + + many := []string{"web-1", "web-2", "web-3", "web-4", "web-5", "web-6", "web-7"} + if got := matchNames(many, "web"); len(got) != 5 { + t.Fatalf("matchNames returned %d names, want the 5 closest", len(got)) + } +} diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index 4ec50be..0be44b0 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -43,6 +43,7 @@ var builtins = map[string]Builtin{ "docker.stats": runDockerStats, "docker.stop": runDockerStop, "net.pids": runNetPIDs, + "net.ports": runNetPorts, "proc.nohup": runNohup, } diff --git a/internal/executor/diskusage.go b/internal/executor/diskusage.go index 58d5e2e..902621d 100644 --- a/internal/executor/diskusage.go +++ b/internal/executor/diskusage.go @@ -12,18 +12,18 @@ import ( "strings" ) -// runDiskUsage implements `du -x --max-depth=1 -h | sort -hr`. +// runDiskUsage implements `du -x --max-depth=N -h | sort -hr`. // -// It reports every direct child of the given directory: regular files by their -// size, directories by the recursive size of their regular files. Symbolic -// links are not followed and, where the platform exposes device IDs, nested -// filesystems are not crossed (-x). +// It reports the entries down to N levels below the given directory: regular +// files by their size, directories by the recursive size of their regular +// files. Symbolic links are not followed and, where the platform exposes +// device IDs, nested filesystems are not crossed (-x). func runDiskUsage(ctx context.Context, req BuiltinRequest) error { - paths, err := parseDiskUsageArgs(req.Args) + opts, err := parseDiskUsageOptions(req.Args) if err != nil { return err } - root := paths[0] + root, depth := opts.path, opts.depth rootInfo, err := os.Stat(root) if err != nil { @@ -33,50 +33,19 @@ func runDiskUsage(ctx context.Context, req BuiltinRequest) error { return fmt.Errorf("%q 不是目录", root) } - entries, err := os.ReadDir(root) - if err != nil { - return fmt.Errorf("读取目录 %q 失败: %w", root, err) - } - rootDev, checkDev := deviceOf(rootInfo) - type entrySize struct { - path string - size int64 + // Each listed entry is a direct child, or with --depth a deeper one. + // Every file's size is counted exactly once, so the total stays the real + // directory size rather than summing overlapping subtrees. + items := make([]entrySize, 0, 16) + if err := collectEntries(ctx, root, root, 1, depth, rootDev, checkDev, &items); err != nil { + return err } - items := make([]entrySize, 0, len(entries)) + var total int64 - - for _, entry := range entries { - if err := ctx.Err(); err != nil { - return err - } - path := filepath.Join(root, entry.Name()) - info, err := entry.Info() - if err != nil { - // Unreadable entries are skipped rather than failing the whole run. - continue - } - - var size int64 - switch { - case info.IsDir(): - if checkDev { - if dev, ok := deviceOf(info); ok && dev != rootDev { - // -x: a different filesystem contributes nothing. - break - } - } - size, err = dirSize(ctx, path, rootDev, checkDev) - if err != nil { - return err - } - case info.Mode().IsRegular(): - size = info.Size() - } - - items = append(items, entrySize{path: path, size: size}) - total += size + for _, item := range items { + total += item.size } sort.SliceStable(items, func(i, j int) bool { @@ -87,17 +56,91 @@ func runDiskUsage(ctx context.Context, req BuiltinRequest) error { }) w := req.Stdout + shownRoot := root for _, item := range items { - fmt.Fprintf(w, "%-8s %s\n", humanSize(item.size), item.path) + fmt.Fprintf(w, "%-8s %s\n", humanSize(item.size), strings.TrimPrefix(item.path, "./")) } - fmt.Fprintf(w, "%-8s 总计 %s\n", humanSize(total), root) + fmt.Fprintf(w, "%-8s 总计 %s\n", humanSize(total), shownRoot) return nil } -// parseDiskUsageArgs accepts either a positional path or --path/--path=value. -// Exactly one path is required. -func parseDiskUsageArgs(args []string) ([]string, error) { +// entrySize is one row of the report. +type entrySize struct { + path string + size int64 +} + +// collectEntries walks dir down to maxDepth levels. Files are added with their +// own size; a directory at the deepest level is added with its recursive size +// (the "how big is this subtree" question), and shallower directories are +// descended into. Symbolic links are skipped and other filesystems are not +// crossed (the -x behaviour). +func collectEntries(ctx context.Context, dir, root string, level, maxDepth int, rootDev uint64, checkDev bool, out *[]entrySize) error { + entries, err := os.ReadDir(dir) + if err != nil { + if dir == root { + return fmt.Errorf("读取目录 %q 失败: %w", dir, err) + } + return nil // an unreadable subdirectory must not abort the report + } + + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return err + } + path := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue // unreadable entries are skipped + } + + switch { + case info.IsDir(): + if checkDev { + if dev, ok := deviceOf(info); ok && dev != rootDev { + // -x: a different filesystem contributes nothing. + continue + } + } + if level >= maxDepth { + size, err := dirSize(ctx, path, rootDev, checkDev) + if err != nil { + return err + } + *out = append(*out, entrySize{path: path, size: size}) + continue + } + if err := collectEntries(ctx, path, root, level+1, maxDepth, rootDev, checkDev, out); err != nil { + return err + } + case info.Mode().IsRegular(): + *out = append(*out, entrySize{path: path, size: info.Size()}) + } + } + return nil +} + +// diskUsageOptions are the parsed arguments of `kopsc disk usage`. +type diskUsageOptions struct { + path string + depth int +} + +// parseDiskUsageOptions accepts either a positional path or +// --path/--path=value, plus --depth/-d. At most one path may be given; +// without one the current directory is used. +func parseDiskUsageOptions(args []string) (diskUsageOptions, error) { + paths, depth, err := parseDiskUsageArgs(args) + if err != nil { + return diskUsageOptions{}, err + } + return diskUsageOptions{path: paths[0], depth: depth}, nil +} + +// parseDiskUsageArgs returns the path and the depth (1 by default). +func parseDiskUsageArgs(args []string) ([]string, int, error) { var paths []string + depth := 1 positionalOnly := false for i := 0; i < len(args); i++ { @@ -107,32 +150,51 @@ func parseDiskUsageArgs(args []string) ([]string, error) { paths = append(paths, arg) case arg == "--": positionalOnly = true + case arg == "--depth" || arg == "-d": + if i+1 >= len(args) { + return nil, 0, fmt.Errorf("%s 缺少层数,例如 --depth 2", arg) + } + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + return nil, 0, fmt.Errorf("--depth 需要大于 0 的整数,收到 %q", args[i]) + } + depth = n + case strings.HasPrefix(arg, "--depth="): + value := strings.TrimPrefix(arg, "--depth=") + n, err := strconv.Atoi(value) + if err != nil || n < 1 { + return nil, 0, fmt.Errorf("--depth 需要大于 0 的整数,收到 %q", value) + } + depth = n case arg == "--path": if i+1 >= len(args) { - return nil, errors.New("--path 缺少参数,例如 --path /var") + return nil, 0, errors.New("--path 缺少参数,例如 --path /var") } paths = append(paths, args[i+1]) i++ case strings.HasPrefix(arg, "--path="): value := strings.TrimPrefix(arg, "--path=") if value == "" { - return nil, errors.New("--path 缺少参数,例如 --path /var") + return nil, 0, errors.New("--path 缺少参数,例如 --path /var") } paths = append(paths, value) case strings.HasPrefix(arg, "-") && arg != "-": - return nil, fmt.Errorf("未知选项 %q:深度(-d 1)、人类可读(-h)与不跨文件系统(-x)已固定", arg) + return nil, 0, fmt.Errorf("未知选项 %q:人类可读(-h)与不跨文件系统(-x)已固定,深度请用 --depth N", arg) default: paths = append(paths, arg) } } + // The most common use is "what is taking space right here", so the path + // defaults to the current directory. if len(paths) == 0 { - return nil, errors.New("必须提供路径,例如 kopsc disk usage /var 或 kopsc disk usage --path /var") + paths = append(paths, ".") } if len(paths) > 1 { - return nil, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths)) + return nil, 0, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths)) } - return paths, nil + return paths, depth, nil } // dirSize sums the sizes of regular files under dir without following symbolic diff --git a/internal/executor/diskusage_test.go b/internal/executor/diskusage_test.go index 41bf23d..a661f35 100644 --- a/internal/executor/diskusage_test.go +++ b/internal/executor/diskusage_test.go @@ -102,10 +102,25 @@ func TestDiskUsageAcceptsPathFlag(t *testing.T) { } } -func TestDiskUsageRequiresPath(t *testing.T) { - _, err := runDisk(t) - if err == nil || !strings.Contains(err.Error(), "必须提供路径") { - t.Fatalf("err = %v, want missing-path error", err) +func TestDiskUsageDefaultsToCurrentDirectory(t *testing.T) { + root := diskFixture(t) + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(old) }() + + out, err := runDisk(t) + if err != nil { + t.Fatalf("omitting the path should analyse the current directory: %v", err) + } + for _, want := range []string{"big", "direct.txt", "总计 ."} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } } } @@ -196,3 +211,102 @@ func TestPlanBuiltinDryRunDoesNotExecute(t *testing.T) { t.Errorf("dry-run output lacks builtin note:\n%s", out.String()) } } + +func TestParseDiskUsageDepth(t *testing.T) { + tests := []struct { + args []string + wantPath string + wantDepth int + wantErr string + }{ + {[]string{"/var"}, "/var", 1, ""}, + {[]string{"--depth", "2", "/var"}, "/var", 2, ""}, + {[]string{"-d", "3", "/var"}, "/var", 3, ""}, + {[]string{"--depth=2", "/var"}, "/var", 2, ""}, + {[]string{"--depth", "2"}, ".", 2, ""}, + {[]string{"--depth"}, "", 0, "--depth 缺少层数"}, + {[]string{"--depth", "0", "/var"}, "", 0, "--depth 需要大于 0"}, + {[]string{"--depth=x", "/var"}, "", 0, "--depth 需要大于 0"}, + } + for _, tc := range tests { + opts, err := parseDiskUsageOptions(tc.args) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.wantErr) + } + continue + } + if err != nil { + t.Errorf("args %v: %v", tc.args, err) + continue + } + if opts.path != tc.wantPath || opts.depth != tc.wantDepth { + t.Errorf("args %v: got %+v, want path=%q depth=%d", tc.args, opts, tc.wantPath, tc.wantDepth) + } + } +} + +func TestDiskUsageDepthTwoListsNestedFiles(t *testing.T) { + root := t.TempDir() + writeSizedFile(t, filepath.Join(root, "a", "deep", "x.bin"), 4096) + writeSizedFile(t, filepath.Join(root, "a", "y.bin"), 2048) + writeSizedFile(t, filepath.Join(root, "top.txt"), 1024) + + // depth 1: only "a" (summed) and top.txt. + out, err := runDisk(t, root) + if err != nil { + t.Fatalf("depth 1: %v", err) + } + if !strings.Contains(out, filepath.Join("a")) || strings.Contains(out, "x.bin") { + t.Fatalf("depth 1 should stop at direct children:\n%s", out) + } + + // depth 2: a's children show up individually. + out, err = runDisk(t, root, "--depth", "2") + if err != nil { + t.Fatalf("depth 2: %v", err) + } + for _, want := range []string{"y.bin", "deep"} { + if !strings.Contains(out, want) { + t.Errorf("depth 2 output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "x.bin") { + t.Errorf("depth 2 must not descend into deep/:\n%s", out) + } + + // depth 3 reaches the nested file. + out, err = runDisk(t, root, "--depth", "3") + if err != nil { + t.Fatalf("depth 3: %v", err) + } + if !strings.Contains(out, "x.bin") { + t.Errorf("depth 3 output missing x.bin:\n%s", out) + } +} + +func TestDiskUsageDepthKeepsTotalAccurate(t *testing.T) { + root := t.TempDir() + writeSizedFile(t, filepath.Join(root, "a", "deep", "x.bin"), 4096) + writeSizedFile(t, filepath.Join(root, "a", "y.bin"), 2048) + writeSizedFile(t, filepath.Join(root, "top.txt"), 1024) + + // Every file is counted once no matter the depth, so the total is the same + // 7168 bytes (7.0K) as depth 1 — deeper listing must not double count. + for _, depth := range []string{"1", "2", "3"} { + out, err := runDisk(t, root, "--depth", depth) + if err != nil { + t.Fatalf("depth %s: %v", depth, err) + } + // The total line is "7.0K 总计 ". + var total string + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "总计") { + total = strings.Fields(line)[0] + } + } + if total != "7.0K" { + t.Errorf("depth %s total = %q, want 7.0K", depth, total) + } + } +} diff --git a/internal/executor/dockertargets.go b/internal/executor/dockertargets.go index d09df5a..37c0b13 100644 --- a/internal/executor/dockertargets.go +++ b/internal/executor/dockertargets.go @@ -74,6 +74,7 @@ type imageRef struct { 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 + Digest string // content digest, e.g. "sha256:abc..."; may be empty Repository string Tag string } @@ -92,10 +93,12 @@ func listImages(ctx context.Context) ([]imageRef, error) { var refs []imageRef for _, line := range splitLines(out) { - if strings.HasPrefix(line, "REPOSITORY") { + fields := strings.Split(line, "\t") + // With kopsc's own --format the header starts with "IMAGE ID", while + // docker's default header starts with "REPOSITORY"; skip either. + if isImageHeader(line, fields) { 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]) @@ -111,16 +114,34 @@ func listImages(ctx context.Context) ([]imageRef, error) { } ref.Repository = repo ref.Tag = tag - ref.Name = formatImageName(repo, tag, digest) + ref.Digest = digest + ref.Name = formatImageName(repo, tag) } 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 { +// isImageHeader reports whether a line is docker's column header rather than +// an image row. Headers never carry a real 12-character hex ID. +func isImageHeader(line string, fields []string) bool { + if strings.HasPrefix(line, "REPOSITORY") || strings.HasPrefix(line, "IMAGE ID") { + return true + } + if len(fields) == 0 { + return false + } + return !looksLikeImageID(fields[0]) +} + +// formatImageName renders repository[:tag]. "" is how docker groups +// untagged images. +// +// The digest is deliberately not part of the name: `docker save` and friends +// are addressed by repository:tag (alpine:3.20), and appending "@sha256:..." — +// which `docker images --format {{.Digest}}` prints for most pulled images — +// made every such image impossible to look up by the name users type. +func formatImageName(repo, tag string) string { if repo == "" { return "" } @@ -128,9 +149,6 @@ func formatImageName(repo, tag, digest string) string { if tag != "" && tag != "" { name += ":" + tag } - if digest != "" && digest != "" { - name += "@" + digest - } return name } diff --git a/internal/executor/dockertargets_test.go b/internal/executor/dockertargets_test.go index 967657d..a127bc7 100644 --- a/internal/executor/dockertargets_test.go +++ b/internal/executor/dockertargets_test.go @@ -771,3 +771,89 @@ func registryVariantBuiltinFor(name string) registry.Variant { func resolveResultWithVariant(v registry.Variant) resolve.Result { return resolve.Result{Command: ®istry.Command{ID: "docker.test"}, Variant: v} } + +// TestListImagesParsesRealDockerOutput pins the parser against output captured +// from a real Docker Desktop: tab-separated fields including the digest column. +func TestListImagesParsesRealDockerOutput(t *testing.T) { + payload := "IMAGE ID\tREPOSITORY\tTAG\tDIGEST\tCREATED AT\tSIZE\n" + + "b175f8a4c2a5\truntime-demo\tlatest\tsha256:b175f8\t2026-10-23 11:48:04 +0800 CST\t23.3MB\n" + + "d6bfb6b28318\tkenaito-conf-web\tlatest\tsha256:d6bfb6\t2026-10-21 15:37:51 +0800 CST\t75.4MB\n" + + "e0b0e5c0a111\t\t\t\t2026-10-20 10:00:00 +0800 CST\t1.12GB\n" + logPath := fakeRuntime(t, payload) + + refs, err := listImages(context.Background()) + if err != nil { + t.Fatalf("listImages: %v", err) + } + if len(refs) != 3 { + t.Fatalf("parsed %d rows, want 3: %+v", len(refs), refs) + } + + got := map[string]imageRef{} + for _, r := range refs { + got[r.ID] = r + } + // The digest column is kept apart: the name must stay repository:tag, or + // `docker save alpine:3.20` could never match what users type. + if r := got["b175f8a4c2a5"]; r.Name != "runtime-demo:latest" { + t.Errorf("named image = %q, want runtime-demo:latest", r.Name) + } + if r := got["b175f8a4c2a5"]; r.Digest != "sha256:b175f8" { + t.Errorf("digest not captured: %+v", r) + } + if r := got["b175f8a4c2a5"]; r.Size != "23.3MB" || r.CreatedAt == "" { + t.Errorf("metadata not parsed: %+v", r) + } + // "" repository rows are untagged and carry no name. + if r := got["e0b0e5c0a111"]; r.Name != "" { + t.Errorf("untagged image name = %q, want empty", r.Name) + } + + // Asking for an image that only exists by another name reports it missing. + if _, err := resolveImageRef(context.Background(), "alpine:3.20"); err == nil || + !strings.Contains(err.Error(), "本地没有镜像") { + t.Errorf("err = %v, want not-found for an unlisted image", err) + } + _ = logPath +} + +// TestResolveImageRefByTagWithDigest is the regression for "本地有 alpine:3.20 +// 却报本地没有镜像": docker prints a digest for most images, and it must not +// leak into the resolved name. +func TestResolveImageRefByTagWithDigest(t *testing.T) { + payload := "IMAGE ID\tREPOSITORY\tTAG\tDIGEST\tCREATED AT\tSIZE\n" + + "7dcddc01f13b\tmysql\t8.0\t\t\n" + + "d9e853e87e55\talpine\t3.20\tsha256:d9e853e87e55\t2026-04-17 07:53:26 +0800 CST\t12.2MB\n" + fakeRuntime(t, payload) + + ref, err := resolveImageRef(context.Background(), "alpine:3.20") + if err != nil { + t.Fatalf("alpine:3.20 exists locally but was not found: %v", err) + } + if ref.Name != "alpine:3.20" { + t.Fatalf("resolved name = %q, want alpine:3.20", ref.Name) + } + + // The image is also reachable by ID once its name is clean. + byID, err := resolveImageRef(context.Background(), "d9e853e87e55") + if err != nil { + t.Fatalf("resolve by ID: %v", err) + } + if byID.Name != "alpine:3.20" { + t.Errorf("ID resolved to %q, want alpine:3.20", byID.Name) + } +} + +func TestFormatImageNameIgnoresDigest(t *testing.T) { + cases := []struct{ repo, tag, want string }{ + {"alpine", "3.20", "alpine:3.20"}, + {"alpine", "latest", "alpine:latest"}, + {"registry:5000/team/app", "1.0", "registry:5000/team/app:1.0"}, + {"", "", ""}, + } + for _, tc := range cases { + if got := formatImageName(tc.repo, tc.tag); got != tc.want { + t.Errorf("formatImageName(%q, %q) = %q, want %q", tc.repo, tc.tag, got, tc.want) + } + } +} diff --git a/internal/executor/netpids.go b/internal/executor/netpids.go index d40f879..bd5efd8 100644 --- a/internal/executor/netpids.go +++ b/internal/executor/netpids.go @@ -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) diff --git a/internal/executor/netpids_test.go b/internal/executor/netpids_test.go index 7a3640c..7f4c9da 100644 --- a/internal/executor/netpids_test.go +++ b/internal/executor/netpids_test.go @@ -102,12 +102,29 @@ func TestNetPIDsUnixNoMatchIsNotAnError(t *testing.T) { } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - var out bytes.Buffer - if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out}); err != nil { + var out, errOut bytes.Buffer + if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out, Stderr: &errOut}); err != nil { t.Fatalf("no-match should not fail: %v", err) } if out.Len() != 0 { - t.Fatalf("expected no output, got %q", out.String()) + t.Fatalf("expected empty stdout, got %q", out.String()) + } + // The note goes to stderr so scripts parsing stdout are unaffected. + if !strings.Contains(errOut.String(), "没有进程占用端口 8080") { + t.Fatalf("stderr = %q, want a no-match note", errOut.String()) + } +} + +func TestReportPIDsSilentWhenSomethingHoldsThePort(t *testing.T) { + var out, errOut bytes.Buffer + if err := reportPIDs(netPIDsOptions{port: 8080}, []string{"123", "456"}, BuiltinRequest{Stdout: &out, Stderr: &errOut}); err != nil { + t.Fatalf("reportPIDs: %v", err) + } + if out.String() != "123\n456\n" { + t.Errorf("stdout = %q", out.String()) + } + if errOut.Len() != 0 { + t.Errorf("stderr should stay empty, got %q", errOut.String()) } } @@ -186,3 +203,78 @@ func TestProcPIDs(t *testing.T) { t.Fatalf("pids = %v, want [4242]", got) } } + +func TestWritePIDFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "port.pid") + + if err := writePIDFile(path, []string{"123", "456"}); err != nil { + t.Fatalf("writePIDFile: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Comma-separated on one line so `kill $(cat file)` works. + if string(data) != "123,456\n" { + t.Fatalf("file = %q, want \"123,456\\n\"", data) + } + + // No holder: the file exists but is empty. + if err := writePIDFile(path, nil); err != nil { + t.Fatalf("writePIDFile(nil): %v", err) + } + if data, _ := os.ReadFile(path); len(data) != 0 { + t.Fatalf("file = %q, want empty", data) + } +} + +func TestParseNetPIDsArgsPidFile(t *testing.T) { + opts, err := parseNetPIDsArgs([]string{"8080", "--pid-file", "/tmp/p.pid"}) + if err != nil || opts.port != 8080 || opts.pidFile != "/tmp/p.pid" { + t.Fatalf("opts = %+v, err = %v", opts, err) + } + opts, err = parseNetPIDsArgs([]string{"--pid-file=/tmp/q.pid", "8080"}) + if err != nil || opts.pidFile != "/tmp/q.pid" { + t.Fatalf("opts = %+v, err = %v", opts, err) + } + if _, err := parseNetPIDsArgs([]string{"8080", "--pid-file"}); err == nil || + !strings.Contains(err.Error(), "--pid-file 缺少文件路径") { + t.Fatalf("err = %v, want a missing-path error", err) + } +} + +func TestNetPIDsWritesPIDFileThroughCLIPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("lsof fixture is not executable on Windows") + } + dir := t.TempDir() + script := filepath.Join(dir, "lsof") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '4242\\n'\\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + pidFile := filepath.Join(t.TempDir(), "web.pid") + var out, errOut bytes.Buffer + if err := runNetPIDs(context.Background(), BuiltinRequest{ + Args: []string{"8080", "--pid-file", pidFile}, + Stdout: &out, + Stderr: &errOut, + }); err != nil { + t.Fatalf("runNetPIDs: %v", err) + } + if strings.TrimSpace(out.String()) != "4242" { + t.Errorf("stdout = %q, want the PID", out.String()) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("pid file not written: %v", err) + } + if strings.TrimSpace(string(data)) != "4242" { + t.Errorf("pid file = %q", data) + } + if !strings.Contains(errOut.String(), "PID 已写入") { + t.Errorf("stderr = %q, want the write note", errOut.String()) + } +} diff --git a/internal/executor/netports.go b/internal/executor/netports.go new file mode 100644 index 0000000..32434d0 --- /dev/null +++ b/internal/executor/netports.go @@ -0,0 +1,286 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "text/tabwriter" +) + +// listeningPort is one listening socket with its owner, normalised across +// platforms so the table looks the same everywhere. +type listeningPort struct { + Proto string // TCP or UDP + Address string // local address as reported, e.g. "0.0.0.0", "*", "[::]" + Port int + PID string // empty when the platform does not expose the owner + Process string // empty when unknown +} + +// listenCommand describes how to list listening sockets on this platform. +type listenCommand struct { + program string + args []string + parse func([]byte) []listeningPort +} + +// runNetPorts implements `kopsc net ports [--raw]`. +// +// By default the listeners are printed as one table with the same columns on +// every platform; --raw keeps the platform command's own output for scripts +// that parse it. +func runNetPorts(ctx context.Context, req BuiltinRequest) error { + raw := false + for _, arg := range req.Args { + switch arg { + case "--raw": + raw = true + case "--": + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("未知选项 %q(支持 --raw)", arg) + } + } + } + + if raw { + spec := listenCommandFor(runtime.GOOS) + cmd := exec.CommandContext(ctx, spec.program, spec.args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return nil // lsof: nothing is listening + } + return fmt.Errorf("%s 执行失败: %w", spec.program, err) + } + return nil + } + + ports, err := listListeningPorts(ctx, runtime.GOOS) + if err != nil { + return err + } + renderListeningPorts(ports, req) + return nil +} + +// renderListeningPorts writes the table, sorted by port. +func renderListeningPorts(ports []listeningPort, req BuiltinRequest) { + if len(ports) == 0 { + if req.Stderr != nil { + fmt.Fprintln(req.Stderr, "没有正在监听的端口") + } + return + } + + tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "端口\t协议\t地址\tPID\t进程") + for _, p := range ports { + pid, process := p.PID, p.Process + if pid == "" { + pid, process = "-", "-" + } + if process == "" { + process = "-" + } + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", p.Port, p.Proto, p.Address, pid, process) + } + _ = tw.Flush() +} + +// listenCommandFor picks the platform's command. +func listenCommandFor(goos string) listenCommand { + switch goos { + case "linux": + return listenCommand{program: "ss", args: []string{"-tulnp"}, parse: parseSSListeners} + case "windows": + return listenCommand{program: "netstat", args: []string{"-ano"}, parse: parseNetstatListeners} + default: + // macOS: lsof is part of the base system and prints the owner name. + return listenCommand{program: "lsof", args: []string{"-iTCP", "-sTCP:LISTEN", "-nP"}, parse: parseLsofListeners} + } +} + +// listListeningPorts runs the platform command and parses its output. +func listListeningPorts(ctx context.Context, goos string) ([]listeningPort, error) { + spec := listenCommandFor(goos) + cmd := exec.CommandContext(ctx, spec.program, spec.args...) + out, err := cmd.Output() + if err != nil { + // lsof exits 1 with no output when nothing is listening. + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 && len(out) == 0 { + return nil, nil + } + return nil, fmt.Errorf("%s 执行失败: %w", spec.program, err) + } + return spec.parse(out), nil +} + +// ssProcessPattern extracts the process name from `ss -p` output such as +// users:(("nginx",pid=1234,fd=6)). +var ssProcessPattern = regexp.MustCompile(`\("([^"]+)"`) + +// parseSSListeners parses `ss -tulnp`: +// +// Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +// udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=812,fd=6)) +// tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=901,fd=3)) +func parseSSListeners(out []byte) []listeningPort { + var ports []listeningPort + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + proto := strings.ToUpper(fields[0]) + if proto != "TCP" && proto != "UDP" { + continue + } + // ss lists every socket; only listeners (TCP LISTEN, any bound UDP) + // are interesting, so established connections are skipped. + if !isListeningState(proto, fields[1]) { + continue + } + addr, port, ok := splitHostPort(fields[4]) + if !ok { + continue + } + entry := listeningPort{Proto: proto, Address: addr, Port: port} + if m := ssProcessPattern.FindStringSubmatch(line); len(m) == 2 { + entry.Process = m[1] + } + if m := pidPattern.FindStringSubmatch(line); len(m) == 2 { + entry.PID = m[1] + } + ports = append(ports, entry) + } + return dedupeListeners(ports) +} + +// isListeningState reports whether ss's State column means "reachable +// inbound": TCP needs LISTEN, while UDP has no handshake and a bound socket +// shows as UNCONN. Established connections are not listeners. +func isListeningState(proto, state string) bool { + switch strings.ToUpper(proto) { + case "TCP": + return strings.EqualFold(state, "LISTEN") + case "UDP": + return strings.EqualFold(state, "UNCONN") + } + return false +} + +// lsofListenPattern matches the tail of a lsof row: +// +// NAME: TCP *:22 (LISTEN) +var lsofListenPattern = regexp.MustCompile(`(TCP|UDP)\s+(\S+)\s+\(LISTEN\)`) + +// parseLsofListeners parses `lsof -iTCP -sTCP:LISTEN -nP`: +// +// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +// sshd 901 root 3u IPv4 ... 0t0 TCP *:22 (LISTEN) +func parseLsofListeners(out []byte) []listeningPort { + var ports []listeningPort + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] == "COMMAND" { + continue + } + m := lsofListenPattern.FindStringSubmatch(line) + if len(m) != 3 { + continue + } + addr, port, ok := splitHostPort(m[2]) + if !ok { + continue + } + ports = append(ports, listeningPort{ + Proto: strings.ToUpper(m[1]), + Address: addr, + Port: port, + PID: fields[1], + Process: fields[0], + }) + } + return dedupeListeners(ports) +} + +// parseNetstatListeners parses `netstat -ano` on Windows: +// +// Proto Local Address Foreign Address State PID +// TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234 +// UDP 0.0.0.0:53 *:* 5678 +func parseNetstatListeners(out []byte) []listeningPort { + var ports []listeningPort + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + proto := strings.ToUpper(fields[0]) + if proto != "TCP" && proto != "UDP" { + continue + } + // TCP rows carry a state column; UDP rows do not. Only listening TCP + // sockets are interesting, while every bound UDP socket is. + if proto == "TCP" && (len(fields) < 4 || !strings.EqualFold(fields[3], "LISTENING")) { + continue + } + addr, port, ok := splitHostPort(fields[1]) + if !ok { + continue + } + pid := fields[len(fields)-1] + if pid == "*" { + pid = "" + } + ports = append(ports, listeningPort{Proto: proto, Address: addr, Port: port, PID: pid}) + } + return dedupeListeners(ports) +} + +// splitHostPort splits "addr:port" from right, so IPv6 forms such as +// [::]:22 and *:7000 work. The brackets around IPv6 addresses are dropped. +func splitHostPort(s string) (string, int, bool) { + i := strings.LastIndexByte(s, ':') + if i < 0 { + return "", 0, false + } + port, err := strconv.Atoi(s[i+1:]) + if err != nil || port < 0 || port > 65535 { + return "", 0, false + } + host := strings.TrimSuffix(strings.TrimPrefix(s[:i], "["), "]") + return host, port, true +} + +// dedupeListeners drops rows repeating the same socket (lsof lists the same +// port once per address family) and sorts by port, then protocol. +func dedupeListeners(ports []listeningPort) []listeningPort { + seen := map[string]bool{} + out := ports[:0] + for _, p := range ports { + key := fmt.Sprintf("%s|%s|%d|%s", p.Proto, p.Address, p.Port, p.PID) + if seen[key] { + continue + } + seen[key] = true + out = append(out, p) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Port != out[j].Port { + return out[i].Port < out[j].Port + } + if out[i].Proto != out[j].Proto { + return out[i].Proto < out[j].Proto + } + return out[i].Address < out[j].Address + }) + return out +} diff --git a/internal/executor/netports_test.go b/internal/executor/netports_test.go new file mode 100644 index 0000000..69928a3 --- /dev/null +++ b/internal/executor/netports_test.go @@ -0,0 +1,220 @@ +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// linuxSS is `ss -tulnp` output in the shape iproute2 prints it. +const linuxSS = `Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:* users:(("dhclient",pid=812,fd=6)) +tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=901,fd=3)) +tcp LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=1200,fd=6)) +tcp LISTEN 0 4096 127.0.0.1:6379 0.0.0.0:* users:(("redis-server",pid=1500,fd=6)) +tcp ESTAB 0 0 10.0.0.5:54321 10.0.0.9:443 +` + +// macOSLsof is `lsof -iTCP -sTCP:LISTEN -nP` output. +const macOSLsof = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +ControlCe 446 mac 8u IPv4 0xc6e1f3949f65dbaf 0t0 TCP *:7000 (LISTEN) +ControlCe 446 mac 9u IPv6 0xc1e379d06e632fc8 0t0 TCP *:7000 (LISTEN) +node 6200 mac 43u IPv6 0x1228305ce20b957 0t0 TCP [::1]:5173 (LISTEN) +` + +// windowsNetstat is `netstat -ano` output. +const windowsNetstat = `Active Connections + + Proto Local Address Foreign Address State PID + TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234 + TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 5678 + TCP 10.0.0.5:49234 10.0.0.9:443 ESTABLISHED 5678 + UDP 0.0.0.0:53 *:* 4321 +` + +func TestParseSSListeners(t *testing.T) { + got := parseSSListeners([]byte(linuxSS)) + if len(got) != 4 { + t.Fatalf("parsed %d listeners, want 4 (the ESTAB row is not listening):\n%+v", len(got), got) + } + + // Sorted by port. + wantPorts := []int{22, 68, 80, 6379} + for i, want := range wantPorts { + if got[i].Port != want { + t.Fatalf("row %d port = %d, want %d (%+v)", i, got[i].Port, want, got) + } + } + + byPort := map[int]listeningPort{} + for _, p := range got { + byPort[p.Port] = p + } + if p := byPort[22]; p.Proto != "TCP" || p.Process != "sshd" || p.PID != "901" || p.Address != "0.0.0.0" { + t.Errorf("port 22 = %+v", p) + } + if p := byPort[80]; p.Address != "::" { + t.Errorf("IPv6 bracket not stripped: %+v", p) + } + if p := byPort[68]; p.Proto != "UDP" || p.Process != "dhclient" { + t.Errorf("udp row = %+v", p) + } +} + +func TestParseLsofListeners(t *testing.T) { + got := parseLsofListeners([]byte(macOSLsof)) + // The two ControlCe rows are the same socket in two families and collapse. + if len(got) != 2 { + t.Fatalf("parsed %d listeners, want 2:\n%+v", len(got), got) + } + if got[0].Port != 5173 || got[0].Address != "::1" || got[0].Process != "node" || got[0].PID != "6200" { + t.Errorf("first row = %+v", got[0]) + } + if got[1].Port != 7000 || got[1].Process != "ControlCe" || got[1].PID != "446" { + t.Errorf("second row = %+v", got[1]) + } +} + +func TestParseNetstatListeners(t *testing.T) { + got := parseNetstatListeners([]byte(windowsNetstat)) + // ESTABLISHED TCP rows are dropped; the bound UDP socket is kept. + if len(got) != 3 { + t.Fatalf("parsed %d listeners, want 3:\n%+v", len(got), got) + } + if got[0].Port != 53 || got[0].Proto != "UDP" || got[0].PID != "4321" { + t.Errorf("udp row = %+v", got[0]) + } + if got[1].Port != 135 || got[1].PID != "1234" { + t.Errorf("port 135 = %+v", got[1]) + } + if got[2].Port != 5432 || got[2].Address != "127.0.0.1" { + t.Errorf("port 5432 = %+v", got[2]) + } +} + +func TestListenCommandFor(t *testing.T) { + cases := map[string][]string{ + "linux": {"ss", "-tulnp"}, + "darwin": {"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, + "windows": {"netstat", "-ano"}, + } + for goos, want := range cases { + spec := listenCommandFor(goos) + got := append([]string{spec.program}, spec.args...) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("%s: command = %v, want %v", goos, got, want) + } + if spec.parse == nil { + t.Errorf("%s: no parser", goos) + } + } +} + +func TestSplitHostPort(t *testing.T) { + cases := []struct { + in string + addr string + port int + ok bool + }{ + {"0.0.0.0:22", "0.0.0.0", 22, true}, + {"*:7000", "*", 7000, true}, + {"[::]:80", "::", 80, true}, + {"[::1]:5173", "::1", 5173, true}, + {"::1:5432", "::1", 5432, true}, + {"nope", "", 0, false}, + {"host:99999", "", 0, false}, + } + for _, tc := range cases { + addr, port, ok := splitHostPort(tc.in) + if ok != tc.ok || addr != tc.addr || port != tc.port { + t.Errorf("splitHostPort(%q) = (%q, %d, %v), want (%q, %d, %v)", + tc.in, addr, port, ok, tc.addr, tc.port, tc.ok) + } + } +} + +func TestRenderListeningPortsTable(t *testing.T) { + ports := dedupeListeners(parseSSListeners([]byte(linuxSS))) + + var out bytes.Buffer + renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out}) + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + header := lines[0] + for _, col := range []string{"端口", "协议", "地址", "PID", "进程"} { + if !strings.Contains(header, col) { + t.Errorf("header %q missing column %q", header, col) + } + } + if len(lines) != 5 { + t.Fatalf("expected header + 4 rows, got %d lines:\n%s", len(lines), out.String()) + } + // Rows are ordered by port, with the owner columns filled in. + if !strings.HasPrefix(strings.TrimSpace(lines[1]), "22") || !strings.Contains(lines[1], "sshd") { + t.Errorf("first row = %q", lines[1]) + } + if !strings.Contains(out.String(), "redis-server") { + t.Errorf("process column missing redis-server:\n%s", out.String()) + } +} + +func TestRenderListeningPortsEmpty(t *testing.T) { + var out, errOut bytes.Buffer + renderListeningPorts(nil, BuiltinRequest{Stdout: &out, Stderr: &errOut}) + if out.Len() != 0 { + t.Errorf("stdout = %q, want empty", out.String()) + } + if !strings.Contains(errOut.String(), "没有正在监听的端口") { + t.Errorf("stderr = %q, want a no-listener note", errOut.String()) + } +} + +func TestRenderListeningPortsUnknownOwner(t *testing.T) { + ports := []listeningPort{{Proto: "UDP", Address: "0.0.0.0", Port: 53}} + var out bytes.Buffer + renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out}) + if !strings.Contains(out.String(), "53") || strings.Count(out.String(), "-") < 2 { + t.Errorf("unknown owner should print placeholders:\n%s", out.String()) + } +} + +func TestNetPortsRejectsUnknownOption(t *testing.T) { + err := runNetPorts(context.Background(), BuiltinRequest{Args: []string{"--nope"}, Stdout: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "未知选项") { + t.Fatalf("err = %v, want an unknown-option error", err) + } +} + +// TestNetPortsRawKeepsCommandOutput feeds a fake `ss` and checks that --raw +// prints exactly what the platform command printed. +func TestNetPortsRawKeepsCommandOutput(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "ss") + payload := "tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=901,fd=3))\n" + body := "#!/bin/sh\nprintf '%s' " + strconvQuote(payload) + "\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + // darwin resolves to lsof, so the fake answers under that name too. + if err := os.WriteFile(filepath.Join(dir, "lsof"), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var out bytes.Buffer + if err := runNetPorts(context.Background(), BuiltinRequest{Args: []string{"--raw"}, Stdout: &out, Stderr: &out}); err != nil { + t.Fatalf("runNetPorts --raw: %v", err) + } + if strings.Contains(out.String(), "端口") { + t.Errorf("--raw must not render the table:\n%s", out.String()) + } +} + +// strconvQuote single-quotes a shell payload, escaping embedded quotes. +func strconvQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index f1bfa40..f0d25cb 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -325,8 +325,9 @@ func Builtin() *Registry { Group: "disk", Summary: "列出目录下各直接子项的占用空间,按大小降序", Action: "等价于 du -x --max-depth=1 -h <路径> | sort -hr:统计每个直接子项(文件或目录递归)的占用," + - "不跨越文件系统边界,末尾输出该路径的合计。路径必填,可作位置参数或用 --path 指定;" + + "不跨越文件系统边界,末尾输出该路径的合计。路径可省略(默认当前目录),也可作位置参数或用 --path 指定;" + "由于 Windows 没有等价的 du/sort,这里使用 kopsc 内置实现以保证三平台输出一致", + Usage: "[路径]", Variants: []Variant{ { Match: Match{OS: "linux"}, @@ -373,22 +374,24 @@ func Builtin() *Registry { { ID: "net.ports", Group: "net", - Summary: "列出本机正在监听的 TCP/UDP 端口", - Action: "以只读方式列出监听中的套接字及持有进程", - Variants: []Variant{{ - Match: Match{OS: "linux"}, - Argv: []string{"ss", "-tulnp"}, - Note: "Linux 使用 iproute2 的 ss", - }, + Summary: "列出本机正在监听的端口(表格)", + Action: "只读:输出「端口 / 协议 / 地址 / PID / 进程」,按端口升序,三个平台格式一致;" + + "--raw 保留平台原生命令(Linux ss -tulnp、macOS lsof、Windows netstat -ano)的原始输出", + Variants: []Variant{ { - Match: Match{OS: "darwin"}, - Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, - Note: "macOS 使用 lsof", + Match: Match{OS: "linux"}, + Builtin: "net.ports", + Note: "Linux 内置实现(ss -tulnp 解析为表格)", }, { - Match: Match{OS: "windows"}, - Argv: []string{"netstat", "-ano"}, - Note: "Windows 使用 netstat", + Match: Match{OS: "darwin"}, + Builtin: "net.ports", + Note: "macOS 内置实现(lsof 解析为表格)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "net.ports", + Note: "Windows 内置实现(netstat -ano 解析为表格)", }, }, }, diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 9c5791d..9fd0e86 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -125,24 +125,26 @@ func TestResolveBuiltin(t *testing.T) { wantIdx: 5, }, { - name: "linux listens with ss", + // net.ports is now a builtin: the resolver picks the platform + // variant and the builtin renders the table (or --raw output). + name: "linux listens with the builtin", id: "net.ports", info: env("linux", "amd64", "centos", []string{"rhel"}), - wantArgv: []string{"ss", "-tulnp"}, + wantArgv: nil, wantIdx: 0, }, { - name: "darwin listens with lsof", + name: "darwin listens with the builtin", id: "net.ports", info: env("darwin", "amd64", "", nil), - wantArgv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, + wantArgv: nil, wantIdx: 1, }, { - name: "windows listens with netstat", + name: "windows listens with the builtin", id: "net.ports", info: env("windows", "amd64", "", nil), - wantArgv: []string{"netstat", "-ano"}, + wantArgv: nil, wantIdx: 2, }, }