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:
@@ -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