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
+77 -7
View File
@@ -26,6 +26,10 @@ type App struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// exitCode is set by commands that intentionally exit non-zero without an
// error (for example a destructive command awaiting --yes).
exitCode int
}
// New returns an App for the running process.
@@ -42,13 +46,14 @@ func New(version string) *App {
// Execute runs the command tree and returns the process exit code.
func (a *App) Execute(args []string) int {
a.exitCode = 0
root := a.Root()
root.SetArgs(args)
if err := root.Execute(); err != nil {
fmt.Fprintln(a.Stderr, "kopsc: "+err.Error())
return executor.ExitCode(err)
}
return 0
return a.exitCode
}
// Root builds the full cobra command tree.
@@ -126,18 +131,32 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
Long: cmd.Summary + "\n\n动作: " + cmd.Action,
DisableFlagParsing: true,
RunE: func(c *cobra.Command, args []string) error {
dryRun, extra, help := parseLeafArgs(args)
dryRun, yes, extra, help, err := parseLeafArgs(cmd, args)
if err != nil {
return err
}
if help {
a.printLeafHelp(c.OutOrStdout(), cmd)
return nil
}
if err := cmd.ValidateArgs(extra); err != nil {
return err
}
result, err := resolve.Resolve(cmd, a.Info)
if err != nil {
return fmt.Errorf("%w(可用 `kopsc explain %s` 查看全部候选)", err, cmd.ID)
}
plan := executor.Plan{Result: result, ExtraArgs: extra}
if cmd.RequiresYes && !yes && !dryRun {
a.printConfirmation(cmd, plan)
a.exitCode = 2
return nil
}
return plan.Run(context.Background(), executor.Options{
DryRun: dryRun,
Stdin: a.Stdin,
@@ -149,34 +168,79 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
return leaf
}
// printConfirmation explains a destructive command and how to approve it.
func (a *App) printConfirmation(cmd *registry.Command, plan executor.Plan) {
w := a.Stdout
fmt.Fprintln(w, "这是破坏性命令,未执行。")
fmt.Fprintf(w, "命令: %s\n", cmd.Invocation())
fmt.Fprintf(w, "将执行: %s\n", plan.Display())
fmt.Fprintf(w, "影响: %s\n\n", cmd.Action)
fmt.Fprintf(w, "确认执行请追加 --yes:\n %s --yes\n", cmd.Invocation())
}
// parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--`
// are consumed, everything else (including `--` and after) is forwarded.
func parseLeafArgs(args []string) (dryRun bool, extra []string, help bool) {
//
// For commands marked Namespaced (Kubernetes-aware), -n/-N mean --namespace
// (kubectl style) and are normalised to --namespace; --dry-run remains the
// only preview flag. On every other command -n keeps its --dry-run meaning.
func parseLeafArgs(cmd *registry.Command, args []string) (dryRun, yes bool, extra []string, help bool, err error) {
passthrough := false
for _, arg := range args {
for i := 0; i < len(args); i++ {
arg := args[i]
if passthrough {
extra = append(extra, arg)
continue
}
switch {
case arg == "--":
passthrough = true
case cmd.Namespaced && (arg == "-n" || arg == "-N" || arg == "--namespace"):
if i+1 >= len(args) {
return false, false, nil, false,
fmt.Errorf("%s: %s 需要命名空间参数,例如 %s prod", cmd.Invocation(), arg, arg)
}
i++
extra = append(extra, "--namespace", args[i])
case cmd.Namespaced && strings.HasPrefix(arg, "-n="):
extra = append(extra, "--namespace="+strings.TrimPrefix(arg, "-n="))
case cmd.Namespaced && strings.HasPrefix(arg, "-N="):
extra = append(extra, "--namespace="+strings.TrimPrefix(arg, "-N="))
case cmd.Namespaced && strings.HasPrefix(arg, "--namespace="):
extra = append(extra, arg)
case arg == "-n" || arg == "--dry-run" || arg == "--dry-run=true":
dryRun = true
case arg == "--dry-run=false":
dryRun = false
case arg == "-y" || arg == "--yes":
yes = true
case arg == "-h" || arg == "--help":
help = true
default:
extra = append(extra, arg)
}
}
return dryRun, extra, help
return dryRun, yes, extra, help, nil
}
func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
invocation := "kopsc " + strings.ReplaceAll(cmd.ID, ".", " ")
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [-- 透传参数...]\n\n", invocation)
invocation := cmd.Invocation()
argsHint := ""
if cmd.Usage != "" {
argsHint = " " + cmd.Usage
}
fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [--yes]%s [-- 透传参数...]\n\n", invocation, argsHint)
fmt.Fprintf(w, "%s\n\n", cmd.Summary)
fmt.Fprintf(w, "动作: %s\n\n", cmd.Action)
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
@@ -190,7 +254,13 @@ func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
fmt.Fprint(w, "选项:\n")
fmt.Fprint(w, " -n, --dry-run 仅打印将执行的命令与判定依据,不执行\n")
fmt.Fprint(w, " -h, --help 显示本帮助\n")
if cmd.RequiresYes {
fmt.Fprint(w, " -y, --yes 确认执行破坏性命令(否则只打印影响并以状态码 2 退出)\n")
}
fmt.Fprint(w, " -- 其后的参数原样透传给底层命令\n")
if cmd.Usage != "" {
fmt.Fprintf(w, "\n参数: %s(至少 %d 个)\n", cmd.Usage, cmd.MinArgs)
}
}
func (a *App) newListCmd() *cobra.Command {
+156 -12
View File
@@ -39,7 +39,7 @@ func TestLeafDryRun(t *testing.T) {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{"[dry-run]", "docker system prune -f", "CentOS 上的 Docker 运行时", "依据"} {
for _, want := range []string{"[dry-run]", "docker system prune -f", "Linux 上的 Docker 运行时", "依据"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q:\n%s", want, out)
}
@@ -212,28 +212,55 @@ func TestGroupHelp(t *testing.T) {
}
func TestParseLeafArgs(t *testing.T) {
plain := &registry.Command{ID: "docker.logs"}
namespaced := &registry.Command{ID: "diagnose", Namespaced: true}
tests := []struct {
name string
cmd *registry.Command
args []string
wantDryRun bool
wantYes bool
wantExtra []string
wantHelp bool
wantErr bool
}{
{"empty", nil, false, nil, false},
{"dry-run short", []string{"-n"}, true, nil, false},
{"dry-run long", []string{"--dry-run"}, true, nil, false},
{"dry-run false", []string{"--dry-run=false"}, false, nil, false},
{"help", []string{"-h"}, false, nil, true},
{"passthrough", []string{"--", "--dry-run", "-h"}, false, []string{"--dry-run", "-h"}, false},
{"unknown flags forwarded", []string{"--filter", "until=1h"}, false, []string{"--filter", "until=1h"}, false},
{"mixed", []string{"--dry-run", "arg", "--", "-n"}, true, []string{"arg", "-n"}, false},
{"empty", plain, nil, false, false, nil, false, false},
{"plain -n is dry-run", plain, []string{"-n"}, true, false, nil, false, false},
{"dry-run long", plain, []string{"--dry-run"}, true, false, nil, false, false},
{"dry-run false", plain, []string{"--dry-run=false"}, false, false, nil, false, false},
{"yes long", plain, []string{"--yes"}, false, true, nil, false, false},
{"yes short", plain, []string{"-y"}, false, true, nil, false, false},
{"help", plain, []string{"-h"}, false, false, nil, true, false},
{"passthrough", plain, []string{"--", "--dry-run", "-h", "--yes"}, false, false, []string{"--dry-run", "-h", "--yes"}, false, false},
{"unknown flags forwarded", plain, []string{"--filter", "until=1h"}, false, false, []string{"--filter", "until=1h"}, false, false},
{"mixed", plain, []string{"--dry-run", "arg", "--", "-n"}, true, false, []string{"arg", "-n"}, false, false},
{"namespaced -n", namespaced, []string{"api-0", "-n", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false},
{"namespaced -n=value", namespaced, []string{"api-0", "-n=prod"}, false, false, []string{"api-0", "--namespace=prod"}, false, false},
{"namespaced -N", namespaced, []string{"api-0", "-N", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false},
{"namespaced --namespace", namespaced, []string{"api-0", "--namespace", "prod"}, false, false, []string{"api-0", "--namespace", "prod"}, false, false},
{"namespaced --namespace=value", namespaced, []string{"api-0", "--namespace=prod"}, false, false, []string{"api-0", "--namespace=prod"}, false, false},
{"namespaced --dry-run still previews", namespaced, []string{"api-0", "--dry-run"}, true, false, []string{"api-0"}, false, false},
{"namespaced -n without value", namespaced, []string{"api-0", "-n"}, false, false, nil, false, true},
{"namespaced passthrough keeps -n", namespaced, []string{"--", "-n", "prod"}, false, false, []string{"-n", "prod"}, false, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dryRun, extra, help := parseLeafArgs(tc.args)
if dryRun != tc.wantDryRun || help != tc.wantHelp {
t.Errorf("dryRun=%v help=%v, want %v/%v", dryRun, help, tc.wantDryRun, tc.wantHelp)
dryRun, yes, extra, help, err := parseLeafArgs(tc.cmd, tc.args)
if tc.wantErr {
if err == nil {
t.Fatal("expected an error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if dryRun != tc.wantDryRun || yes != tc.wantYes || help != tc.wantHelp {
t.Errorf("dryRun=%v yes=%v help=%v, want %v/%v/%v",
dryRun, yes, help, tc.wantDryRun, tc.wantYes, tc.wantHelp)
}
if strings.Join(extra, "|") != strings.Join(tc.wantExtra, "|") {
t.Errorf("extra = %v, want %v", extra, tc.wantExtra)
@@ -242,6 +269,87 @@ func TestParseLeafArgs(t *testing.T) {
}
}
func TestDestructiveCommandRequiresYes(t *testing.T) {
app, stdout, stderr := testApp(centos("docker"))
code := app.Execute([]string{"docker", "clearall"})
if code != 2 {
t.Fatalf("exit code = %d, want 2", code)
}
out := stdout.String()
for _, want := range []string{"破坏性命令", "将执行: docker system prune -a -f", "影响", "kopsc docker clearall --yes"} {
if !strings.Contains(out, want) {
t.Errorf("confirmation output missing %q:\n%s", want, out)
}
}
if stderr.Len() != 0 {
t.Errorf("unexpected stderr: %s", stderr.String())
}
}
func TestDestructiveCommandDryRunNeedsNoYes(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "prunevolumes", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d, want 0", code)
}
if !strings.Contains(stdout.String(), "docker volume prune -f") {
t.Errorf("dry-run output:\n%s", stdout.String())
}
}
func TestYesIsNotForwarded(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "clearall", "--yes", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d, want 0", code)
}
out := stdout.String()
if strings.Contains(out, "--yes") {
t.Errorf("--yes leaked into the forwarded args:\n%s", out)
}
if !strings.Contains(out, "docker system prune -a -f") {
t.Errorf("unexpected dry-run output:\n%s", out)
}
}
func TestMissingRequiredArgs(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "logs"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
msg := stderr.String()
for _, want := range []string{"缺少参数", "kopsc docker logs <容器>"} {
if !strings.Contains(msg, want) {
t.Errorf("stderr missing %q:\n%s", want, msg)
}
}
}
func TestMissingArgsWhenOnlyOptionsGiven(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "rm", "-f"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "缺少参数") {
t.Errorf("stderr = %s", stderr.String())
}
}
func TestDestructiveHelpMentionsYes(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "clearall", "-h"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
if !strings.Contains(out, "--yes") || !strings.Contains(out, "状态码 2") {
t.Errorf("help missing --yes documentation:\n%s", out)
}
}
func TestDiskUsageCommandThroughCLI(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.bin"), bytes.Repeat([]byte("x"), 4096), 0o644); err != nil {
@@ -316,6 +424,42 @@ func TestDiskUsageHelp(t *testing.T) {
}
}
func TestDiagnoseDryRun(t *testing.T) {
app, stdout, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"diagnose", "web", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
out := stdout.String()
for _, want := range []string{"[dry-run]", "(内置) diagnose web", "内置实现"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q:\n%s", want, out)
}
}
}
func TestDiagnoseNamespaceTranslationThroughCLI(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"diagnose", "api-0", "-n", "prod", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "api-0 --namespace prod") {
t.Errorf("-n was not translated to --namespace:\n%s", stdout.String())
}
}
func TestDiagnoseRequiresTarget(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"diagnose"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "缺少参数") {
t.Errorf("stderr = %s", stderr.String())
}
}
// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough.
func TestCLIHelperProcess(t *testing.T) {
if os.Getenv("KOPSC_CLI_HELPER") != "1" {