Files
odboy bd6f20d7c5 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
2026-11-01 07:47:57 +08:00

235 lines
6.7 KiB
Go

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))
}