feat: 新增 proc nohup 后台运行、docker save/load 内置实现,移除 Podman 支持

- proc.nohup:后台运行命令并将 stdout/stderr 追加写入日志,
  Unix 下新会话、Windows 下 DETACHED_PROCESS 脱离终端
- docker.save:等价 docker save <镜像> -o <镜像名>.tar,文件名按
  仓库名+标签生成(alpine:3.20 → alpine_3.20.tar),传镜像 ID 时
  先解析出对应名称;docker.load 改为归档文件作位置参数
- 移除 Podman 支持,所有容器命令只走 docker
This commit is contained in:
2026-11-01 07:47:57 +08:00
parent 6002a3afd1
commit bd6f20d7c5
19 changed files with 1551 additions and 120 deletions
+134 -11
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -46,17 +47,14 @@ func TestLeafDryRun(t *testing.T) {
}
}
func TestLeafDryRunPrefersPodman(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
func TestLeafDryRunPicksDockerVariant(t *testing.T) {
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "clearsafe", "--dry-run"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
if !strings.Contains(stdout.String(), "podman system prune -f") {
t.Errorf("expected podman variant:\n%s", stdout.String())
}
if strings.Contains(stdout.String(), "docker system prune -f") {
t.Errorf("docker variant should not be chosen when podman is present:\n%s", stdout.String())
if !strings.Contains(stdout.String(), "docker system prune -f") {
t.Errorf("expected the docker variant:\n%s", stdout.String())
}
}
@@ -127,7 +125,7 @@ func TestListMarksUnavailable(t *testing.T) {
}
func TestExplain(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 {
t.Fatalf("exit code = %d", code)
@@ -140,7 +138,7 @@ func TestExplain(t *testing.T) {
"候选:",
"✓",
"✗",
"podman system prune -f",
"docker system prune -f",
"distro=centos",
} {
if !strings.Contains(out, want) {
@@ -176,13 +174,13 @@ func TestExplainUnknownCommand(t *testing.T) {
}
func TestPlatform(t *testing.T) {
app, stdout, _ := testApp(centos("docker", "podman"))
app, stdout, _ := testApp(centos("docker"))
if code := app.Execute([]string{"platform"}); code != 0 {
t.Fatalf("exit code = %d", code)
}
out := stdout.String()
for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker, podman"} {
for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker"} {
if !strings.Contains(out, want) {
t.Errorf("platform output missing %q:\n%s", want, out)
}
@@ -508,3 +506,128 @@ func TestExecutionPreservesExitCode(t *testing.T) {
t.Fatalf("exit code = %d, want 9", code)
}
}
func TestProcNohupThroughCLI(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "run.log")
app, stdout, stderr := testApp(centos("docker"))
// A command-local -n must reach the child: kopsc's leaf parser disables
// flag parsing for this command.
if code := app.Execute([]string{"proc", "nohup", "-o", logPath, "-w", "sh", "-c", "echo out; echo err >&2"}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "已在后台启动") || !strings.Contains(out, logPath) {
t.Errorf("launch summary missing:\n%s", out)
}
if !strings.Contains(out, "out") || !strings.Contains(out, "err") {
t.Errorf("--wait did not relay output:\n%s", out)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("log not written: %v", err)
}
if !strings.Contains(string(data), "out") || !strings.Contains(string(data), "err") {
t.Errorf("log = %q", data)
}
}
func TestProcNohupDryRunDoesNotLaunch(t *testing.T) {
app, stdout, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"proc", "nohup", "--dry-run", "sh", "-c", "echo hi"}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "[dry-run]") {
t.Errorf("dry-run output missing:\n%s", stdout.String())
}
}
func TestProcNohupRequiresCommand(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"proc", "nohup", "-q"}); code == 0 {
t.Fatal("expected a non-zero exit code without a command")
}
if !strings.Contains(stderr.String(), "缺少参数") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestProcNohupKeepsCommandLocalDryRun(t *testing.T) {
app, stdout, stderr := testApp(centos("docker"))
// -n after the program belongs to the program, so kopsc must actually run.
if code := app.Execute([]string{"proc", "nohup", "-q", "-w", "sh", "-c", "echo ran"}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
if strings.Contains(stdout.String(), "[dry-run]") {
t.Errorf("command-local arguments were intercepted as kopsc options:\n%s", stdout.String())
}
}
// fakeRuntimeScript installs a fake docker on PATH that answers
// `images --format` with one image and records every invocation in calls.log.
func fakeRuntimeScript(t *testing.T, images string) string {
t.Helper()
dir := t.TempDir()
logPath := filepath.Join(dir, "calls.log")
script := "#!/bin/sh\n" +
"echo \"$*\" >> " + logPath + "\n" +
"case \"$1\" in\n" +
" images) cat <<'EOT'\n" + images + "\nEOT\n" +
" ;;\n" +
"esac\n"
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return logPath
}
func TestDockerSaveThroughCLINamesArchiveAfterImageID(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake runtime is a shell script")
}
logPath := fakeRuntimeScript(t, "IMAGE ID\tREPOSITORY\tTAG\tDIGEST\naaaa11112222\talpine\t3.20\t")
app, stdout, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "save", "aaaa11112222"}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s stdout=%s", code, stderr.String(), stdout.String())
}
if !strings.Contains(stdout.String(), "alpine_3.20.tar") {
t.Errorf("summary missing archive name:\n%s", stdout.String())
}
data, _ := os.ReadFile(logPath)
if !strings.Contains(string(data), "save -o alpine_3.20.tar aaaa11112222") {
t.Errorf("calls = %q", data)
}
}
func TestDockerLoadThroughCLITakesArchive(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake runtime is a shell script")
}
archive := filepath.Join(t.TempDir(), "alpine_3.20.tar")
if err := os.WriteFile(archive, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
logPath := fakeRuntimeScript(t, "")
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "load", archive}); code != 0 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
}
data, _ := os.ReadFile(logPath)
if !strings.Contains(string(data), "load -i "+archive) {
t.Errorf("calls = %q", data)
}
}
func TestDockerLoadThroughCLIRequiresArchive(t *testing.T) {
app, _, stderr := testApp(centos("docker"))
if code := app.Execute([]string{"docker", "load"}); code == 0 {
t.Fatal("expected a non-zero exit code without an archive")
}
if !strings.Contains(stderr.String(), "缺少参数") {
t.Errorf("stderr = %q", stderr.String())
}
}