go-infra/internal/coreexec/coreexec.go

219 lines
5.2 KiB
Go
Raw Permalink Normal View History

package coreexec
import (
"context"
"syscall"
core "dappco.re/go/core"
)
var localFS = (&core.Fs{}).NewUnrestricted()
const executeAccess = 1
// Result captures process output and exit status.
// Usage: result, err := coreexec.Run(ctx, "git", "status", "--short")
type Result struct {
Stdout string
Stderr string
ExitCode int
}
// LookPath resolves an executable name against PATH.
// Usage: path, err := coreexec.LookPath("gh")
func LookPath(name string) (string, error) {
if name == "" {
return "", core.E("coreexec.LookPath", "empty executable name", nil)
}
ds := core.Env("DS")
if core.PathIsAbs(name) || core.Contains(name, ds) {
if isExecutable(name) {
return name, nil
}
return "", core.E("coreexec.LookPath", core.Concat("executable not found: ", name), nil)
}
for _, dir := range core.Split(core.Env("PATH"), core.Env("PS")) {
if dir == "" {
dir = core.Env("DIR_CWD")
} else if !core.PathIsAbs(dir) {
dir = core.Path(core.Env("DIR_CWD"), dir)
}
candidate := core.Path(dir, name)
if isExecutable(candidate) {
return candidate, nil
}
}
return "", core.E("coreexec.LookPath", core.Concat("executable not found in PATH: ", name), nil)
}
// Run executes a command and captures stdout, stderr, and exit status.
// Usage: result, err := coreexec.Run(ctx, "gh", "api", "repos/org/repo")
func Run(ctx context.Context, name string, args ...string) (Result, error) {
path, err := LookPath(name)
if err != nil {
return Result{}, err
}
tempDir := localFS.TempDir("coreexec-")
if tempDir == "" {
return Result{}, core.E("coreexec.Run", "create capture directory", nil)
}
defer func() { _ = coreResultErr(localFS.DeleteAll(tempDir), "coreexec.Run") }()
stdoutPath := core.Path(tempDir, "stdout")
stderrPath := core.Path(tempDir, "stderr")
stdoutFile, err := createFile(stdoutPath)
if err != nil {
return Result{}, err
}
defer func() { _ = stdoutFile.Close() }()
stderrFile, err := createFile(stderrPath)
if err != nil {
return Result{}, err
}
defer func() { _ = stderrFile.Close() }()
pid, err := syscall.ForkExec(path, append([]string{name}, args...), &syscall.ProcAttr{
Dir: core.Env("DIR_CWD"),
Env: syscall.Environ(),
Files: []uintptr{
0,
stdoutFile.Fd(),
stderrFile.Fd(),
},
})
if err != nil {
return Result{}, core.E("coreexec.Run", core.Concat("start ", name), err)
}
status, err := waitForPID(ctx, pid, name)
if err != nil {
return Result{}, err
}
stdout, err := readFile(stdoutPath)
if err != nil {
return Result{}, err
}
stderr, err := readFile(stderrPath)
if err != nil {
return Result{}, err
}
return Result{
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode(status),
}, nil
}
// Exec replaces the current process with the named executable.
// Usage: return coreexec.Exec("ssh", "-i", keyPath, host)
func Exec(name string, args ...string) error {
path, err := LookPath(name)
if err != nil {
return err
}
if err := syscall.Exec(path, append([]string{name}, args...), syscall.Environ()); err != nil {
return core.E("coreexec.Exec", core.Concat("exec ", name), err)
}
return nil
}
type captureFile interface {
Close() error
Fd() uintptr
}
type waitResult struct {
status syscall.WaitStatus
err error
}
func isExecutable(path string) bool {
if !localFS.IsFile(path) {
return false
}
return syscall.Access(path, executeAccess) == nil
}
func createFile(path string) (captureFile, error) {
created := localFS.Create(path)
if !created.OK {
return nil, core.E("coreexec.Run", core.Concat("create ", path), coreResultErr(created, "coreexec.Run"))
}
file, ok := created.Value.(captureFile)
if !ok {
return nil, core.E("coreexec.Run", core.Concat("capture handle type for ", path), nil)
}
return file, nil
}
func readFile(path string) (string, error) {
read := localFS.Read(path)
if !read.OK {
return "", core.E("coreexec.Run", core.Concat("read ", path), coreResultErr(read, "coreexec.Run"))
}
content, ok := read.Value.(string)
if !ok {
return "", core.E("coreexec.Run", core.Concat("unexpected content type for ", path), nil)
}
return content, nil
}
func waitForPID(ctx context.Context, pid int, name string) (syscall.WaitStatus, error) {
done := make(chan waitResult, 1)
go func() {
var status syscall.WaitStatus
_, err := syscall.Wait4(pid, &status, 0, nil)
done <- waitResult{status: status, err: err}
}()
select {
case result := <-done:
if result.err != nil {
return 0, core.E("coreexec.Run", core.Concat("wait ", name), result.err)
}
return result.status, nil
case <-ctx.Done():
_ = syscall.Kill(pid, syscall.SIGKILL)
result := <-done
if result.err != nil {
return 0, core.E("coreexec.Run", core.Concat("wait ", name), result.err)
}
return 0, core.E("coreexec.Run", core.Concat("command cancelled: ", name), ctx.Err())
}
}
func exitCode(status syscall.WaitStatus) int {
if status.Exited() {
return status.ExitStatus()
}
if status.Signaled() {
return 128 + int(status.Signal())
}
return 1
}
func coreResultErr(r core.Result, op string) error {
if r.OK {
return nil
}
if err, ok := r.Value.(error); ok && err != nil {
return err
}
if r.Value == nil {
return core.E(op, "unexpected empty core result", nil)
}
return core.E(op, core.Sprint(r.Value), nil)
}