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:
@@ -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
|
||||
Reference in New Issue
Block a user