Files
odboy 6b6f8619df 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 控制层级
2026-11-01 08:37:44 +08:00

344 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package diagnose
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
)
// Finding is one judgement about a container.
type Finding struct {
Severity string // "ok", "warn" or "error"
Verdict string
Evidence string
Advice string
}
// Service collects and renders diagnoses.
type Service struct {
info platform.Info
runner Runner
out io.Writer
errOut io.Writer
now func() time.Time
}
// New builds a Service. runner is usually ExecRunner; tests inject a fake.
func New(info platform.Info, runner Runner, out, errOut io.Writer) *Service {
if runner == nil {
runner = ExecRunner{}
}
if out == nil {
out = io.Discard
}
if errOut == nil {
errOut = io.Discard
}
return &Service{info: info, runner: runner, out: out, errOut: errOut, now: time.Now}
}
// Run parses the arguments forwarded by kopsc and diagnoses the target.
func (s *Service) Run(ctx context.Context, args []string) error {
target, opts, err := ParseArgs(args)
if err != nil {
return err
}
return s.diagnose(ctx, target, opts)
}
type source struct {
kind string // "docker" or "k8s"
program string // docker
namespace string
context string
}
func (s *Service) diagnose(ctx context.Context, target string, opts Options) error {
src, err := s.resolve(ctx, target, opts)
if err != nil {
return err
}
switch src.kind {
case "docker":
return s.diagnoseDocker(ctx, src, target, opts)
case "k8s":
return s.diagnoseK8s(ctx, src, target, opts)
default:
return fmt.Errorf("未知诊断来源 %q", src.kind)
}
}
// resolve decides whether the target is a container or a Pod.
func (s *Service) resolve(ctx context.Context, target string, opts Options) (source, error) {
switch opts.Kind {
case "docker":
program, err := s.dockerProgram()
if err != nil {
return source{}, err
}
return source{kind: "docker", program: program}, nil
case "k8s":
ns := opts.Namespace
if ns == "" {
ns, _ = s.kubeNamespace(ctx, opts.Context)
}
return source{kind: "k8s", namespace: ns, context: opts.Context}, nil
}
// Providing namespace/context is an explicit signal for Kubernetes.
if opts.Namespace != "" || opts.Context != "" {
ns := opts.Namespace
if ns == "" {
ns, _ = s.kubeNamespace(ctx, opts.Context)
}
return source{kind: "k8s", namespace: ns, context: opts.Context}, nil
}
var dockerErr error
if program, err := s.dockerProgram(); err == nil {
if res, err := s.run(ctx, program, "inspect", "--type", "container", target); err == nil {
var items []json.RawMessage
if json.Unmarshal(res.Stdout, &items) == nil && len(items) > 0 {
return source{kind: "docker", program: program}, nil
}
dockerErr = fmt.Errorf("Docker 中没有名为 %q 的容器", target)
} else {
dockerErr = err
}
} else {
dockerErr = err
}
if _, err := s.runner.LookPath("kubectl"); err == nil {
ns, _ := s.kubeNamespace(ctx, opts.Context)
if res, err := s.run(ctx, "kubectl", kubectlArgs(opts.Context, "get", "pod", target, "-n", ns, "-o", "name")...); err == nil &&
strings.TrimSpace(string(res.Stdout)) != "" {
return source{kind: "k8s", namespace: ns, context: opts.Context}, nil
}
base := fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns)
return source{}, s.withCandidates(ctx, base, target, opts)
}
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) {
if _, err := s.runner.LookPath("docker"); err == nil {
return "docker", nil
}
for _, name := range s.info.Runtimes {
if name == "docker" {
return "docker", nil
}
}
return "", fmt.Errorf("未找到 docker")
}
// run executes a command with a per-command timeout.
func (s *Service) run(ctx context.Context, name string, args ...string) (CommandResult, error) {
callCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
res, err := s.runner.Run(callCtx, name, args...)
if err != nil {
message := summarizeStderr(string(res.Stderr))
if message == "" {
message = err.Error()
}
return res, fmt.Errorf("%s %s 失败: %s", name, strings.Join(args, " "), message)
}
return res, nil
}
// summarizeStderr keeps the actionable last line of a multi-line error (kubectl
// prints its connection error last) and trims the rest.
func summarizeStderr(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
return truncate(strings.TrimSpace(lines[len(lines)-1]), 300)
}
func kubectlArgs(context string, args ...string) []string {
if context == "" {
return args
}
out := make([]string, 0, 2+len(args))
out = append(out, "--context", context)
return append(out, args...)
}
// kubeNamespace resolves the effective namespace: --namespace, then the
// context's kubeconfig namespace, then "default".
func (s *Service) kubeNamespace(ctx context.Context, context string) (string, error) {
res, err := s.run(ctx, "kubectl", kubectlArgs(context, "config", "view", "--minify", "-o", "jsonpath={..namespace}")...)
if err != nil {
return "default", err
}
ns := strings.TrimSpace(string(res.Stdout))
if ns == "" {
return "default", nil
}
return ns, nil
}
func (s *Service) renderFinding(indent string, f Finding) {
icon := "✅"
switch f.Severity {
case "error":
icon = "❌"
case "warn":
icon = "⚠️"
}
fmt.Fprintf(s.out, "%s判定: %s %s\n", indent, icon, f.Verdict)
if f.Evidence != "" {
fmt.Fprintf(s.out, "%s依据: %s\n", indent, f.Evidence)
}
if f.Advice != "" {
fmt.Fprintf(s.out, "%s建议: %s\n", indent, f.Advice)
}
}
func (s *Service) writeSection(title, content string) {
fmt.Fprintf(s.out, "\n%s:\n", title)
if strings.TrimSpace(content) == "" {
fmt.Fprintln(s.out, "(无)")
return
}
fmt.Fprintln(s.out, content)
}
func tailLines(text string, n int) string {
text = strings.TrimRight(text, "\n")
if strings.TrimSpace(text) == "" {
return ""
}
lines := strings.Split(text, "\n")
if n > 0 && len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}