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:
+134
-11
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 运行时"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) {
|
||||
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
|
||||
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
||||
"diagnose",
|
||||
"disk.usage", "net.pids", "net.ports", "pkg.refreshindex",
|
||||
"disk.usage", "net.pids", "net.ports", "proc.nohup", "pkg.refreshindex",
|
||||
}
|
||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
||||
@@ -38,12 +38,12 @@ func TestBuiltinClearsafeIsSafe(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("docker.clearsafe not found")
|
||||
}
|
||||
if len(cmd.Variants) < 4 {
|
||||
if len(cmd.Variants) < 3 {
|
||||
t.Fatalf("expected a variant per platform, got %d", len(cmd.Variants))
|
||||
}
|
||||
|
||||
for _, variant := range cmd.Variants {
|
||||
if len(variant.Argv) == 0 || (variant.Argv[0] != "docker" && variant.Argv[0] != "podman") {
|
||||
if len(variant.Argv) == 0 || variant.Argv[0] != "docker" {
|
||||
t.Errorf("unexpected program %v", variant.Argv)
|
||||
}
|
||||
if strings.Join(variant.Argv, " ") != variant.Argv[0]+" system prune -f" {
|
||||
@@ -182,6 +182,32 @@ func TestDiskUsageIsBuiltinEverywhere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcNohupIsBuiltinEverywhere(t *testing.T) {
|
||||
reg := Builtin()
|
||||
cmd, ok := reg.Lookup("proc.nohup")
|
||||
if !ok {
|
||||
t.Fatal("proc.nohup not found")
|
||||
}
|
||||
if cmd.RequiresYes {
|
||||
t.Error("proc.nohup is not destructive and must not require --yes")
|
||||
}
|
||||
if cmd.MinArgs != 1 || cmd.Usage == "" {
|
||||
t.Errorf("proc.nohup must require the command to run, got MinArgs=%d Usage=%q", cmd.MinArgs, cmd.Usage)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, variant := range cmd.Variants {
|
||||
if variant.Builtin != "proc.nohup" || len(variant.Argv) != 0 || variant.Shell {
|
||||
t.Errorf("variant for %s must be the proc.nohup builtin, got %+v", variant.Match.OS, variant)
|
||||
}
|
||||
seen[variant.Match.OS] = true
|
||||
}
|
||||
for _, osName := range []string{"linux", "darwin", "windows"} {
|
||||
if !seen[osName] {
|
||||
t.Errorf("proc.nohup lacks a %s variant", osName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerCommandsWellFormed(t *testing.T) {
|
||||
reg := Builtin()
|
||||
destructive := map[string]bool{
|
||||
@@ -200,8 +226,14 @@ func TestDockerCommandsWellFormed(t *testing.T) {
|
||||
"docker.pull": 1,
|
||||
"docker.tag": 2,
|
||||
"docker.save": 1,
|
||||
"docker.load": 1,
|
||||
"docker.cp": 2,
|
||||
}
|
||||
// Built from kopsc's own implementation rather than a fixed argv.
|
||||
builtin := map[string]string{
|
||||
"docker.save": "docker.save",
|
||||
"docker.load": "docker.load",
|
||||
}
|
||||
|
||||
for i := range reg.Commands {
|
||||
cmd := ®.Commands[i]
|
||||
@@ -219,24 +251,36 @@ func TestDockerCommandsWellFormed(t *testing.T) {
|
||||
t.Errorf("%s: missing Usage for MinArgs=%d", cmd.ID, cmd.MinArgs)
|
||||
}
|
||||
|
||||
var podman, docker int
|
||||
platforms := map[string]bool{}
|
||||
for _, v := range cmd.Variants {
|
||||
if want, ok := builtin[cmd.ID]; ok {
|
||||
if v.Builtin != want || len(v.Argv) != 0 {
|
||||
t.Errorf("%s: builtin variant = %q with argv %v, want builtin %q",
|
||||
cmd.ID, v.Builtin, v.Argv, want)
|
||||
continue
|
||||
}
|
||||
if v.Runtime != v.Match.Runtime || v.Runtime != "docker" {
|
||||
t.Errorf("%s: variant runtime %q / match %q, want docker", cmd.ID, v.Runtime, v.Match.Runtime)
|
||||
}
|
||||
platforms[v.Match.OS] = true
|
||||
continue
|
||||
}
|
||||
if len(v.Argv) == 0 {
|
||||
t.Errorf("%s: docker command must have an argv variant", cmd.ID)
|
||||
continue
|
||||
}
|
||||
switch v.Argv[0] {
|
||||
case "podman":
|
||||
podman++
|
||||
case "docker":
|
||||
docker++
|
||||
if v.Argv[0] != "docker" {
|
||||
t.Errorf("%s: unexpected program %q", cmd.ID, v.Argv[0])
|
||||
}
|
||||
if v.Match.Runtime != v.Argv[0] {
|
||||
t.Errorf("%s: runtime %q does not match program %q", cmd.ID, v.Match.Runtime, v.Argv[0])
|
||||
if v.Match.Runtime != "docker" {
|
||||
t.Errorf("%s: runtime %q, want docker", cmd.ID, v.Match.Runtime)
|
||||
}
|
||||
platforms[v.Match.OS] = true
|
||||
}
|
||||
if podman != 1 || docker != 3 {
|
||||
t.Errorf("%s: variants = %d podman / %d docker, want 1/3", cmd.ID, podman, docker)
|
||||
for _, osName := range []string{"linux", "darwin", "windows"} {
|
||||
if !platforms[osName] {
|
||||
t.Errorf("%s: missing %s variant", cmd.ID, osName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user