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
+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