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