feat: 优化非 Docker 命令的易用性,修复带 digest 镜像无法按名称查找

- docker save/rmi:镜像名不再拼接 digest({{.Digest}} 对多数镜像输出
  sha256:…,导致 alpine:3.20 被存成 alpine:3.20@sha256:… 而查不到),
  digest 单独保存;同时修正自定义 --format 输出中 IMAGE ID 表头未被过滤
- diagnose:目标不存在时给出相似名称候选,无相似时列出当前存在的对象
- net.pids:无进程占用时向 stderr 提示且 stdout 保持为空,新增 --pid-file
- net.ports:默认输出跨平台一致的端口表格,--raw 保留原生命令输出
- disk.usage:路径可省略(默认当前目录),新增 --depth N 控制层级
This commit is contained in:
2026-11-01 08:37:44 +08:00
parent f5ae80c1a8
commit 6b6f8619df
15 changed files with 1317 additions and 130 deletions
+117 -2
View File
@@ -121,10 +121,125 @@ func (s *Service) resolve(ctx context.Context, target string, opts Options) (sou
strings.TrimSpace(string(res.Stdout)) != "" {
return source{kind: "k8s", namespace: ns, context: opts.Context}, nil
}
return source{}, fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns)
base := fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns)
return source{}, s.withCandidates(ctx, base, target, opts)
}
return source{}, fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr)
base := fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr)
return source{}, s.withCandidates(ctx, base, target, opts)
}
// withCandidates appends similar names to a "target not found" error, so the
// user does not have to run docker ps / kubectl get pods to find out that the
// container is called web-1.
//
// When nothing is similar, the few objects that do exist are listed instead —
// knowing that the only containers are "redis" and "db" is usually enough to
// spot the typo. With no objects at all the error keeps its original text.
func (s *Service) withCandidates(ctx context.Context, base error, target string, opts Options) error {
all := s.candidateNames(ctx, target, opts)
var hits, existing []string
for _, name := range all {
if name.matched {
hits = append(hits, name.name)
}
existing = append(existing, name.name)
}
if len(hits) > 0 {
return fmt.Errorf("%w\n你可能要找的是: %s\n(设置 --kind docker 或 --kind k8s 可缩小查找范围)",
base, strings.Join(hits, ", "))
}
if len(existing) == 0 {
return base
}
return fmt.Errorf("%w\n当前存在的对象: %s\n(可用 kopsc docker psall 或 kubectl get pods 查看详情)",
base, strings.Join(existing, ", "))
}
// nameCandidate is one existing name and whether it resembles the target.
type nameCandidate struct {
name string
matched bool
}
// candidateNames collects the names of existing Docker containers and Pods,
// marking those that resemble the target (prefix or substring, ignoring case)
// and ordering the similar ones first.
func (s *Service) candidateNames(ctx context.Context, target string, opts Options) []nameCandidate {
if opts.Kind == "k8s" {
return rankNames(s.podNames(ctx, opts), target)
}
var names []string
if program, err := s.dockerProgram(); err == nil {
if res, err := s.run(ctx, program, "ps", "-a", "--format", "{{.Names}}"); err == nil {
names = append(names, strings.Fields(string(res.Stdout))...)
}
}
if opts.Kind != "docker" {
names = append(names, s.podNames(ctx, opts)...)
}
return rankNames(names, target)
}
// podNames lists the Pods in the effective namespace.
func (s *Service) podNames(ctx context.Context, opts Options) []string {
if _, err := s.runner.LookPath("kubectl"); err != nil {
return nil
}
ns, _ := s.kubeNamespace(ctx, opts.Context)
res, err := s.run(ctx, "kubectl", kubectlArgs(opts.Context, "get", "pods", "-n", ns, "-o", "name")...)
if err != nil {
return nil
}
var names []string
for _, field := range strings.Fields(string(res.Stdout)) {
names = append(names, strings.TrimPrefix(field, "pod/"))
}
return names
}
// rankNames marks the names that resemble target — prefix hits before
// substring hits, comparison ignoring case — and returns at most maxNames of
// them, followed by the remaining names so callers can also show what exists
// when nothing is similar.
func rankNames(names []string, target string) []nameCandidate {
const maxNames = 5
lower := strings.ToLower(target)
var prefixHits, containsHits, others []nameCandidate
for _, name := range names {
n := strings.ToLower(name)
switch {
case strings.HasPrefix(n, lower), strings.HasPrefix(lower, n):
prefixHits = append(prefixHits, nameCandidate{name: name, matched: true})
case strings.Contains(n, lower):
containsHits = append(containsHits, nameCandidate{name: name, matched: true})
default:
others = append(others, nameCandidate{name: name})
}
}
hits := append(prefixHits, containsHits...)
if len(hits) > maxNames {
hits = hits[:maxNames]
}
if len(others) > maxNames {
others = others[:maxNames]
}
return append(hits, others...)
}
// matchNames is rankNames reduced to the similar names only.
func matchNames(names []string, target string) []string {
var hits []string
for _, c := range rankNames(names, target) {
if c.matched {
hits = append(hits, c.name)
}
}
return hits
}
func (s *Service) dockerProgram() (string, error) {
+76
View File
@@ -138,3 +138,79 @@ func TestResolveNamespaceSkipsDocker(t *testing.T) {
t.Errorf("namespace missing from output:\n%s", out.String())
}
}
func TestResolveNotFoundSuggestsSimilarContainers(t *testing.T) {
r := &fakeRunner{
available: map[string]bool{"docker": true, "kubectl": true},
respond: func(name string, args []string) (CommandResult, error) {
joined := strings.Join(args, " ")
switch {
case len(args) > 0 && args[0] == "inspect":
return CommandResult{Stdout: []byte("[]")}, nil
case strings.HasPrefix(joined, "ps -a"):
return CommandResult{Stdout: []byte("web-1\nweb-2\ndb\nnginx-web\n")}, nil
case strings.HasPrefix(joined, "config view"):
return CommandResult{}, nil
case strings.HasPrefix(joined, "get pod"):
return CommandResult{Stderr: []byte("not found")}, fmt.Errorf("exit status 1")
}
return CommandResult{}, nil
},
}
s, _, _ := newTestService(r)
err := s.Run(context.Background(), []string{"web"})
if err == nil || !strings.Contains(err.Error(), "未找到目标") {
t.Fatalf("err = %v, want not-found error", err)
}
msg := err.Error()
for _, want := range []string{"你可能要找的是", "web-1", "web-2"} {
if !strings.Contains(msg, want) {
t.Errorf("error missing %q:\n%s", want, msg)
}
}
}
func TestResolveNotFoundWithoutCandidatesKeepsOriginalText(t *testing.T) {
r := &fakeRunner{
available: map[string]bool{"docker": true},
respond: func(name string, args []string) (CommandResult, error) {
switch {
case len(args) > 0 && args[0] == "inspect":
return CommandResult{Stdout: []byte("[]")}, nil
case strings.HasPrefix(strings.Join(args, " "), "ps -a"):
return CommandResult{Stdout: []byte("alpha\nbeta\n")}, nil
}
return CommandResult{}, nil
},
}
s, _, _ := newTestService(r)
err := s.Run(context.Background(), []string{"ghost"})
if err == nil || !strings.Contains(err.Error(), "未找到目标") {
t.Fatalf("err = %v, want not-found error", err)
}
if strings.Contains(err.Error(), "你可能要找的是") {
t.Errorf("unexpected candidate hint:\n%s", err.Error())
}
// Nothing is similar, so the existing objects are listed instead.
for _, want := range []string{"当前存在的对象", "alpha", "beta"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q:\n%s", want, err.Error())
}
}
}
func TestMatchNamesRanking(t *testing.T) {
names := []string{"nginx-web", "web-1", "WEB-2", "api", "myweb"}
got := matchNames(names, "web")
want := []string{"web-1", "WEB-2", "nginx-web", "myweb"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("matchNames() = %v, want %v (prefix hits first, case-insensitive)", got, want)
}
many := []string{"web-1", "web-2", "web-3", "web-4", "web-5", "web-6", "web-7"}
if got := matchNames(many, "web"); len(got) != 5 {
t.Fatalf("matchNames returned %d names, want the 5 closest", len(got))
}
}