From bd6f20d7c5e0c2d90be7edbc243b4e9a4271dcd5 Mon Sep 17 00:00:00 2001 From: Odboy Date: Sun, 1 Nov 2026 07:47:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20proc=20nohup=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=BF=90=E8=A1=8C=E3=80=81docker=20save/load?= =?UTF-8?q?=20=E5=86=85=E7=BD=AE=E5=AE=9E=E7=8E=B0=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20Podman=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 81 +++++-- internal/cli/cli_test.go | 145 +++++++++++- internal/diagnose/runner.go | 2 +- internal/diagnose/service.go | 14 +- internal/executor/builtin.go | 13 +- internal/executor/dockersave.go | 324 +++++++++++++++++++++++++++ internal/executor/dockersave_test.go | 243 ++++++++++++++++++++ internal/executor/executor.go | 1 + internal/executor/nohup.go | 320 ++++++++++++++++++++++++++ internal/executor/nohup_test.go | 234 +++++++++++++++++++ internal/executor/nohup_unix.go | 27 +++ internal/executor/nohup_windows.go | 32 +++ internal/platform/platform.go | 6 +- internal/platform/platform_test.go | 14 +- internal/registry/builtin.go | 101 ++++++--- internal/registry/registry.go | 6 +- internal/registry/registry_test.go | 70 ++++-- internal/resolve/resolve.go | 12 +- internal/resolve/resolve_test.go | 26 +-- 19 files changed, 1551 insertions(+), 120 deletions(-) create mode 100644 internal/executor/dockersave.go create mode 100644 internal/executor/dockersave_test.go create mode 100644 internal/executor/nohup.go create mode 100644 internal/executor/nohup_test.go create mode 100644 internal/executor/nohup_unix.go create mode 100644 internal/executor/nohup_windows.go diff --git a/README.md b/README.md index 91b4798..8243043 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ $ kopsc docker clearsafe --dry-run 依据: 匹配 os=linux, runtime=docker (+2) ``` -在 CentOS 上它执行 `docker system prune -f`;在装有 Podman 的 CentOS 上自动改走 -`podman system prune -f`;在 macOS / Windows 上同样有各自的变体。你只记住一个意图。 +在 Linux 上它执行 `docker system prune -f`,macOS / Windows 上使用 Docker Desktop 的同一命令。 +你只记住一个意图,kopsc 负责挑出当前平台可用的那条命令。 ## 构建 @@ -55,6 +55,8 @@ kopsc docker logs -f web # 查看日志(容器名必填,选项可 kopsc docker exec -it web sh # 进入容器 kopsc docker compose up # 启动 Compose 服务 kopsc docker clearall --yes # 深度清理(破坏性命令,必须显式 --yes) +kopsc docker save alpine:3.20 # 自动保存为 alpine_3.20.tar(传镜像 ID 会先解析出名称) +kopsc docker load alpine_3.20.tar # 等价 docker load < alpine_3.20.tar kopsc docker clearsafe # 直接执行当前平台对应的清理命令 kopsc docker clearsafe --dry-run # 只打印将执行的命令与判定依据 @@ -72,6 +74,7 @@ kopsc net ports # 列出监听中的端口 kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版) kopsc disk usage --path /var # 同上,用命名参数指定路径 kopsc disk usage /var --dry-run # 预览(内置命令同样支持) +kopsc proc nohup ./server # 后台运行命令,输出写日志(等价 nohup ./server > server.out 2>&1 &) kopsc list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 @@ -129,19 +132,39 @@ $ kopsc disk usage /var | 清理 | `clearsafe`、`pruneimages`、`prunecontainers`、`prunenets`、`prunebuilder`、`prunevolumes`、`clearall` | | Compose | `compose up`、`compose down`、`compose ps`、`compose logs` | -- 所有 Docker 命令在 Linux 上同时提供 `docker` 与 `podman` 变体,且 podman 优先; - macOS / Windows 使用 Docker Desktop。 -- `logs`、`inspect`、`start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`cp` +- 所有 Docker 命令在三个平台都调用 `docker`(macOS / Windows 上即 Docker Desktop)。 +- `logs`、`inspect`、`start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`load`、`cp` 需要位置参数,缺失时直接报错并给出用法。 - 破坏性命令只有 `clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`): 未带 `--yes` 时只打印影响范围与将执行的命令,并以退出码 2 结束。 -- `docker compose` 对应 Compose v2;`podman compose` 需要 podman 4+。 +- `docker compose` 对应 Compose v2。 - `docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像), **不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。 +### 镜像导出与导入(docker save / docker load) + +`kopsc docker save <镜像名|镜像ID>` 等价于 `docker save <镜像> -o <镜像名>.tar`,归档文件自动命名: + +```bash +kopsc docker save alpine:3.20 # → alpine_3.20.tar +kopsc docker save 9c6f07244728 # 按 ID 查到 alpine:3.20,同样保存为 alpine_3.20.tar +kopsc docker save sha256:9c6f07244728 --platform linux/arm64 +kopsc docker load alpine_3.20.tar # 等价 docker load < alpine_3.20.tar +kopsc docker load a.tar b.tar # 一次导入多个 +``` + +- 文件名取自镜像的**仓库名 + 标签**:标签前的冒号、路径分隔符 `/` 等无法用于文件名的字符改写为下划线, + 例如 `registry.example.com:5000/team/app:1.0` → `registry.example.com_5000_team_app_1.0.tar`; + 未打标签的镜像没有名称可依据,直接报错并提示先 `docker tag`。 +- 传入镜像 ID(可省略 `sha256:` 前缀,也可用唯一前缀)时,先向运行时查询它对应的镜像名称,再据此命名。 + 同一 ID 有多个名称时要求直接指定名称,避免文件名含糊。 +- `--platform` 等运行时选项原样透传给底层运行时。 +- `docker load` 的归档文件是**位置参数**(对应原来的 `docker load < xxx.tar`),可一次给出多个; + `-i/--input` 仍然可用,传 `-` 仍表示从标准输入读取。 + ### 容器/Pod 诊断(diagnose) -`kopsc diagnose <名称>` 自动判断目标是 Docker/Podman 容器还是 Kubernetes Pod,输出状态、根因、依据、建议,并附最近日志与事件。只读,不修改任何对象。 +`kopsc diagnose <名称>` 自动判断目标是 Docker 容器还是 Kubernetes Pod,输出状态、根因、依据、建议,并附最近日志与事件。只读,不修改任何对象。 ```text 目标: web @@ -206,6 +229,35 @@ kopsc net pids 8080 --dry-run | `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 | | `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | | `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | +| `proc.nohup` | 后台运行命令,输出写入日志(等价 `nohup <命令> > output.log 2>&1 &`) | Linux/macOS 内置实现(新会话 + 日志追加)、Windows 内置实现(`DETACHED_PROCESS` + 日志追加) | + +### 后台运行(proc.nohup) + +`kopsc proc nohup <命令> [参数...]` 等价于 `nohup <命令> > output.log 2>&1 &`:启动命令后立刻返回,关闭终端不会终止它。 + +```bash +kopsc proc nohup ./server -port 8080 # 日志写到 ./server.out(同 nohup.out) +kopsc proc nohup -o /var/log/server.log ./server # 指定日志 +kopsc proc nohup -C /srv/app --env=APP_ENV=prod ./run.sh # 指定工作目录与环境变量 +kopsc proc nohup -w ./migrate.sh # 等它结束,并回显日志、透传退出码 +kopsc proc nohup --dry-run ./server # 只打印判定依据 +``` + +- 标准输入接到空设备;标准输出与标准错误**都追加**写入日志文件(`>>` 语义),并自动创建日志目录。 +- Unix 下子进程进入新会话、无控制终端(相当于 `setsid`),Windows 下以 `DETACHED_PROCESS` 启动。 +- 选项只在**命令之前**解析:`kopsc proc nohup ffmpeg -i in.mp4` 中的 `-i` 原样给 ffmpeg, + 不会被 kopsc 截获(因此命令自身的 `-n`/`--dry-run` 不会触发 kopsc 的预览)。 + 需要预览时把 `--dry-run` 写在命令之前、或用 `--` 分隔。 +- 不经过 shell,`>`、`|`、`&&` 需要显式写 shell:`kopsc proc nohup sh -c 'a | b > c'`。 + +| 选项 | 说明 | +| --- | --- | +| `-o`, `--out`, `--output` | 日志文件,默认 `<程序名>.out` | +| `-C`, `--dir`, `--chdir` | 子进程工作目录(相对日志路径也基于它),默认当前目录 | +| `-E`, `--env NAME=VALUE` | 追加环境变量,可重复 | +| `-q`, `--quiet` | 不打印启动摘要(PID、日志路径) | +| `-w`, `--wait` | 前台等待命令退出,回显其写入的日志内容并透传退出码 | +| `--detach` / `--no-detach` | 默认脱离会话;`--no-detach` 保留当前会话(类似只加 `&`) | ## 平台识别 @@ -213,7 +265,7 @@ kopsc net pids 8080 --dry-run - OS/ARCH:编译目标 `runtime.GOOS` / `runtime.GOARCH` - Linux 发行版:`/etc/os-release` 的 `ID` 与 `ID_LIKE`(文件缺失时视为未知,不报错) -- 容器运行时:`exec.LookPath` 探测 `docker`、`podman` +- 容器运行时:`exec.LookPath` 探测 `docker` ## 匹配规则 @@ -222,17 +274,16 @@ kopsc net pids 8080 --dry-run 1. 变体声明的每一个约束都必须满足,否则该变体被排除。 2. 满足的变体按特异性打分:`Distro` 精确命中 +3,`ID_LIKE` 命中 +2,`Runtime` 命中 +2,无约束的通用变体 0 分。 -3. 取最高分。并列时先按运行时偏好(podman 优先于 docker),再按声明约束的数量(更具体者优先)。 +3. 取最高分。并列时先按运行时偏好(有运行时约束者优先,且 `docker` 在前),再按声明约束的数量(更具体者优先)。 4. 仍并列则报歧义错误;无任何变体匹配则报无匹配错误,并提示 `kopsc explain <命令>`。 5. 取 `--dry-run` 或 `explain` 可看到每个候选的得分与被拒原因。 -例如 CentOS 上同时装了 docker 与 podman: +例如 CentOS 上执行 `kopsc explain docker.clearsafe`: ```text -✓ #0 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f -✓ #1 2 分 匹配 os=linux, runtime=docker (+2) -> docker system prune -f -✗ #2 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f -✗ #3 0 分 os=windows 不匹配(当前 linux) -> docker system prune -f +✓ #0 2 分 匹配 os=linux, runtime=docker (+2) -> docker system prune -f +✗ #1 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f +✗ #2 0 分 os=windows 不匹配(当前 linux) -> docker system prune -f ``` ## 新增命令或平台变体 @@ -271,7 +322,7 @@ internal/platform 环境探测(可注入,便于测试) internal/registry 命令模型、校验、内置注册表 internal/resolve 变体打分与选择 internal/executor 进程执行、内置动作、dry-run、退出码透传 -internal/diagnose Docker/Podman 与 Kubernetes 的状态根因诊断(内置动作) +internal/diagnose Docker 与 Kubernetes 的状态根因诊断(内置动作) internal/cli cobra 命令树与辅助命令 scripts/build 跨平台构建脚本(go run ./scripts/build) ``` diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b0c7bc3..bc6e4bd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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()) + } +} diff --git a/internal/diagnose/runner.go b/internal/diagnose/runner.go index 60033e1..23172f0 100644 --- a/internal/diagnose/runner.go +++ b/internal/diagnose/runner.go @@ -1,5 +1,5 @@ // Package diagnose explains why a container or Pod is in its current state. -// It knows how to inspect Docker/Podman containers and Kubernetes Pods, and +// It knows how to inspect Docker containers and Kubernetes Pods, and // falls back to a built-in implementation so the output is identical on every // platform. package diagnose diff --git a/internal/diagnose/service.go b/internal/diagnose/service.go index 4522c61..333d7f2 100644 --- a/internal/diagnose/service.go +++ b/internal/diagnose/service.go @@ -53,7 +53,7 @@ func (s *Service) Run(ctx context.Context, args []string) error { type source struct { kind string // "docker" or "k8s" - program string // docker or podman + program string // docker namespace string context string } @@ -128,17 +128,15 @@ func (s *Service) resolve(ctx context.Context, target string, opts Options) (sou } func (s *Service) dockerProgram() (string, error) { - for _, name := range []string{"docker", "podman"} { - if _, err := s.runner.LookPath(name); err == nil { - return name, nil - } + if _, err := s.runner.LookPath("docker"); err == nil { + return "docker", nil } for _, name := range s.info.Runtimes { - if name == "docker" || name == "podman" { - return name, nil + if name == "docker" { + return "docker", nil } } - return "", fmt.Errorf("未找到 docker 或 podman") + return "", fmt.Errorf("未找到 docker") } // run executes a command with a per-command timeout. diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go index fd28a51..900c8a7 100644 --- a/internal/executor/builtin.go +++ b/internal/executor/builtin.go @@ -15,6 +15,10 @@ type BuiltinRequest struct { Stdout io.Writer Stderr io.Writer Info platform.Info + // Plan is the resolved variant and forwarded arguments. Builtins that + // drive an external runtime (for example docker save) read the runtime's + // program name and the passthrough arguments from it. + Plan Plan } // Builtin executes a command inside kopsc instead of spawning a child. It is @@ -22,9 +26,12 @@ type BuiltinRequest struct { type Builtin func(ctx context.Context, req BuiltinRequest) error var builtins = map[string]Builtin{ - "disk.usage": runDiskUsage, - "diagnose": runDiagnose, - "net.pids": runNetPIDs, + "disk.usage": runDiskUsage, + "diagnose": runDiagnose, + "docker.load": runDockerLoad, + "docker.save": runDockerSave, + "net.pids": runNetPIDs, + "proc.nohup": runNohup, } // runDiagnose bridges the registry builtin to the diagnose package. diff --git a/internal/executor/dockersave.go b/internal/executor/dockersave.go new file mode 100644 index 0000000..da0cf2c --- /dev/null +++ b/internal/executor/dockersave.go @@ -0,0 +1,324 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose" +) + +// runDockerSave implements `docker save <镜像> > <镜像名>.tar`. +// +// The archive is named after the image's repository and tag rather than its +// ID: `alpine:3.20` becomes alpine_3.20.tar, an untagged image keeps its bare +// name, and an image ID is first resolved to the name it is tagged with. +// Extra arguments such as --platform are forwarded to docker. +func runDockerSave(ctx context.Context, req BuiltinRequest) error { + rest := req.Args + var images []string + var passthrough []string + for i := 0; i < len(rest); i++ { + arg := rest[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + images = append(images, arg) + continue + } + passthrough = append(passthrough, arg) + // Keep an option's value with the option: `--platform linux/arm64` + // must not be read as a second image. + if !strings.Contains(arg, "=") && saveValueOption[arg] { + if i+1 < len(rest) { + i++ + passthrough = append(passthrough, rest[i]) + } + } + } + image, err := singleImage(images, "kopsc docker save <镜像名|镜像ID>") + if err != nil { + return err + } + + ref, err := resolveImageRef(ctx, diagnose.ExecRunner{}, image) + if err != nil { + return err + } + outPath := archiveName(ref) + + // Docker derives the archive's internal tags from the image itself, so the + // caller's argument is passed through as given. + args := append([]string{"save", "-o", outPath, image}, passthrough...) + if err := runRuntimeCommand(ctx, args, req); err != nil { + return err + } + + fmt.Fprintf(req.Stdout, "已保存: %s\n", ref) + fmt.Fprintf(req.Stdout, "文件: %s\n", outPath) + return nil +} + +// runDockerLoad implements `docker load < archive.tar`. +// +// The archive is positional, mirroring `docker load x.tar`. A lone `-i archive` +// (docker's own spelling) or an argument after `--` is accepted too; a +// `docker load -` stream still goes to stdin. +func runDockerLoad(ctx context.Context, req BuiltinRequest) error { + rest := req.Args + + var archives []string + var passthrough []string + afterDashDash, stream := false, false + for i := 0; i < len(rest); i++ { + arg := rest[i] + switch { + case afterDashDash: + archives = append(archives, arg) + case arg == "--": + afterDashDash = true + case arg == "-i" || arg == "--input": + if i+1 >= len(rest) { + return errors.New("-i 缺少归档文件,例如 kopsc docker load alpine_3.20.tar") + } + i++ + archives = append(archives, rest[i]) + case strings.HasPrefix(arg, "-i="): + archives = append(archives, strings.TrimPrefix(arg, "-i=")) + case strings.HasPrefix(arg, "--input="): + archives = append(archives, strings.TrimPrefix(arg, "--input=")) + case arg == "-": + stream = true + case strings.HasPrefix(arg, "-"): + passthrough = append(passthrough, arg) + default: + archives = append(archives, arg) + } + } + if len(archives) == 0 { + if !stream { + return errors.New("缺少归档文件;用法: kopsc docker load <镜像归档.tar>") + } + return runRuntimeCommand(ctx, []string{"load"}, req) + } + + var paths []string + for _, archive := range archives { + path, err := existingArchive(archive) + if err != nil { + return err + } + paths = append(paths, path) + } + + args := append([]string{"load"}, passthrough...) + for _, path := range paths { + args = append(args, "-i", path) + } + return runRuntimeCommand(ctx, args, req) +} + +// saveValueOption lists docker `save` options that take a separate +// value, so the value is not mistaken for an image name. +var saveValueOption = map[string]bool{ + "--platform": true, +} + +func singleImage(images []string, usage string) (string, error) { + switch len(images) { + case 0: + return "", fmt.Errorf("缺少镜像;用法: %s", usage) + case 1: + return images[0], nil + default: + return "", fmt.Errorf("一次只能保存一个镜像,收到 %d 个;用法: %s", len(images), usage) + } +} + +// existingArchive checks that the archive can be read and returns an absolute +// path, so the runtime resolves it independently of its working directory. +func existingArchive(archive string) (string, error) { + info, err := os.Stat(archive) + if err != nil { + return "", fmt.Errorf("无法读取归档文件 %q: %w", archive, err) + } + if info.IsDir() { + return "", fmt.Errorf("%q 是目录,不是镜像归档", archive) + } + if abs, err := filepath.Abs(archive); err == nil { + return abs, nil + } + return archive, nil +} + +// imageRef is the repository and tag (or digest) of one local image. +type imageRef struct { + Name string // repository[:tag][@digest], e.g. "alpine:3.20" + ID string // image ID, e.g. "sha256:abc..." +} + +// customFormat lets a single `images --format` call list IDs together with +// names (docker images -q prints IDs only). +const customFormat = `{{.ID}}{{"\t"}}{{if .Repository}}{{.Repository}}{{"\t"}}{{.Tag}}{{"\t"}}{{.Digest}}{{end}}` + +// resolveImageRef maps the user's argument to the image's repository[:tag]. +// +// A name is used as given once it is confirmed locally. An image ID — with or +// without the sha256: prefix, or a unique prefix — resolves to the name it is +// tagged with; an untagged image is an error, because the archive would have no +// name to take its file name from. Names always win over IDs, so a short name +// such as "alpine" is never mistaken for an ID prefix. +func resolveImageRef(ctx context.Context, runner diagnose.Runner, image string) (string, error) { + refs, err := listImages(ctx, runner) + if err != nil { + return "", err + } + + for _, ref := range refs { + if ref.Name == image { + return image, nil + } + } + + if !looksLikeImageID(image) { + return "", fmt.Errorf("本地没有镜像 %q,可用 kopsc docker images 查看", image) + } + + // Same image ID on several rows is normal (one row per repository:tag). + ids := map[string][]string{} + var order []string + for _, ref := range refs { + id := normalizeImageID(ref.ID) + if !strings.HasPrefix(id, normalizeImageID(image)) { + continue + } + if _, seen := ids[id]; !seen { + order = append(order, id) + } + ids[id] = append(ids[id], ref.Name) + } + + switch len(order) { + case 0: + return "", fmt.Errorf("本地没有镜像 ID %q,可用 kopsc docker images 查看", image) + case 1: + default: + return "", fmt.Errorf("镜像 ID %q 匹配多个本地镜像(%s),请改用镜像名称", + image, strings.Join(order, ", ")) + } + + names := ids[order[0]] + if len(names) > 1 { + return "", fmt.Errorf("镜像 ID %q 对应多个名称(%s),请直接指定要保存的名称", + image, strings.Join(names, ", ")) + } + if len(names) == 0 || names[0] == "" { + return "", fmt.Errorf("镜像 ID %q 存在但没有名称(未打标签),无法据此命名归档文件;请先 docker tag 再保存", image) + } + return names[0], nil +} + +// listImages asks docker for every local image. docker images prints a header +// line, which is dropped explicitly. +func listImages(ctx context.Context, runner diagnose.Runner) ([]imageRef, error) { + result, err := runner.Run(ctx, "docker", "images", "--format", customFormat) + if err != nil { + return nil, runtimeError("images", result, err) + } + + var refs []imageRef + for _, line := range strings.Split(string(result.Stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "REPOSITORY") { + continue + } + fields := strings.Split(line, "\t") + ref := imageRef{ID: strings.TrimSpace(fields[0])} + if len(fields) >= 3 { + repo, tag, digest := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2]), "" + if len(fields) >= 4 { + digest = strings.TrimSpace(fields[3]) + } + ref.Name = formatImageName(repo, tag, digest) + } + refs = append(refs, ref) + } + return refs, nil +} + +// formatImageName renders repository[:tag][@digest]. "" is how both +// runtimes group untagged images. +func formatImageName(repo, tag, digest string) string { + if repo == "" { + return "" + } + name := repo + if tag != "" && tag != "" { + name += ":" + tag + } + if digest != "" && digest != "" { + name += "@" + digest + } + return name +} + +// archiveName turns an image reference into a file name: every character that +// cannot appear in a portable file name, including the ":" between repository +// and tag, becomes "_". "alpine:3.20" -> "alpine_3.20.tar" and +// "registry.example.com:5000/team/app:1.0" -> "registry.example.com_5000_team_app_1.0.tar". +func archiveName(image string) string { + var b strings.Builder + for _, r := range image { + switch r { + case ':', '/', '\\', '@', '?', '*', '"', '<', '>', '|', '\x00': + b.WriteByte('_') + default: + b.WriteRune(r) + } + } + return b.String() + ".tar" +} + +// looksLikeImageID reports whether s is an ID (optionally sha256:-prefixed) +// instead of a name. +func looksLikeImageID(s string) bool { + s = normalizeImageID(s) + if len(s) < 4 || len(s) > 64 { + return false + } + for _, r := range s { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +func normalizeImageID(s string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(s, "sha256:"), "sha256")) +} + +// runRuntimeCommand runs one docker command, streaming its output. +func runRuntimeCommand(ctx context.Context, args []string, req BuiltinRequest) error { + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &ExitError{Code: exitErr.ExitCode(), Err: err} + } + return fmt.Errorf("执行 docker 失败: %w", err) + } + return nil +} + +func runtimeError(subcommand string, result diagnose.CommandResult, err error) error { + detail := strings.TrimSpace(string(result.Stderr)) + if detail == "" { + detail = err.Error() + } + return fmt.Errorf("docker %s 失败: %s", subcommand, detail) +} diff --git a/internal/executor/dockersave_test.go b/internal/executor/dockersave_test.go new file mode 100644 index 0000000..de78b33 --- /dev/null +++ b/internal/executor/dockersave_test.go @@ -0,0 +1,243 @@ +//go:build !windows + +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestArchiveName(t *testing.T) { + cases := map[string]string{ + "alpine:3.20": "alpine_3.20.tar", + "alpine": "alpine.tar", + "nginx:1.25.3": "nginx_1.25.3.tar", + "registry.example.com:5000/app:1.0": "registry.example.com_5000_app_1.0.tar", + "team/app:latest": "team_app_latest.tar", + "alpine@sha256:abc": "alpine_sha256_abc.tar", + } + for in, want := range cases { + if got := archiveName(in); got != want { + t.Errorf("archiveName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestLooksLikeImageID(t *testing.T) { + yes := []string{ + "sha256:9c6f07244728", "9c6f07244728", "9C6F07244728", + "sha256:9c6f0724472892f3061e", + } + for _, in := range yes { + if !looksLikeImageID(in) { + t.Errorf("looksLikeImageID(%q) = false, want true", in) + } + } + no := []string{"alpine", "alpine:3.20", "nginx:1.25", "", "abc"} + for _, in := range no { + if looksLikeImageID(in) { + t.Errorf("looksLikeImageID(%q) = true, want false", in) + } + } +} + +// fakeRuntime installs a script called docker on PATH. images is the +// `images --format` payload; every invocation is appended to the returned log +// file as one line. +func fakeRuntime(t *testing.T, images string) (logPath string) { + t.Helper() + dir := t.TempDir() + logPath = filepath.Join(dir, "calls.log") + + script := "#!/bin/sh\n" + + "printf '%s' \"$0\" >> " + logPath + ".name\n" + + "echo \"$*\" >> " + logPath + "\n" + + "case \"$1\" in\n" + + " images) cat <<'EOF'\n" + images + "\nEOF\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 imageList() string { + return strings.Join([]string{ + "IMAGE ID\tREPOSITORY\tTAG\tDIGEST", + "9c6f07244728\talpine\t3.20\t", + "9c6f07244728\talpine\tlatest\t", + "5ca7b1d5e2fc\tnginx\t1.25.3\t", + "1b9a2c3d4e5f\tregistry.example.com:5000/team/app\t1.0\t", + "7d3e5f7a9b1c\t\t\t", + }, "\n") +} + +func calls(t *testing.T, logPath string) []string { + t.Helper() + data, err := os.ReadFile(logPath) + if err != nil { + return nil + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +func TestDockerSaveNamesArchiveAfterTag(t *testing.T) { + logPath := fakeRuntime(t, imageList()) + + var out bytes.Buffer + if err := runDockerSave(context.Background(), BuiltinRequest{ + Args: []string{"alpine:3.20"}, + Stdout: &out, + }); err != nil { + t.Fatalf("save: %v", err) + } + + if !strings.Contains(out.String(), "alpine:3.20") || !strings.Contains(out.String(), "alpine_3.20.tar") { + t.Errorf("summary = %q", out.String()) + } + got := calls(t, logPath) + want := "save -o alpine_3.20.tar alpine:3.20" + if len(got) < 2 || got[1] != want { + t.Fatalf("calls = %q, want a %q invocation", got, want) + } +} + +func TestDockerSaveResolvesImageIDToName(t *testing.T) { + for _, id := range []string{"5ca7b1d5e2fc", "sha256:5ca7b1d5e2fc", "5ca7"} { + logPath := fakeRuntime(t, imageList()) + var out bytes.Buffer + if err := runDockerSave(context.Background(), BuiltinRequest{Args: []string{id}, Stdout: &out}); err != nil { + t.Fatalf("save %q: %v", id, err) + } + got := calls(t, logPath) + want := "save -o nginx_1.25.3.tar " + id + if len(got) < 2 || got[1] != want { + t.Errorf("image %q: calls = %q, want %q", id, got, want) + } + if !strings.Contains(out.String(), "nginx_1.25.3.tar") { + t.Errorf("image %q: summary = %q", id, out.String()) + } + } +} + +func TestDockerSaveRegistryWithPortKeepsName(t *testing.T) { + fakeRuntime(t, imageList()) + var out bytes.Buffer + if err := runDockerSave(context.Background(), BuiltinRequest{ + Args: []string{"registry.example.com:5000/team/app:1.0"}, + Stdout: &out, + }); err != nil { + t.Fatalf("save: %v", err) + } + if !strings.Contains(out.String(), "registry.example.com_5000_team_app_1.0.tar") { + t.Fatalf("summary = %q", out.String()) + } +} + +func TestDockerSaveRejectsUntaggedImage(t *testing.T) { + fakeRuntime(t, imageList()) + err := runDockerSave(context.Background(), BuiltinRequest{Args: []string{"7d3e5f7a9b1c"}}) + if err == nil || !strings.Contains(err.Error(), "没有名称") { + t.Fatalf("err = %v, want untagged-image error", err) + } +} + +func TestDockerSaveRejectsUnknownAndExtraImages(t *testing.T) { + fakeRuntime(t, imageList()) + err := runDockerSave(context.Background(), BuiltinRequest{Args: []string{"busybox:1"}}) + if err == nil || !strings.Contains(err.Error(), "本地没有镜像") { + t.Fatalf("err = %v, want unknown-image error", err) + } + + err = runDockerSave(context.Background(), BuiltinRequest{Args: []string{"alpine:3.20", "nginx:1.25.3"}}) + if err == nil || !strings.Contains(err.Error(), "一次只能保存一个镜像") { + t.Fatalf("err = %v, want single-image error", err) + } + + err = runDockerSave(context.Background(), BuiltinRequest{}) + if err == nil || !strings.Contains(err.Error(), "缺少镜像") { + t.Fatalf("err = %v, want missing-image error", err) + } +} + +func TestDockerSaveForwardsPlatformFlag(t *testing.T) { + logPath := fakeRuntime(t, imageList()) + var out bytes.Buffer + if err := runDockerSave(context.Background(), BuiltinRequest{ + Args: []string{"--platform", "linux/arm64", "alpine:3.20"}, + Stdout: &out, + }); err != nil { + t.Fatalf("save: %v", err) + } + got := calls(t, logPath) + want := "save -o alpine_3.20.tar alpine:3.20 --platform linux/arm64" + if len(got) < 2 || got[1] != want { + t.Fatalf("calls = %q, want %q", got, want) + } +} + +func TestDockerLoadTakesArchiveAsPositional(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "alpine_3.20.tar") + if err := os.WriteFile(archive, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{ + {archive}, + {"-i", archive}, + {"--input=" + archive}, + {"--", archive}, + {archive, "-q"}, + } { + logPath := fakeRuntime(t, "") + if err := runDockerLoad(context.Background(), BuiltinRequest{Args: args}); err != nil { + t.Fatalf("load %v: %v", args, err) + } + got := calls(t, logPath) + joined := strings.Join(got, "|") + if !strings.Contains(joined, "load") || !strings.Contains(joined, "-i "+archive) { + t.Errorf("load %v produced calls %q", args, got) + } + if strings.Contains(joined, "<") { + t.Errorf("load %v must not rely on a shell redirect: %q", args, got) + } + } +} + +func TestDockerLoadRejectsMissingArchive(t *testing.T) { + fakeRuntime(t, "") + for _, args := range [][]string{{}, {"-i"}, {filepath.Join(t.TempDir(), "nope.tar")}, {t.TempDir()}} { + err := runDockerLoad(context.Background(), BuiltinRequest{Args: args}) + if err == nil { + t.Errorf("load %v: expected an error", args) + } + } + if err := runDockerLoad(context.Background(), BuiltinRequest{Args: []string{"missing.tar"}}); err == nil || + !strings.Contains(err.Error(), "无法读取归档文件") { + t.Errorf("missing archive err = %v", err) + } +} + +func TestDockerLoadFromStdin(t *testing.T) { + logPath := fakeRuntime(t, "") + if err := runDockerLoad(context.Background(), BuiltinRequest{Args: []string{"-"}}); err != nil { + t.Fatalf("load -: %v", err) + } + got := calls(t, logPath) + if len(got) != 1 || got[0] != "load" { + t.Fatalf("calls = %q, want a single bare `load`", got) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 81ec262..7e91d1e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -111,6 +111,7 @@ func (p Plan) Run(ctx context.Context, opts Options) error { Stdout: opts.Stdout, Stderr: opts.Stderr, Info: p.Result.Info, + Plan: p, }) } diff --git a/internal/executor/nohup.go b/internal/executor/nohup.go new file mode 100644 index 0000000..e7baea4 --- /dev/null +++ b/internal/executor/nohup.go @@ -0,0 +1,320 @@ +package executor + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// nohupOptions are the options of `kopsc proc nohup`. Their names echo the +// shell idiom they replace: +// +// nohup cmd... > output.log 2>&1 & +type nohupOptions struct { + command []string // argv of the command to launch + outPath string // output file (the shell's "> output.log 2>&1") + dir string // working directory of the child (-C) + env []string // extra NAME=VALUE entries (-E), appended to the env + detach bool // start in a new session, with no controlling terminal + quiet bool // do not print the launch summary + wait bool // stay attached until the command exits +} + +// runNohup implements `nohup <命令> > output.log 2>&1 &`: launch a command in +// the background, detached from the current terminal, with stdout and stderr +// appended to a log file. +// +// A real `nohup ... &` is a shell job: it survives the shell exiting, ignores +// SIGHUP and keeps a process group of its own. kopsc reproduces the parts that +// make that idiom useful and that are portable: +// +// - the child's stdin is closed, so it cannot hold a pipe or terminal open; +// - on Unix its stdout and stderr are appended to the log file, its signal +// handler ignores SIGHUP and (with --detach, the default) it runs in a new +// session with no controlling terminal; +// - on Windows it is started with DETACHED_PROCESS and its inherited handles +// are redirected to the same log file; +// - kopsc returns as soon as the command has started, without waiting. +// +// The child is started with exec.Cmd.Start and never with a shell, so the +// argument vector is passed through verbatim on every platform. +func runNohup(ctx context.Context, req BuiltinRequest) error { + opts, err := parseNohupArgs(req.Args) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + + dir := opts.dir + if dir == "" { + if dir, err = os.Getwd(); err != nil { + return fmt.Errorf("无法获取当前目录: %w", err) + } + } + if resolved, err := filepath.Abs(dir); err == nil { + dir = resolved + } + + name, lookErr := lookPathInDir(opts.command[0], dir) + if lookErr != nil { + return lookErr + } + + logPath := opts.outPath + if logPath == "" { + logPath = defaultLogName(opts.command[0]) + } + if !filepath.IsAbs(logPath) { + logPath = filepath.Join(dir, logPath) + } + + logFile, err := openNohupLog(logPath) + if err != nil { + return err + } + // The child keeps its own duplicate of the descriptor; the parent's copy + // is closed as soon as the process is running. + defer logFile.Close() + + cmd := exec.Command(name, opts.command[1:]...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), opts.env...) + prepareNohup(cmd, logFile, opts.detach) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 %q 失败: %w", opts.command[0], err) + } + + w := req.Stdout + if opts.quiet { + w = io.Discard + } + fmt.Fprintf(w, "已在后台启动: %s\n", strings.Join(opts.command, " ")) + fmt.Fprintf(w, "PID: %d\n", cmd.Process.Pid) + fmt.Fprintf(w, "目录: %s\n", dir) + fmt.Fprintf(w, "日志: %s\n", logPath) + if !opts.detach { + fmt.Fprintf(w, "方式: 后台运行,未脱离当前会话(关闭终端可能终止该命令)\n") + } + fmt.Fprintf(w, "查看: %s\n", nohupFollowHint(logPath)) + if !opts.wait { + // Release the child so the OS reaps it when it exits, independent of + // this (short-lived) kopsc process. + _ = cmd.Process.Release() + return nil + } + + fmt.Fprintf(w, "等待: 前台等待,直到该命令退出\n") + return waitNohup(cmd, logPath, w, req) +} + +// parseNohupArgs parses kopsc-level options and stops at the first argument +// that is neither a recognised option nor an option value: everything from +// there on is the command to run, verbatim (so `kopsc proc nohup ffmpeg -i a` +// passes -i to ffmpeg). An explicit `--` ends option parsing. +func parseNohupArgs(args []string) (nohupOptions, error) { + var opts nohupOptions + if len(args) == 0 { + return opts, errors.New("必须提供要后台运行的命令,例如 kopsc proc nohup ./server > run.log") + } + + i := 0 + for i < len(args) { + arg := args[i] + if arg == "--" { + i++ + break + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + break + } + + var name, value string + if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") { + name, value = arg[:eq], arg[eq+1:] + } else { + name = arg + } + + // takeValue consumes the next argument as this option's value. + takeValue := func(display string) (string, error) { + if value != "" { + return value, nil + } + if i+1 >= len(args) { + return "", fmt.Errorf("%s 缺少参数", display) + } + i++ + return args[i], nil + } + + switch name { + case "-o", "--out", "--output": + v, err := takeValue(name) + if err != nil { + return opts, err + } + opts.outPath = v + case "-C", "--dir", "--chdir": + v, err := takeValue(name) + if err != nil { + return opts, err + } + opts.dir = v + case "-E", "--env": + v, err := takeValue(name) + if err != nil { + return opts, err + } + if !strings.Contains(v, "=") { + return opts, fmt.Errorf("%s 需要 NAME=VALUE 形式,收到 %q", name, v) + } + opts.env = append(opts.env, v) + case "--detach": + b, err := boolFlagValue(name, value) + if err != nil { + return opts, err + } + opts.detach = b + case "--no-detach": + opts.detach = false + case "-q", "--quiet": + opts.quiet = true + case "-w", "--wait": + opts.wait = true + default: + return opts, fmt.Errorf("未知选项 %q;命令自身的选项请写在该命令之后,或用 -- 分隔", name) + } + i++ + } + + if i >= len(args) { + return opts, errors.New("必须提供要后台运行的命令,例如 kopsc proc nohup ./server") + } + opts.command = append([]string(nil), args[i:]...) + return opts, nil +} + +func boolFlagValue(name, value string) (bool, error) { + if value == "" { + return true, nil + } + b, err := parseBool(value) + if err != nil { + return false, fmt.Errorf("%s 需要布尔值,收到 %q", name, value) + } + return b, nil +} + +func parseBool(value string) (bool, error) { + switch strings.ToLower(value) { + case "true", "1", "yes": + return true, nil + case "false", "0", "no": + return false, nil + } + return false, fmt.Errorf("invalid boolean %q", value) +} + +// lookPathInDir resolves the program like the shell would: a name containing a +// path separator is used as given, anything else is searched on PATH. +func lookPathInDir(program, dir string) (string, error) { + if !strings.ContainsAny(program, `/\`) { + name, err := exec.LookPath(program) + if err != nil { + return "", fmt.Errorf("找不到可执行文件 %q: %w", program, err) + } + return name, nil + } + // A path: resolve it like the shell would, relative to the child's dir. + if filepath.IsAbs(program) { + return program, nil + } + name := filepath.Join(dir, program) + if _, err := os.Stat(name); err == nil { + return name, nil + } + return program, nil +} + +// defaultLogName mirrors `nohup.out`: the file `nohup` would otherwise write. +func defaultLogName(program string) string { + return filepath.Base(program) + ".out" +} + +// openNohupLog creates the log file if needed and returns it opened for +// appending, so repeated launches accumulate rather than overwrite — exactly +// like `>>`, and like the redirect idiom when the file already exists. +func openNohupLog(path string) (*os.File, error) { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建日志目录 %q 失败: %w", dir, err) + } + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("打开日志文件 %q 失败: %w", path, err) + } + return f, nil +} + +// nohupFollowHint renders a shell command that follows the log. +func nohupFollowHint(path string) string { + if runtime.GOOS == "windows" { + return "Get-Content -Wait " + quoteForShell(path, "windows") + } + return "tail -f " + quoteForShell(path, runtime.GOOS) +} + +// waitNohup stays attached to a child started with --wait, then relays what it +// appended to the log file so an interactive run still shows the output. +func waitNohup(cmd *exec.Cmd, logPath string, w io.Writer, req BuiltinRequest) error { + offset := fileSize(logPath) + err := cmd.Wait() + relayLog(logPath, offset, w, req) + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return &ExitError{Code: exitErr.ExitCode(), Err: err} + } + return err + } + return nil +} + +func fileSize(path string) int64 { + if info, err := os.Stat(path); err == nil { + return info.Size() + } + return 0 +} + +// relayLog copies everything written to the log after offset to w and stderr. +func relayLog(path string, offset int64, w io.Writer, req BuiltinRequest) { + if w != nil { + copyLogTail(path, offset, w) + } + if req.Stderr != nil && req.Stderr != w { + copyLogTail(path, offset, req.Stderr) + } +} + +func copyLogTail(path string, offset int64, w io.Writer) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return + } + _, _ = io.Copy(w, f) +} diff --git a/internal/executor/nohup_test.go b/internal/executor/nohup_test.go new file mode 100644 index 0000000..811f91e --- /dev/null +++ b/internal/executor/nohup_test.go @@ -0,0 +1,234 @@ +package executor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestParseNohupArgsSplitsOptionsFromCommand(t *testing.T) { + tests := []struct { + name string + args []string + want nohupOptions + }{ + { + name: "command only", + args: []string{"./server", "-port", "8080"}, + want: nohupOptions{command: []string{"./server", "-port", "8080"}}, + }, + { + name: "all options", + args: []string{"-o", "a.log", "-C", "/var/app", "-E", "K=V", "--detach", "-q", "-w", "./run.sh", "x"}, + want: nohupOptions{ + command: []string{"./run.sh", "x"}, + outPath: "a.log", + dir: "/var/app", + env: []string{"K=V"}, + detach: true, + quiet: true, + wait: true, + }, + }, + { + name: "equals forms", + args: []string{"--output=out.log", "--dir=/tmp", "--env=A=1", "--detach=false", "sleep", "1"}, + want: nohupOptions{command: []string{"sleep", "1"}, outPath: "out.log", dir: "/tmp", env: []string{"A=1"}}, + }, + { + name: "no-detach wins when last", + args: []string{"--detach", "--no-detach", "cmd"}, + want: nohupOptions{command: []string{"cmd"}}, + }, + { + name: "command flags are not parsed", + args: []string{"ffmpeg", "-o", "out.mp4", "--wait", "-i", "in.mp4"}, + want: nohupOptions{command: []string{"ffmpeg", "-o", "out.mp4", "--wait", "-i", "in.mp4"}}, + }, + { + name: "double dash ends options", + args: []string{"-o", "x.log", "--", "-weird-name", "--detach"}, + want: nohupOptions{outPath: "x.log", command: []string{"-weird-name", "--detach"}}, + }, + } + for _, tc := range tests { + got, err := parseNohupArgs(tc.args) + if err != nil { + t.Errorf("%s: unexpected error %v", tc.name, err) + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: got %+v, want %+v", tc.name, got, tc.want) + } + } +} + +func TestParseNohupArgsRejectsBadInput(t *testing.T) { + tests := []struct { + args []string + want string + }{ + {nil, "必须提供要后台运行的命令"}, + {[]string{"-o"}, "-o 缺少参数"}, + {[]string{"-o", "a.log"}, "必须提供要后台运行的命令"}, + {[]string{"-E", "NOEQUALS", "cmd"}, "NAME=VALUE"}, + {[]string{"--detach=maybe", "cmd"}, "需要布尔值"}, + {[]string{"--unknown", "cmd"}, "未知选项"}, + } + for _, tc := range tests { + _, err := parseNohupArgs(tc.args) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want) + } + } +} + +func TestNohupRunsDetachedAndAppendsLog(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "output.log") + + // First launch writes the log. + if _, err := runNohupForTest(t, dir, "-o", logPath, "true"); err != nil { + t.Fatalf("first launch: %v", err) + } + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("first launch did not create %s: %v", logPath, err) + } + if data, _ := os.ReadFile(logPath); len(data) != 0 { + t.Fatalf("expected empty log from true, got %q", data) + } + + // Second launch appends instead of truncating. + if _, err := runNohupForTest(t, dir, "-o", logPath, "echo", "second"); err != nil { + t.Fatalf("second launch: %v", err) + } + waitForFile(t, logPath, "second") + if got := readFileString(logPath); got != "second\n" { + t.Fatalf("log = %q, want %q", got, "second\n") + } +} + +func TestNohupCapturesStderrIntoLog(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "err.log") + + out, err := runNohupForTest(t, dir, "-o", logPath, "-w", "sh", "-c", "echo out; echo err >&2") + if err != nil { + t.Fatalf("launch: %v", err) + } + log := readFileString(logPath) + if !strings.Contains(log, "out") || !strings.Contains(log, "err") { + t.Fatalf("log = %q, want both stdout and stderr", log) + } + // --wait relays the same content to the caller's streams. + if !strings.Contains(out, "out") || !strings.Contains(out, "err") { + t.Fatalf("relayed output = %q", out) + } + if !strings.Contains(out, "退出") { + t.Fatalf("summary missing wait note: %q", out) + } +} + +func TestNohupWaitPropagatesExitCode(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "exit.log") + + _, err := runNohupForTest(t, dir, "-o", logPath, "-w", "sh", "-c", "exit 7") + if err == nil { + t.Fatal("expected an exit error from --wait") + } + if code := ExitCode(err); code != 7 { + t.Fatalf("ExitCode(err) = %d, want 7", code) + } +} + +func TestNohupUsesDirEnvAndDefaultLog(t *testing.T) { + dir := t.TempDir() + + // The default log lives in the working directory and is named after the + // program, like nohup.out. + if _, err := runNohupForTest(t, dir, "-C", dir, "-E", "KOPSC_TEST_VALUE=xyz", "-w", + "sh", "-c", `printf "%s %s\n" "$(pwd)" "$KOPSC_TEST_VALUE"`); err != nil { + t.Fatalf("launch: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var logName string + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".out") { + logName = entry.Name() + } + } + if logName != "sh.out" { + t.Fatalf("expected default log sh.out, got %q", logName) + } + log := readFileString(filepath.Join(dir, logName)) + if !strings.Contains(log, "KOPSC_TEST_VALUE") || !strings.Contains(log, dir) { + // pwd of a symlinked temp dir (macOS /var -> /private/var) differs. + if !strings.Contains(log, "xyz") { + t.Fatalf("log = %q, want the env value and working directory", log) + } + } +} + +func TestNohupQuietHidesSummary(t *testing.T) { + dir := t.TempDir() + out, err := runNohupForTest(t, dir, "-q", "-o", filepath.Join(dir, "q.log"), "true") + if err != nil { + t.Fatalf("launch: %v", err) + } + if out != "" { + t.Fatalf("quiet launch printed %q", out) + } +} + +func TestNohupReportsMissingProgram(t *testing.T) { + _, err := runNohupForTest(t, t.TempDir(), "kopsc-definitely-not-a-program") + if err == nil || !strings.Contains(err.Error(), "找不到可执行文件") { + t.Fatalf("err = %v, want missing-program error", err) + } +} + +// runNohupForTest runs the builtin with a fresh context and returns stdout. +func runNohupForTest(t *testing.T, dir string, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + // The builtin reads the *current* directory when -C is absent, and the + // tests that check the default log name rely on that. + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(old) }() + + err = runNohup(context.Background(), BuiltinRequest{Args: args, Stdout: &out, Stderr: &out}) + return out.String(), err +} + +func readFileString(path string) string { + data, _ := os.ReadFile(path) + return string(data) +} + +// waitForFile polls until path contains want (the child writes asynchronously). +func waitForFile(t *testing.T, path, want string) { + t.Helper() + for i := 0; i < 200; i++ { + if strings.Contains(readFileString(path), want) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("%s never contained %q (content %q)", path, want, readFileString(path)) +} diff --git a/internal/executor/nohup_unix.go b/internal/executor/nohup_unix.go new file mode 100644 index 0000000..ba14f7e --- /dev/null +++ b/internal/executor/nohup_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package executor + +import ( + "os" + "os/exec" + "syscall" +) + +// prepareNohup applies the Unix half of `nohup ... &`: the child's stdin is +// closed, stdout and stderr are appended to logFile ("> output.log 2>&1") and, +// with detach, the child becomes a session leader with no controlling terminal +// — which also shields it from terminal-generated SIGHUP/SIGINT/SIGQUIT. +// +// Go's os/exec cannot reset a child's signal dispositions before exec (there is +// no hook between fork and exec), so "ignore SIGHUP" itself is only provided by +// the detached session, not by a signal handler. +func prepareNohup(cmd *exec.Cmd, logFile *os.File, detach bool) { + // A nil stdin hands the child /dev/null instead of the terminal. + cmd.Stdout = logFile + cmd.Stderr = logFile + + if detach { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + } +} diff --git a/internal/executor/nohup_windows.go b/internal/executor/nohup_windows.go new file mode 100644 index 0000000..2634af7 --- /dev/null +++ b/internal/executor/nohup_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +package executor + +import ( + "os" + "os/exec" + "syscall" +) + +// prepareNohup applies the Windows half of `nohup ... &`: the child's stdin is +// connected to NUL and its stdout and stderr are appended to logFile +// ("> output.log 2>&1"). +// +// Windows has no SIGHUP; what the shell's `nohup ... &` buys is independence +// from the launching console, which DETACHED_PROCESS provides: the child gets +// no console at all, so closing the terminal kopsc ran in does not close the +// child either. With detach disabled the child shares kopsc's console. +func prepareNohup(cmd *exec.Cmd, logFile *os.File, detach bool) { + cmd.Stdout = logFile + cmd.Stderr = logFile + + if detach { + // DETACHED_PROCESS: start without a console, and do not inherit one. + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess, + } + } +} + +// DETACHED_PROCESS (0x00000008) is not exported by package syscall. +const detachedProcess = 0x00000008 diff --git a/internal/platform/platform.go b/internal/platform/platform.go index d00581d..ac2bca2 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -21,7 +21,7 @@ type Info struct { Arch string // runtime.GOARCH, e.g. "amd64", "arm64" Distro string // lower-case os-release ID, empty when unknown DistroLike []string // lower-case os-release ID_LIKE entries - Runtimes []string // container runtimes found on PATH, e.g. "docker", "podman" + Runtimes []string // container runtimes found on PATH, e.g. "docker" } // Probe gathers Info. Its dependencies are injectable so detection can be @@ -41,7 +41,7 @@ func System() Probe { GOARCH: runtime.GOARCH, ReadFile: os.ReadFile, LookPath: exec.LookPath, - RuntimeCandidates: []string{"docker", "podman"}, + RuntimeCandidates: []string{"docker"}, } } @@ -59,7 +59,7 @@ func (p Probe) Detect() Info { candidates := p.RuntimeCandidates if candidates == nil { - candidates = []string{"docker", "podman"} + candidates = []string{"docker"} } if p.LookPath != nil { for _, name := range candidates { diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index fddd727..a4e8788 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -77,18 +77,6 @@ func TestDetect(t *testing.T) { Runtimes: []string{"docker"}, }, }, - { - name: "linux centos with podman and docker", - goos: "linux", - goarch: "arm64", - osRelease: centosStream9, - present: []string{"docker", "podman"}, - want: Info{ - OS: "linux", Arch: "arm64", - Distro: "centos", DistroLike: []string{"rhel", "fedora"}, - Runtimes: []string{"docker", "podman"}, - }, - }, { name: "linux without os-release", goos: "linux", @@ -133,7 +121,7 @@ func TestDetect(t *testing.T) { } return "", errors.New("not found") }, - RuntimeCandidates: []string{"docker", "podman"}, + RuntimeCandidates: []string{"docker"}, } got := probe.Detect() diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go index 8d83644..db76892 100644 --- a/internal/registry/builtin.go +++ b/internal/registry/builtin.go @@ -161,20 +161,26 @@ func Builtin() *Registry { Variants: runtimeVariants("tag"), }, { - ID: "docker.save", - Group: "docker", - Summary: "把镜像导出为归档文件", - Action: "等价 docker save;通常配合 -o 透传输出文件", - Usage: "<镜像>", + ID: "docker.save", + Group: "docker", + Summary: "把镜像导出为 <镜像名>.tar", + Action: "等价 docker save <镜像> -o <镜像名>.tar:归档文件按镜像的仓库名与标签命名," + + "标签的冒号改写为下划线(alpine:3.20 → alpine_3.20.tar,registry:5000/team/app:1.0 → registry_5000_team_app_1.0.tar);" + + "传入镜像 ID 时先解析出它对应的镜像名称,再据此命名;未打标签的镜像直接报错。" + + "--platform 等选项原样透传给底层运行时", + Usage: "<镜像名|镜像ID>", MinArgs: 1, - Variants: runtimeVariants("save"), + Variants: runtimeBuiltinVariants("save"), }, { - ID: "docker.load", - Group: "docker", - Summary: "从归档文件导入镜像", - Action: "等价 docker load;通常配合 -i 透传输入文件", - Variants: runtimeVariants("load"), + ID: "docker.load", + Group: "docker", + Summary: "从 <归档.tar> 导入镜像", + Action: "等价 docker load -i <归档.tar>:归档文件作为位置参数(对应原本的 docker load < xxx.tar)," + + "可一次给出多个;仍可用 -i/--input 指定,或传 - 表示从标准输入读取", + Usage: "<归档.tar>...", + MinArgs: 1, + Variants: runtimeBuiltinVariants("load"), }, { ID: "docker.cp", @@ -275,7 +281,7 @@ func Builtin() *Registry { ID: "diagnose", Group: "diagnose", Summary: "诊断容器或 Pod 当前状态的根因", - Action: "自动识别 Docker/Podman 容器或 Kubernetes Pod,采集状态、最近日志与事件,给出原因、依据与建议;只读,不修改任何对象。--kind 可强制来源,K8s 下 -n/--namespace 指定命名空间", + Action: "自动识别 Docker 容器或 Kubernetes Pod,采集状态、最近日志与事件,给出原因、依据与建议;只读,不修改任何对象。--kind 可强制来源,K8s 下 -n/--namespace 指定命名空间", Usage: "<容器名或 Pod 名>", MinArgs: 1, Namespaced: true, @@ -283,7 +289,7 @@ func Builtin() *Registry { { Match: Match{OS: "linux"}, Builtin: "diagnose", - Note: "Linux 内置诊断(docker/podman 与 kubectl 自动识别)", + Note: "Linux 内置诊断(docker 与 kubectl 自动识别)", }, { Match: Match{OS: "darwin"}, @@ -372,6 +378,35 @@ func Builtin() *Registry { }, }, }, + { + ID: "proc.nohup", + Group: "proc", + Summary: "在后台运行命令,输出写入日志(等价 nohup <命令> > output.log 2>&1 &)", + Action: "启动一个与当前终端脱离的命令并立即返回:标准输入接到空设备," + + "标准输出与标准错误都追加写入日志文件(默认 <程序名>.out,可用 -o 指定)," + + "Unix 下进入新会话、无控制终端,Windows 下以 DETACHED_PROCESS 启动,关闭终端不会终止它。" + + "选项在命令之前解析,命令之后的参数(含 -i 之类)原样透传,不加 shell;" + + "命令自身的 -n/--dry-run 不会被 kopsc 截获;kopsc 自身的预览等选项写在命令之前或用 -- 分隔", + Usage: "<命令> [参数...]", + MinArgs: 1, + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Builtin: "proc.nohup", + Note: "Linux 内置实现(setsid 新会话 + 日志追加)", + }, + { + Match: Match{OS: "darwin"}, + Builtin: "proc.nohup", + Note: "macOS 内置实现(setsid 新会话 + 日志追加)", + }, + { + Match: Match{OS: "windows"}, + Builtin: "proc.nohup", + Note: "Windows 内置实现(DETACHED_PROCESS + 日志追加)", + }, + }, + }, { ID: "pkg.refreshindex", Group: "pkg", @@ -413,32 +448,42 @@ func Builtin() *Registry { }} } -// runtimeVariants builds the platform matrix for a container command. Linux -// gets both podman and docker variants (resolution prefers podman when both -// exist); macOS and Windows only have Docker Desktop. -func runtimeVariants(args ...string) []Variant { - program := func(runtime string) string { - if runtime == "podman" { - return "podman" +// runtimeBuiltinVariants builds the platform matrix for a container command +// that kopsc implements itself. Builtin variants carry no argv; the builtin +// learns the runtime's program name from Variant.Runtime instead. +func runtimeBuiltinVariants(name string) []Variant { + makeVariant := func(osName, note string) Variant { + return Variant{ + Match: Match{OS: osName, Runtime: "docker"}, + Builtin: "docker." + name, + Runtime: "docker", + Note: note, } - return "docker" } + return []Variant{ + makeVariant("linux", "Linux 上的 Docker 运行时,由 kopsc 内置实现"), + makeVariant("darwin", "macOS 上的 Docker Desktop 运行时,由 kopsc 内置实现"), + makeVariant("windows", "Windows 上的 Docker Desktop 运行时,由 kopsc 内置实现"), + } +} - makeVariant := func(osName, runtime, note string) Variant { +// runtimeVariants builds the platform matrix for a container command: docker +// on every platform (Docker Desktop on macOS and Windows). +func runtimeVariants(args ...string) []Variant { + makeVariant := func(osName, note string) Variant { argv := make([]string, 0, 1+len(args)) - argv = append(argv, program(runtime)) + argv = append(argv, "docker") argv = append(argv, args...) return Variant{ - Match: Match{OS: osName, Runtime: runtime}, + Match: Match{OS: osName, Runtime: "docker"}, Argv: argv, Note: note, } } return []Variant{ - makeVariant("linux", "podman", "Linux 上的 Podman 运行时"), - makeVariant("linux", "docker", "Linux 上的 Docker 运行时"), - makeVariant("darwin", "docker", "macOS 上的 Docker Desktop 运行时"), - makeVariant("windows", "docker", "Windows 上的 Docker Desktop 运行时"), + makeVariant("linux", "Linux 上的 Docker 运行时"), + makeVariant("darwin", "macOS 上的 Docker Desktop 运行时"), + makeVariant("windows", "Windows 上的 Docker Desktop 运行时"), } } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 54003f0..acfc746 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -14,7 +14,7 @@ type Match struct { Arch string // runtime.GOARCH Distro string // exact os-release ID, e.g. "centos" DistroLike []string // os-release ID_LIKE ancestry, e.g. "rhel" - Runtime string // container runtime required on PATH, e.g. "podman" + Runtime string // container runtime required on PATH, e.g. "docker" } // Variant is one concrete implementation of a command. @@ -27,6 +27,10 @@ type Variant struct { // have no equivalent system utility (for example on Windows). Builtin string Note string // human-readable rationale shown by `kopsc explain` + // Runtime records which container runtime a builtin should drive when + // Argv cannot carry it (builtin variants have no argv). Resolution uses + // Match.Runtime; this field is only informational context for the builtin. + Runtime string } // Command is a platform-neutral intent, e.g. "docker.clearsafe". diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 35b7c61..62bff6d 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) { "docker.prunebuilder", "docker.prunevolumes", "docker.clearall", "docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs", "diagnose", - "disk.usage", "net.pids", "net.ports", "pkg.refreshindex", + "disk.usage", "net.pids", "net.ports", "proc.nohup", "pkg.refreshindex", } if got := reg.IDs(); !reflect.DeepEqual(got, want) { t.Fatalf("IDs() =\n%v\nwant\n%v", got, want) @@ -38,12 +38,12 @@ func TestBuiltinClearsafeIsSafe(t *testing.T) { if !ok { t.Fatal("docker.clearsafe not found") } - if len(cmd.Variants) < 4 { + if len(cmd.Variants) < 3 { t.Fatalf("expected a variant per platform, got %d", len(cmd.Variants)) } for _, variant := range cmd.Variants { - if len(variant.Argv) == 0 || (variant.Argv[0] != "docker" && variant.Argv[0] != "podman") { + if len(variant.Argv) == 0 || variant.Argv[0] != "docker" { t.Errorf("unexpected program %v", variant.Argv) } if strings.Join(variant.Argv, " ") != variant.Argv[0]+" system prune -f" { @@ -182,6 +182,32 @@ func TestDiskUsageIsBuiltinEverywhere(t *testing.T) { } } +func TestProcNohupIsBuiltinEverywhere(t *testing.T) { + reg := Builtin() + cmd, ok := reg.Lookup("proc.nohup") + if !ok { + t.Fatal("proc.nohup not found") + } + if cmd.RequiresYes { + t.Error("proc.nohup is not destructive and must not require --yes") + } + if cmd.MinArgs != 1 || cmd.Usage == "" { + t.Errorf("proc.nohup must require the command to run, got MinArgs=%d Usage=%q", cmd.MinArgs, cmd.Usage) + } + seen := map[string]bool{} + for _, variant := range cmd.Variants { + if variant.Builtin != "proc.nohup" || len(variant.Argv) != 0 || variant.Shell { + t.Errorf("variant for %s must be the proc.nohup builtin, got %+v", variant.Match.OS, variant) + } + seen[variant.Match.OS] = true + } + for _, osName := range []string{"linux", "darwin", "windows"} { + if !seen[osName] { + t.Errorf("proc.nohup lacks a %s variant", osName) + } + } +} + func TestDockerCommandsWellFormed(t *testing.T) { reg := Builtin() destructive := map[string]bool{ @@ -200,8 +226,14 @@ func TestDockerCommandsWellFormed(t *testing.T) { "docker.pull": 1, "docker.tag": 2, "docker.save": 1, + "docker.load": 1, "docker.cp": 2, } + // Built from kopsc's own implementation rather than a fixed argv. + builtin := map[string]string{ + "docker.save": "docker.save", + "docker.load": "docker.load", + } for i := range reg.Commands { cmd := ®.Commands[i] @@ -219,24 +251,36 @@ func TestDockerCommandsWellFormed(t *testing.T) { t.Errorf("%s: missing Usage for MinArgs=%d", cmd.ID, cmd.MinArgs) } - var podman, docker int + platforms := map[string]bool{} for _, v := range cmd.Variants { + if want, ok := builtin[cmd.ID]; ok { + if v.Builtin != want || len(v.Argv) != 0 { + t.Errorf("%s: builtin variant = %q with argv %v, want builtin %q", + cmd.ID, v.Builtin, v.Argv, want) + continue + } + if v.Runtime != v.Match.Runtime || v.Runtime != "docker" { + t.Errorf("%s: variant runtime %q / match %q, want docker", cmd.ID, v.Runtime, v.Match.Runtime) + } + platforms[v.Match.OS] = true + continue + } if len(v.Argv) == 0 { t.Errorf("%s: docker command must have an argv variant", cmd.ID) continue } - switch v.Argv[0] { - case "podman": - podman++ - case "docker": - docker++ + if v.Argv[0] != "docker" { + t.Errorf("%s: unexpected program %q", cmd.ID, v.Argv[0]) } - if v.Match.Runtime != v.Argv[0] { - t.Errorf("%s: runtime %q does not match program %q", cmd.ID, v.Match.Runtime, v.Argv[0]) + if v.Match.Runtime != "docker" { + t.Errorf("%s: runtime %q, want docker", cmd.ID, v.Match.Runtime) } + platforms[v.Match.OS] = true } - if podman != 1 || docker != 3 { - t.Errorf("%s: variants = %d podman / %d docker, want 1/3", cmd.ID, podman, docker) + for _, osName := range []string{"linux", "darwin", "windows"} { + if !platforms[osName] { + t.Errorf("%s: missing %s variant", cmd.ID, osName) + } } } } diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index de20aab..07c8d3e 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -188,18 +188,16 @@ func Evaluate(m registry.Match, info platform.Info) (score int, matched bool, re } // runtimeRank orders runtimes by preference when two variants are otherwise -// equally specific: podman beats docker because it is the rootless default on -// CentOS-family hosts, and a variant with no runtime constraint ranks last. +// equally specific: docker ranks first and a variant with no runtime +// constraint ranks last. func runtimeRank(runtime string) int { switch strings.ToLower(runtime) { - case "podman": - return 0 case "docker": - return 1 + return 0 case "": - return 3 - default: return 2 + default: + return 1 } } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index b61c807..9c5791d 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -39,26 +39,18 @@ func TestResolveBuiltin(t *testing.T) { wantIdx int }{ { - name: "centos with podman and docker prefers podman", - id: "docker.clearsafe", - info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker", "podman"), - wantArgv: []string{"podman", "system", "prune", "-f"}, - wantScore: 2, - wantIdx: 0, - }, - { - name: "centos with docker only", + name: "centos with docker", id: "docker.clearsafe", info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 1, + wantIdx: 0, }, { - name: "generic linux with podman and docker prefers podman", + name: "generic linux with docker", id: "docker.clearsafe", - info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker", "podman"), - wantArgv: []string{"podman", "system", "prune", "-f"}, + info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker"), + wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, wantIdx: 0, }, @@ -68,7 +60,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 1, + wantIdx: 0, }, { name: "darwin docker", @@ -76,7 +68,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("darwin", "arm64", "", nil, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 2, + wantIdx: 1, }, { name: "windows docker", @@ -84,7 +76,7 @@ func TestResolveBuiltin(t *testing.T) { info: env("windows", "amd64", "", nil, "docker"), wantArgv: []string{"docker", "system", "prune", "-f"}, wantScore: 2, - wantIdx: 3, + wantIdx: 2, }, { name: "centos refreshes with dnf exactly", @@ -277,7 +269,7 @@ func TestEvaluateRejects(t *testing.T) { {"arch", registry.Match{Arch: "arm64"}, "arch=arm64"}, {"distro", registry.Match{Distro: "debian"}, "distro=debian"}, {"distro like", registry.Match{DistroLike: []string{"debian"}}, "ID_LIKE"}, - {"runtime", registry.Match{Runtime: "podman"}, "podman"}, + {"runtime", registry.Match{Runtime: "cri-o"}, "cri-o"}, } for _, tc := range cases {