Files
kenaito-kopsc/internal/diagnose/service.go
T
odboy e02f3010cf feat: 扩充 Docker 命令并新增 diagnose 状态诊断
- Docker:新增只读、生命周期、清理、Compose 共 32 条命令,Linux 自动适配 podman
- 危险命令 clearall/prunevolumes 未带 --yes 时仅提示并以退出码 2 结束
- 需要容器/镜像名的命令做必填参数校验,-- 之后参数计入位置参数
- diagnose:自动识别 Docker/Podman 容器或 K8s Pod,输出状态、根因、依据、建议并附日志与事件
- diagnose 中 -n 表示 namespace,其余命令 -n 仍为 --dry-run
- 新增内置动作桥接与平台变体 helper,补充单元/集成/CLI 测试与 README
2026-10-31 14:09:38 +08:00

231 lines
6.1 KiB
Go
Raw 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 or podman
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
}
return source{}, fmt.Errorf("未找到目标 %q:%v;Kubernetes 命名空间 %s 中也没有该 Pod", target, dockerErr, ns)
}
return source{}, fmt.Errorf("未找到目标 %q:%v;同时未安装 kubectl", target, dockerErr)
}
func (s *Service) dockerProgram() (string, error) {
for _, name := range []string{"docker", "podman"} {
if _, err := s.runner.LookPath(name); err == nil {
return name, nil
}
}
for _, name := range s.info.Runtimes {
if name == "docker" || name == "podman" {
return name, nil
}
}
return "", fmt.Errorf("未找到 docker 或 podman")
}
// 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")
}