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

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