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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -771,3 +771,89 @@ func registryVariantBuiltinFor(name string) registry.Variant {
|
||||
func resolveResultWithVariant(v registry.Variant) resolve.Result {
|
||||
return resolve.Result{Command: ®istry.Command{ID: "docker.test"}, Variant: v}
|
||||
}
|
||||
|
||||
// TestListImagesParsesRealDockerOutput pins the parser against output captured
|
||||
// from a real Docker Desktop: tab-separated fields including the digest column.
|
||||
func TestListImagesParsesRealDockerOutput(t *testing.T) {
|
||||
payload := "IMAGE ID\tREPOSITORY\tTAG\tDIGEST\tCREATED AT\tSIZE\n" +
|
||||
"b175f8a4c2a5\truntime-demo\tlatest\tsha256:b175f8\t2026-10-23 11:48:04 +0800 CST\t23.3MB\n" +
|
||||
"d6bfb6b28318\tkenaito-conf-web\tlatest\tsha256:d6bfb6\t2026-10-21 15:37:51 +0800 CST\t75.4MB\n" +
|
||||
"e0b0e5c0a111\t<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)
|
||||
// - 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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "'", `'\''`) + "'"
|
||||
}
|
||||
Reference in New Issue
Block a user