- 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 控制层级
344 lines
9.8 KiB
Go
344 lines
9.8 KiB
Go
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")
|
||
}
|