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:
@@ -67,11 +67,13 @@ kopsc diagnose web # 自动识别 Docker 容器或 K8s Pod,给
|
|||||||
kopsc diagnose api-0 -n prod # K8s:本命令里 -n 表示 namespace
|
kopsc diagnose api-0 -n prod # K8s:本命令里 -n 表示 namespace
|
||||||
kopsc diagnose api-0 --kind k8s --context staging
|
kopsc diagnose api-0 --kind k8s --context staging
|
||||||
kopsc diagnose web --lines 200 --no-events
|
kopsc diagnose web --lines 200 --no-events
|
||||||
|
# 目标名写错时不用自己查:kopsc 会提示「你可能要找的是: web-1, web-2」
|
||||||
|
|
||||||
kopsc net pids 8080 # 等价 lsof -ti:8080,输出占用端口的 PID
|
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 --path /var # 同上,用命名参数指定路径
|
||||||
kopsc disk usage /var --dry-run # 预览(内置命令同样支持)
|
kopsc disk usage /var --dry-run # 预览(内置命令同样支持)
|
||||||
kopsc proc nohup ./server # 后台运行命令,输出写日志(等价 nohup ./server > server.out 2>&1 &)
|
kopsc proc nohup ./server # 后台运行命令,输出写日志(等价 nohup ./server > server.out 2>&1 &)
|
||||||
@@ -96,8 +98,10 @@ $ kopsc disk usage /var
|
|||||||
总计 2.0G /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 上不跟随
|
- 不跟随符号链接;Unix 上通过设备号实现 `-x`(不跨文件系统),Windows 上不跟随
|
||||||
reparse point,目录遍历本身不会跨越卷。
|
reparse point,目录遍历本身不会跨越卷。
|
||||||
- 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。
|
- 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。
|
||||||
@@ -235,14 +239,20 @@ Pending(结合 FailedScheduling/FailedMount 事件)、Evicted 等。
|
|||||||
|
|
||||||
### 端口占用查询(net.pids)
|
### 端口占用查询(net.pids)
|
||||||
|
|
||||||
`kopsc net pids <端口>` 等价 `lsof -ti:<端口>`:输出占用该端口的进程 PID,每行一个、升序、去重;没有进程占用时无输出并正常退出。
|
`kopsc net pids <端口>` 等价 `lsof -ti:<端口>`:输出占用该端口的进程 PID,每行一个、升序、去重。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kopsc net pids 8080
|
kopsc net pids 8080
|
||||||
kopsc net pids --port 8080
|
kopsc net pids --port 8080
|
||||||
|
kopsc net pids 8080 --pid-file /tmp/web.pid # 同时写出逗号分隔的 PID
|
||||||
kopsc net pids 8080 --dry-run
|
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。
|
- `lsof` 无匹配时退出码为 1 且无输出,kopsc 视为“没有进程占用”,返回 0。
|
||||||
- 只读命令,不需要 `--yes`。
|
- 只读命令,不需要 `--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 内置实现 |
|
| `diagnose` | 容器/Pod 状态根因诊断(自动识别 Docker 与 Kubernetes) | Linux/macOS/Windows 均使用 kopsc 内置实现 |
|
||||||
| `net.pids` | 查找占用指定端口的进程 PID(等价 `lsof -ti:<端口>`) | Linux `lsof` → `ss` → `/proc`;macOS `lsof`;Windows `netstat -ano`。三平台命令与输出一致(PID 升序、每行一个) |
|
| `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 内置实现,保证输出一致 |
|
| `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` |
|
| `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` + 日志追加) |
|
| `proc.nohup` | 后台运行命令,输出写入日志(等价 `nohup <命令> > output.log 2>&1 &`) | Linux/macOS 内置实现(新会话 + 日志追加)、Windows 内置实现(`DETACHED_PROCESS` + 日志追加) |
|
||||||
|
|
||||||
|
|||||||
+25
-10
@@ -73,14 +73,17 @@ func TestLeafHelp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLeafPassesThroughArgs(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"))
|
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 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d", code)
|
t.Fatalf("exit code = %d", code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "ss -tulnp -l") {
|
out := stdout.String()
|
||||||
t.Errorf("passthrough args missing:\n%s", 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) {
|
func TestDiskUsageDefaultsToCurrentDirectoryThroughCLI(t *testing.T) {
|
||||||
app, _, stderr := testApp(centos("docker"))
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "payload.bin"), bytes.Repeat([]byte("x"), 2048), 0o644); err != nil {
|
||||||
if code := app.Execute([]string{"disk", "usage"}); code != 1 {
|
t.Fatal(err)
|
||||||
t.Fatalf("exit code = %d, want 1", code)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "必须提供路径") {
|
old, err := os.Getwd()
|
||||||
t.Errorf("stderr = %s", stderr.String())
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,10 +121,125 @@ func (s *Service) resolve(ctx context.Context, target string, opts Options) (sou
|
|||||||
strings.TrimSpace(string(res.Stdout)) != "" {
|
strings.TrimSpace(string(res.Stdout)) != "" {
|
||||||
return source{kind: "k8s", namespace: ns, context: opts.Context}, nil
|
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) {
|
func (s *Service) dockerProgram() (string, error) {
|
||||||
|
|||||||
@@ -138,3 +138,79 @@ func TestResolveNamespaceSkipsDocker(t *testing.T) {
|
|||||||
t.Errorf("namespace missing from output:\n%s", out.String())
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ var builtins = map[string]Builtin{
|
|||||||
"docker.stats": runDockerStats,
|
"docker.stats": runDockerStats,
|
||||||
"docker.stop": runDockerStop,
|
"docker.stop": runDockerStop,
|
||||||
"net.pids": runNetPIDs,
|
"net.pids": runNetPIDs,
|
||||||
|
"net.ports": runNetPorts,
|
||||||
"proc.nohup": runNohup,
|
"proc.nohup": runNohup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+120
-58
@@ -12,18 +12,18 @@ import (
|
|||||||
"strings"
|
"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
|
// It reports the entries down to N levels below the given directory: regular
|
||||||
// size, directories by the recursive size of their regular files. Symbolic
|
// files by their size, directories by the recursive size of their regular
|
||||||
// links are not followed and, where the platform exposes device IDs, nested
|
// files. Symbolic links are not followed and, where the platform exposes
|
||||||
// filesystems are not crossed (-x).
|
// device IDs, nested filesystems are not crossed (-x).
|
||||||
func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
|
func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
|
||||||
paths, err := parseDiskUsageArgs(req.Args)
|
opts, err := parseDiskUsageOptions(req.Args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
root := paths[0]
|
root, depth := opts.path, opts.depth
|
||||||
|
|
||||||
rootInfo, err := os.Stat(root)
|
rootInfo, err := os.Stat(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,50 +33,19 @@ func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
|
|||||||
return fmt.Errorf("%q 不是目录", root)
|
return fmt.Errorf("%q 不是目录", root)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("读取目录 %q 失败: %w", root, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootDev, checkDev := deviceOf(rootInfo)
|
rootDev, checkDev := deviceOf(rootInfo)
|
||||||
|
|
||||||
type entrySize struct {
|
// Each listed entry is a direct child, or with --depth a deeper one.
|
||||||
path string
|
// Every file's size is counted exactly once, so the total stays the real
|
||||||
size int64
|
// 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
|
var total int64
|
||||||
|
for _, item := range items {
|
||||||
for _, entry := range entries {
|
total += item.size
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
@@ -87,17 +56,91 @@ func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
w := req.Stdout
|
w := req.Stdout
|
||||||
|
shownRoot := root
|
||||||
for _, item := range items {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDiskUsageArgs accepts either a positional path or --path/--path=value.
|
// entrySize is one row of the report.
|
||||||
// Exactly one path is required.
|
type entrySize struct {
|
||||||
func parseDiskUsageArgs(args []string) ([]string, error) {
|
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
|
var paths []string
|
||||||
|
depth := 1
|
||||||
positionalOnly := false
|
positionalOnly := false
|
||||||
|
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
@@ -107,32 +150,51 @@ func parseDiskUsageArgs(args []string) ([]string, error) {
|
|||||||
paths = append(paths, arg)
|
paths = append(paths, arg)
|
||||||
case arg == "--":
|
case arg == "--":
|
||||||
positionalOnly = true
|
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":
|
case arg == "--path":
|
||||||
if i+1 >= len(args) {
|
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])
|
paths = append(paths, args[i+1])
|
||||||
i++
|
i++
|
||||||
case strings.HasPrefix(arg, "--path="):
|
case strings.HasPrefix(arg, "--path="):
|
||||||
value := strings.TrimPrefix(arg, "--path=")
|
value := strings.TrimPrefix(arg, "--path=")
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil, errors.New("--path 缺少参数,例如 --path /var")
|
return nil, 0, errors.New("--path 缺少参数,例如 --path /var")
|
||||||
}
|
}
|
||||||
paths = append(paths, value)
|
paths = append(paths, value)
|
||||||
case strings.HasPrefix(arg, "-") && arg != "-":
|
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:
|
default:
|
||||||
paths = append(paths, arg)
|
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 {
|
if len(paths) == 0 {
|
||||||
return nil, errors.New("必须提供路径,例如 kopsc disk usage /var 或 kopsc disk usage --path /var")
|
paths = append(paths, ".")
|
||||||
}
|
}
|
||||||
if len(paths) > 1 {
|
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
|
// dirSize sums the sizes of regular files under dir without following symbolic
|
||||||
|
|||||||
@@ -102,10 +102,25 @@ func TestDiskUsageAcceptsPathFlag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiskUsageRequiresPath(t *testing.T) {
|
func TestDiskUsageDefaultsToCurrentDirectory(t *testing.T) {
|
||||||
_, err := runDisk(t)
|
root := diskFixture(t)
|
||||||
if err == nil || !strings.Contains(err.Error(), "必须提供路径") {
|
old, err := os.Getwd()
|
||||||
t.Fatalf("err = %v, want missing-path error", err)
|
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())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type imageRef struct {
|
|||||||
ID string // image ID, e.g. "sha256:abc..."
|
ID string // image ID, e.g. "sha256:abc..."
|
||||||
CreatedAt string // human-readable creation time, as docker lists it
|
CreatedAt string // human-readable creation time, as docker lists it
|
||||||
Size string // human-readable size, 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
|
Repository string
|
||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
@@ -92,10 +93,12 @@ func listImages(ctx context.Context) ([]imageRef, error) {
|
|||||||
|
|
||||||
var refs []imageRef
|
var refs []imageRef
|
||||||
for _, line := range splitLines(out) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
fields := strings.Split(line, "\t")
|
|
||||||
ref := imageRef{ID: strings.TrimSpace(fields[0])}
|
ref := imageRef{ID: strings.TrimSpace(fields[0])}
|
||||||
if len(fields) >= 3 {
|
if len(fields) >= 3 {
|
||||||
repo, tag := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2])
|
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.Repository = repo
|
||||||
ref.Tag = tag
|
ref.Tag = tag
|
||||||
ref.Name = formatImageName(repo, tag, digest)
|
ref.Digest = digest
|
||||||
|
ref.Name = formatImageName(repo, tag)
|
||||||
}
|
}
|
||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
}
|
}
|
||||||
return refs, nil
|
return refs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatImageName renders repository[:tag][@digest]. "<none>" is how docker
|
// isImageHeader reports whether a line is docker's column header rather than
|
||||||
// groups untagged images.
|
// an image row. Headers never carry a real 12-character hex ID.
|
||||||
func formatImageName(repo, tag, digest string) string {
|
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>" {
|
if repo == "<none>" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -128,9 +149,6 @@ func formatImageName(repo, tag, digest string) string {
|
|||||||
if tag != "" && tag != "<none>" {
|
if tag != "" && tag != "<none>" {
|
||||||
name += ":" + tag
|
name += ":" + tag
|
||||||
}
|
}
|
||||||
if digest != "" && digest != "<none>" {
|
|
||||||
name += "@" + digest
|
|
||||||
}
|
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -771,3 +771,89 @@ func registryVariantBuiltinFor(name string) registry.Variant {
|
|||||||
func resolveResultWithVariant(v registry.Variant) resolve.Result {
|
func resolveResultWithVariant(v registry.Variant) resolve.Result {
|
||||||
return resolve.Result{Command: ®istry.Command{ID: "docker.test"}, Variant: v}
|
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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,56 +27,100 @@ var pidPattern = regexp.MustCompile(`pid=(\d+)`)
|
|||||||
// - macOS: lsof (the system ships it; no /proc fallback)
|
// - macOS: lsof (the system ships it; no /proc fallback)
|
||||||
// - Windows: netstat -ano
|
// - Windows: netstat -ano
|
||||||
func runNetPIDs(ctx context.Context, req BuiltinRequest) error {
|
func runNetPIDs(ctx context.Context, req BuiltinRequest) error {
|
||||||
port, err := parsePortArgs(req.Args)
|
opts, err := parseNetPIDsArgs(req.Args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
port := opts.port
|
||||||
|
|
||||||
|
var pids []string
|
||||||
if runtime.GOOS == "windows" {
|
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) {
|
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
|
var value string
|
||||||
|
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
arg := args[i]
|
arg := args[i]
|
||||||
switch {
|
switch {
|
||||||
case arg == "--port":
|
case arg == "--port":
|
||||||
if i+1 >= len(args) {
|
if i+1 >= len(args) {
|
||||||
return 0, fmt.Errorf("--port 缺少端口号")
|
return opts, fmt.Errorf("--port 缺少端口号")
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
value = args[i]
|
value = args[i]
|
||||||
case strings.HasPrefix(arg, "--port="):
|
case strings.HasPrefix(arg, "--port="):
|
||||||
value = strings.TrimPrefix(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 != "-":
|
case strings.HasPrefix(arg, "-") && arg != "-":
|
||||||
return 0, fmt.Errorf("未知选项 %q", arg)
|
return opts, fmt.Errorf("未知选项 %q", arg)
|
||||||
default:
|
default:
|
||||||
if value != "" {
|
if value != "" {
|
||||||
return 0, fmt.Errorf("一次只支持一个端口,收到多个参数")
|
return opts, fmt.Errorf("一次只支持一个端口,收到多个参数")
|
||||||
}
|
}
|
||||||
value = arg
|
value = arg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return 0, fmt.Errorf("必须提供端口,例如 kopsc net pids 8080")
|
return opts, fmt.Errorf("必须提供端口,例如 kopsc net pids 8080")
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := strconv.Atoi(value)
|
port, err := strconv.Atoi(value)
|
||||||
if err != nil || port < 1 || port > 65535 {
|
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 {
|
// writePIDFile writes the PIDs as one comma-separated line, so the file works
|
||||||
pids, err := unixPIDs(ctx, port)
|
// with `kill $(cat file)` and is empty when nothing holds the port. The
|
||||||
if err != nil {
|
// location is reported on stderr to keep stdout machine-readable.
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +298,15 @@ func procPIDs(port int, procRoot string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func netPIDsWindows(ctx context.Context, port int, req BuiltinRequest) 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")
|
cmd := exec.CommandContext(ctx, "netstat", "-ano")
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
@@ -264,11 +317,9 @@ func netPIDsWindows(ctx context.Context, port int, req BuiltinRequest) error {
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
}
|
}
|
||||||
return fmt.Errorf("netstat -ano 失败: %s", message)
|
return nil, fmt.Errorf("netstat -ano 失败: %s", message)
|
||||||
}
|
}
|
||||||
|
return parseNetstatPIDs(stdout.String(), port), nil
|
||||||
writePIDs(req.Stdout, parseNetstatPIDs(stdout.String(), port))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNetstatPIDs extracts unique owning PIDs for a local port from
|
// parseNetstatPIDs extracts unique owning PIDs for a local port from
|
||||||
@@ -326,6 +377,25 @@ func normalizePIDs(pids []string) []string {
|
|||||||
return out
|
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) {
|
func writePIDs(w interface{ Write([]byte) (int, error) }, pids []string) {
|
||||||
for _, pid := range pids {
|
for _, pid := range pids {
|
||||||
fmt.Fprintln(w, pid)
|
fmt.Fprintln(w, pid)
|
||||||
|
|||||||
@@ -102,12 +102,29 @@ func TestNetPIDsUnixNoMatchIsNotAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out, errOut bytes.Buffer
|
||||||
if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out}); err != nil {
|
if err := runNetPIDs(context.Background(), BuiltinRequest{Args: []string{"8080"}, Stdout: &out, Stderr: &errOut}); err != nil {
|
||||||
t.Fatalf("no-match should not fail: %v", err)
|
t.Fatalf("no-match should not fail: %v", err)
|
||||||
}
|
}
|
||||||
if out.Len() != 0 {
|
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)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -325,8 +325,9 @@ func Builtin() *Registry {
|
|||||||
Group: "disk",
|
Group: "disk",
|
||||||
Summary: "列出目录下各直接子项的占用空间,按大小降序",
|
Summary: "列出目录下各直接子项的占用空间,按大小降序",
|
||||||
Action: "等价于 du -x --max-depth=1 -h <路径> | sort -hr:统计每个直接子项(文件或目录递归)的占用," +
|
Action: "等价于 du -x --max-depth=1 -h <路径> | sort -hr:统计每个直接子项(文件或目录递归)的占用," +
|
||||||
"不跨越文件系统边界,末尾输出该路径的合计。路径必填,可作位置参数或用 --path 指定;" +
|
"不跨越文件系统边界,末尾输出该路径的合计。路径可省略(默认当前目录),也可作位置参数或用 --path 指定;" +
|
||||||
"由于 Windows 没有等价的 du/sort,这里使用 kopsc 内置实现以保证三平台输出一致",
|
"由于 Windows 没有等价的 du/sort,这里使用 kopsc 内置实现以保证三平台输出一致",
|
||||||
|
Usage: "[路径]",
|
||||||
Variants: []Variant{
|
Variants: []Variant{
|
||||||
{
|
{
|
||||||
Match: Match{OS: "linux"},
|
Match: Match{OS: "linux"},
|
||||||
@@ -373,22 +374,24 @@ func Builtin() *Registry {
|
|||||||
{
|
{
|
||||||
ID: "net.ports",
|
ID: "net.ports",
|
||||||
Group: "net",
|
Group: "net",
|
||||||
Summary: "列出本机正在监听的 TCP/UDP 端口",
|
Summary: "列出本机正在监听的端口(表格)",
|
||||||
Action: "以只读方式列出监听中的套接字及持有进程",
|
Action: "只读:输出「端口 / 协议 / 地址 / PID / 进程」,按端口升序,三个平台格式一致;" +
|
||||||
Variants: []Variant{{
|
"--raw 保留平台原生命令(Linux ss -tulnp、macOS lsof、Windows netstat -ano)的原始输出",
|
||||||
|
Variants: []Variant{
|
||||||
|
{
|
||||||
Match: Match{OS: "linux"},
|
Match: Match{OS: "linux"},
|
||||||
Argv: []string{"ss", "-tulnp"},
|
Builtin: "net.ports",
|
||||||
Note: "Linux 使用 iproute2 的 ss",
|
Note: "Linux 内置实现(ss -tulnp 解析为表格)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Match: Match{OS: "darwin"},
|
Match: Match{OS: "darwin"},
|
||||||
Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
Builtin: "net.ports",
|
||||||
Note: "macOS 使用 lsof",
|
Note: "macOS 内置实现(lsof 解析为表格)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Match: Match{OS: "windows"},
|
Match: Match{OS: "windows"},
|
||||||
Argv: []string{"netstat", "-ano"},
|
Builtin: "net.ports",
|
||||||
Note: "Windows 使用 netstat",
|
Note: "Windows 内置实现(netstat -ano 解析为表格)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,24 +125,26 @@ func TestResolveBuiltin(t *testing.T) {
|
|||||||
wantIdx: 5,
|
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",
|
id: "net.ports",
|
||||||
info: env("linux", "amd64", "centos", []string{"rhel"}),
|
info: env("linux", "amd64", "centos", []string{"rhel"}),
|
||||||
wantArgv: []string{"ss", "-tulnp"},
|
wantArgv: nil,
|
||||||
wantIdx: 0,
|
wantIdx: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "darwin listens with lsof",
|
name: "darwin listens with the builtin",
|
||||||
id: "net.ports",
|
id: "net.ports",
|
||||||
info: env("darwin", "amd64", "", nil),
|
info: env("darwin", "amd64", "", nil),
|
||||||
wantArgv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
wantArgv: nil,
|
||||||
wantIdx: 1,
|
wantIdx: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "windows listens with netstat",
|
name: "windows listens with the builtin",
|
||||||
id: "net.ports",
|
id: "net.ports",
|
||||||
info: env("windows", "amd64", "", nil),
|
info: env("windows", "amd64", "", nil),
|
||||||
wantArgv: []string{"netstat", "-ano"},
|
wantArgv: nil,
|
||||||
wantIdx: 2,
|
wantIdx: 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user