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
This commit is contained in:
2026-10-31 14:09:38 +08:00
parent 84f7361396
commit e02f3010cf
19 changed files with 2465 additions and 69 deletions
+230
View File
@@ -0,0 +1,230 @@
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")
}