Files
rvbox/internal/client/supervisor/windows/native_exec.go
T

478 lines
14 KiB
Go

package windows
// This file contains the platform-neutral process bookkeeping shared by the
// native Windows adapter and the deterministic non-Windows test adapter. The
// Windows build supplies the token/Job-backed start and signal operations in
// native_windows.go; keeping output and stdin semantics here prevents those
// paths from drifting apart.
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/client/supervisor"
"github.com/rvbox/rvbox/internal/domain"
)
var (
ErrProcessAlreadyRunning = errors.New("a process for this command is already running")
ErrProcessNotFound = errors.New("supervised process was not found")
ErrProcessNotReady = errors.New("supervised process is not ready")
)
// NativeOptions is the common policy input. Windows callers additionally get
// token/session selection and Job Object containment in the native build; the
// test adapter uses the same shell and output limits without OS handles.
type NativeOptions struct {
Shells ShellPaths
WorkRoot string
JobProfiles map[string]JobProfile
MaxWrapperBytes uint64
MaxOutputChunk uint64
WindowsTermGrace time.Duration
Now func() time.Time
}
// JobProfile is the validated administrator policy for one protocol profile.
// The native Windows adapter applies only controls it can set and read back
// atomically on a Job Object; a requested unsupported required control rejects
// the command before any child is created.
type JobProfile struct {
RequiredControls []string
CPUPercent uint64
MemoryMaxBytes uint64
PIDsMax uint64
IOReadBPS uint64
IOWriteBPS uint64
}
func (options NativeOptions) withDefaults() NativeOptions {
if options.MaxWrapperBytes == 0 {
options.MaxWrapperBytes = 10 << 20
}
if options.MaxOutputChunk == 0 || options.MaxOutputChunk > 64<<10 {
options.MaxOutputChunk = 64 << 10
}
if options.WindowsTermGrace <= 0 {
options.WindowsTermGrace = 10 * time.Second
}
if options.Now == nil {
options.Now = func() time.Time { return time.Now().UTC() }
}
return options
}
type execSupervisor struct {
options NativeOptions
mu sync.Mutex
active map[domain.UUID]*execProcess
}
func newExecSupervisor(options NativeOptions) *execSupervisor {
options = options.withDefaults()
return &execSupervisor{options: options, active: make(map[domain.UUID]*execProcess)}
}
type execProcess struct {
issue domain.UUID
identity supervisor.EffectiveIdentity
cmd *exec.Cmd
pid uint32
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
outputs chan outputResult
done chan struct{}
started time.Time
waitFn func() (int32, bool, error)
killFn func(uint32) error
snapshotFn func() (supervisor.ResourceSnapshot, error)
mu sync.Mutex
finished bool
status supervisor.ExitStatus
waitErr error
closeIn sync.Once
}
type outputResult struct {
chunk supervisor.OutputChunk
err error
}
func (process *execProcess) IssueUUID() domain.UUID { return process.issue }
func (process *execProcess) Identity() supervisor.EffectiveIdentity { return process.identity }
func (process *execProcess) ReadOutput(ctx context.Context) (supervisor.OutputChunk, error) {
if process == nil || process.outputs == nil {
return supervisor.OutputChunk{}, ErrProcessNotReady
}
select {
case <-ctx.Done():
return supervisor.OutputChunk{}, ctx.Err()
case result, ok := <-process.outputs:
if !ok {
return supervisor.OutputChunk{}, io.EOF
}
return result.chunk, result.err
}
}
func (process *execProcess) Wait(ctx context.Context) (supervisor.ExitStatus, error) {
if process == nil || process.done == nil {
return supervisor.ExitStatus{}, ErrProcessNotReady
}
select {
case <-ctx.Done():
return supervisor.ExitStatus{}, ctx.Err()
case <-process.done:
process.mu.Lock()
defer process.mu.Unlock()
return process.status, process.waitErr
}
}
func (process *execProcess) WriteStdin(ctx context.Context, data []byte, appendNewline bool) error {
if process == nil || process.stdin == nil {
return ErrProcessNotReady
}
if appendNewline {
data = append(append([]byte(nil), data...), stdinLineEnding()...)
}
return writeWithContext(ctx, process.stdin, data)
}
func (process *execProcess) CloseStdin(_ context.Context) error {
if process == nil || process.stdin == nil {
return ErrProcessNotReady
}
var err error
process.closeIn.Do(func() { err = process.stdin.Close() })
return err
}
func writeWithContext(ctx context.Context, writer io.Writer, data []byte) error {
if len(data) == 0 {
return nil
}
result := make(chan error, 1)
go func() {
_, err := writer.Write(data)
result <- err
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-result:
return err
}
}
func (process *execProcess) setResult(status supervisor.ExitStatus, err error) {
process.mu.Lock()
process.status, process.waitErr, process.finished = status, err, true
process.mu.Unlock()
close(process.done)
}
func (process *execProcess) startReaders(maxChunk uint64, remove func()) {
var readers sync.WaitGroup
read := func(stream rvboxv1.StreamKind, source io.ReadCloser) {
defer readers.Done()
defer source.Close()
limit := int(maxChunk)
if limit <= 0 {
limit = 64 << 10
}
buffer := make([]byte, limit)
for {
count, err := source.Read(buffer)
if count > 0 {
data := append([]byte(nil), buffer[:count]...)
select {
case process.outputs <- outputResult{chunk: supervisor.OutputChunk{Stream: stream, Data: data}}:
default:
// The output channel is bounded. A reader must never hold a
// child pipe open while waiting for network I/O; dropping here
// is surfaced as an explicit read error to the caller.
select {
case process.outputs <- outputResult{err: fmt.Errorf("%w: output channel full", supervisor.ErrUnsupported)}:
case <-time.After(time.Second):
}
}
}
if err != nil {
if !errors.Is(err, io.EOF) {
select {
case process.outputs <- outputResult{err: err}:
default:
}
}
return
}
}
}
readers.Add(2)
go read(rvboxv1.StreamKind_STREAM_STDOUT, process.stdout)
go read(rvboxv1.StreamKind_STREAM_STDERR, process.stderr)
go func() {
readers.Wait()
close(process.outputs)
if remove != nil {
remove()
}
}()
}
func materializeWrapper(directory string, wrapper Wrapper, now time.Time) (string, func(), error) {
if directory == "" {
return "", nil, ErrInvalidWorkingDirectory
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return "", nil, err
}
temporary, err := os.CreateTemp(directory, ".rvbox-wrapper-*")
if err != nil {
return "", nil, err
}
temporaryName := temporary.Name()
cleanup := func() {
_ = temporary.Close()
_ = os.Remove(temporaryName)
}
if err := temporary.Chmod(0o600); err != nil {
cleanup()
return "", nil, err
}
if _, err := temporary.Write(wrapper.Bytes); err != nil {
cleanup()
return "", nil, err
}
if err := temporary.Sync(); err != nil {
cleanup()
return "", nil, err
}
if err := temporary.Close(); err != nil {
_ = os.Remove(temporaryName)
return "", nil, err
}
finalName := temporaryName + wrapper.Extension
if err := os.Rename(temporaryName, finalName); err != nil {
_ = os.Remove(temporaryName)
return "", nil, err
}
return finalName, func() { _ = os.Remove(finalName) }, nil
}
func parseEnvironment(values []string) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
index := strings.IndexByte(value, '=')
if index <= 0 {
continue
}
result[value[:index]] = value[index+1:]
}
return result
}
func (process *execProcess) terminate(code uint32) error {
if process == nil {
return ErrProcessNotReady
}
if process.killFn != nil {
return process.killFn(code)
}
if process.cmd == nil || process.cmd.Process == nil {
return ErrProcessNotReady
}
return process.cmd.Process.Kill()
}
func (manager *execSupervisor) remove(issue domain.UUID, process *execProcess) {
manager.mu.Lock()
if manager.active[issue] == process {
delete(manager.active, issue)
}
manager.mu.Unlock()
}
func validateExecutionSource(spec *supervisor.StartSpec) error {
if err := spec.Validate(); err != nil {
return err
}
if spec.Execution.GetScript() != nil && len(spec.ScriptBody) == 0 && spec.Execution.GetScript().GetSizeBytes() != 0 {
return ErrProcessNotReady
}
if len(spec.ExecutionProfiles) != 0 {
return fmt.Errorf("%w: execution profiles are not supported by this adapter yet", supervisor.ErrUnsupported)
}
return nil
}
func sourceCommand(spec supervisor.StartSpec, wrapperPath string) (string, []string, error) {
source := spec.Execution.GetCommandText()
if wrapperPath != "" {
source = wrapperPath
}
switch spec.Execution.GetShellType() {
case rvboxv1.ShellType_SHELL_SH:
if wrapperPath != "" {
return "/bin/sh", []string{wrapperPath}, nil
}
return "/bin/sh", []string{"-c", source}, nil
case rvboxv1.ShellType_SHELL_BASH:
if wrapperPath != "" {
return "/bin/bash", []string{wrapperPath}, nil
}
return "/bin/bash", []string{"-c", source}, nil
default:
return "", nil, ErrUnsupportedShell
}
}
// startPortable launches the same bounded pipe contract on Unix. It is kept
// private because v1 does not advertise a Unix client; tests use it to prove
// runner/supervisor ordering without a Windows host.
func (manager *execSupervisor) startPortable(ctx context.Context, spec supervisor.StartSpec) (supervisor.Process, error) {
if err := validateExecutionSource(&spec); err != nil {
return nil, err
}
manager.mu.Lock()
if _, exists := manager.active[spec.IssueUUID]; exists {
manager.mu.Unlock()
return nil, ErrProcessAlreadyRunning
}
manager.mu.Unlock()
var wrapperPath string
var cleanup func()
if spec.Execution.GetScript() != nil {
wrapper, err := BuildWrapper(spec.Execution, spec.ScriptBody, manager.options.MaxWrapperBytes)
if err != nil {
return nil, err
}
wrapperPath, cleanup, err = materializeWrapper(spec.WorkingDirectory, wrapper, manager.options.Now())
if err != nil {
return nil, err
}
}
program, arguments, err := sourceCommand(spec, wrapperPath)
if err != nil {
if cleanup != nil {
cleanup()
}
return nil, err
}
return manager.startCommand(ctx, spec, program, arguments, supervisor.EffectiveIdentity{Context: "CURRENT_PROCESS", Elevated: false}, cleanup, nil)
}
func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor.StartSpec, program string, arguments []string, identity supervisor.EffectiveIdentity, cleanup func(), configure func(*exec.Cmd) error) (supervisor.Process, error) {
command := exec.CommandContext(ctx, program, arguments...)
if configure != nil {
if err := configure(command); err != nil {
if cleanup != nil {
cleanup()
}
return nil, err
}
}
command.Dir = filepath.Clean(spec.WorkingDirectory)
base := parseEnvironment(os.Environ())
entries, err := MergeEnvironment(base, spec.Environment)
if err != nil {
if cleanup != nil {
cleanup()
}
return nil, err
}
command.Env = make([]string, 0, len(entries))
for _, entry := range entries {
command.Env = append(command.Env, entry.Key+"="+entry.Value)
}
stdinRead, stdin, err := os.Pipe()
if err != nil {
if cleanup != nil {
cleanup()
}
return nil, err
}
stdout, stdoutWrite, err := os.Pipe()
if err != nil {
_ = stdinRead.Close()
_ = stdin.Close()
if cleanup != nil {
cleanup()
}
return nil, err
}
stderr, stderrWrite, err := os.Pipe()
if err != nil {
_ = stdinRead.Close()
_ = stdin.Close()
_ = stdout.Close()
_ = stdoutWrite.Close()
if cleanup != nil {
cleanup()
}
return nil, err
}
command.Stdin = stdinRead
command.Stdout = stdoutWrite
command.Stderr = stderrWrite
if err := command.Start(); err != nil {
_ = stdinRead.Close()
_ = stdin.Close()
_ = stdout.Close()
_ = stdoutWrite.Close()
_ = stderr.Close()
_ = stderrWrite.Close()
if cleanup != nil {
cleanup()
}
return nil, err
}
_ = stdinRead.Close()
_ = stdoutWrite.Close()
_ = stderrWrite.Close()
started := manager.options.Now()
waitFn := func() (int32, bool, error) {
err := command.Wait()
var code int32
if command.ProcessState != nil {
code = int32(command.ProcessState.ExitCode())
}
return code, false, err
}
return manager.registerProcess(spec.IssueUUID, identity, command, stdin, stdout, stderr, started, waitFn, func(uint32) error { return command.Process.Kill() }, cleanup), nil
}
func (manager *execSupervisor) registerProcess(issue domain.UUID, identity supervisor.EffectiveIdentity, command *exec.Cmd, stdin io.WriteCloser, stdout, stderr io.ReadCloser, started time.Time, waitFn func() (int32, bool, error), killFn func(uint32) error, cleanup func()) *execProcess {
var pid uint32
if command != nil && command.Process != nil && command.Process.Pid > 0 {
pid = uint32(command.Process.Pid)
}
process := &execProcess{issue: issue, identity: identity, cmd: command, pid: pid, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
manager.mu.Lock()
manager.active[issue] = process
manager.mu.Unlock()
process.startReaders(manager.options.MaxOutputChunk, cleanup)
go func() {
code, signaled, err := process.waitFn()
finished := manager.options.Now()
status := supervisor.ExitStatus{StartedAt: started, FinishedAt: finished, OutputDrained: false, Code: code, Signaled: signaled}
process.setResult(status, err)
manager.remove(issue, process)
}()
return process
}