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
+66 -15
View File
@@ -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)
```
+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())
}
}
+1 -1
View File
@@ -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
+6 -8
View File
@@ -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.
+10 -3
View File
@@ -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.
+324
View File
@@ -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]. "<none>" is how both
// runtimes group untagged images.
func formatImageName(repo, tag, digest string) string {
if repo == "<none>" {
return ""
}
name := repo
if tag != "" && tag != "<none>" {
name += ":" + tag
}
if digest != "" && digest != "<none>" {
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)
}
+243
View File
@@ -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<none>\t<none>\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)
}
}
+1
View File
@@ -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,
})
}
+320
View File
@@ -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)
}
+234
View File
@@ -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))
}
+27
View File
@@ -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}
}
}
+32
View File
@@ -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
+3 -3
View File
@@ -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 {
+1 -13
View File
@@ -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()
+73 -28
View File
@@ -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 运行时"),
}
}
+5 -1
View File
@@ -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".
+57 -13
View File
@@ -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 := &reg.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)
}
}
}
}
+5 -7
View File
@@ -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
}
}
+9 -17
View File
@@ -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 {