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
+33 -6
View File
@@ -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` + 日志追加) |
+25 -10
View File
@@ -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())
}
}
+117 -2
View File
@@ -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) {
+76
View File
@@ -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))
}
}
+1
View File
@@ -43,6 +43,7 @@ var builtins = map[string]Builtin{
"docker.stats": runDockerStats,
"docker.stop": runDockerStop,
"net.pids": runNetPIDs,
"net.ports": runNetPorts,
"proc.nohup": runNohup,
}
+120 -58
View File
@@ -12,18 +12,18 @@ import (
"strings"
)
// runDiskUsage implements `du -x --max-depth=1 -h <path> | sort -hr`.
// runDiskUsage implements `du -x --max-depth=N -h <path> | 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
+118 -4
View File
@@ -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 总计 <root>".
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)
}
}
}
+27 -9
View File
@@ -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]. "<none>" 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]. "<none>" 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 == "<none>" {
return ""
}
@@ -128,9 +149,6 @@ func formatImageName(repo, tag, digest string) string {
if tag != "" && tag != "<none>" {
name += ":" + tag
}
if digest != "" && digest != "<none>" {
name += "@" + digest
}
return name
}
+86
View File
@@ -771,3 +771,89 @@ func registryVariantBuiltinFor(name string) registry.Variant {
func resolveResultWithVariant(v registry.Variant) resolve.Result {
return resolve.Result{Command: &registry.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<none>\t<none>\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)
}
// "<none>" 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"},
{"<none>", "<none>", ""},
}
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)
}
}
}
+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)
+95 -3
View File
@@ -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())
}
}
+286
View File
@@ -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
}
+220
View File
@@ -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, "'", `'\''`) + "'"
}
+17 -14
View File
@@ -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 解析为表格)",
},
},
},
+8 -6
View File
@@ -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,
},
}