- 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
321 lines
9.0 KiB
Go
321 lines
9.0 KiB
Go
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)
|
|
}
|