516 lines
12 KiB
Go
516 lines
12 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/cirruslabs/tart-guest-agent/internal/execuser"
|
|
"github.com/creack/pty"
|
|
"github.com/google/uuid"
|
|
"github.com/samber/lo"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
)
|
|
|
|
const (
|
|
standardStreamsBufferSize = 4096
|
|
|
|
eofChar = 0x04
|
|
|
|
// execRuntimeFailureExitCode matches Docker's exit code for runtime failures before a process starts.
|
|
execRuntimeFailureExitCode = 125
|
|
// signalExitCodeOffset is the base for shell-style exit codes of processes terminated by signals.
|
|
signalExitCodeOffset = 128
|
|
)
|
|
|
|
func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
|
|
// Read the first exec request, it should describe a command to execute
|
|
firstExecRequest, err := stream.Recv()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
firstExecRequestCommand, ok := firstExecRequest.Type.(*ExecRequest_Command_)
|
|
if !ok {
|
|
return fmt.Errorf("first exec request should describe a command to execute")
|
|
}
|
|
|
|
zap.S().Infof("executing %s", formatCommandAndArgs(firstExecRequestCommand.Command.Name,
|
|
firstExecRequestCommand.Command.Args))
|
|
|
|
if firstExecRequestCommand.Command.Detach &&
|
|
(firstExecRequestCommand.Command.Interactive || firstExecRequestCommand.Command.Tty) {
|
|
return fmt.Errorf("detach cannot be used with interactive or tty")
|
|
}
|
|
|
|
// Execute the command
|
|
execCtx := stream.Context()
|
|
if firstExecRequestCommand.Command.Detach {
|
|
execCtx = context.Background()
|
|
}
|
|
|
|
cmd := exec.CommandContext(execCtx, firstExecRequestCommand.Command.Name,
|
|
firstExecRequestCommand.Command.Args...)
|
|
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
|
|
if err := applyExecOverrides(cmd, firstExecRequestCommand.Command); err != nil {
|
|
zap.S().Warnf("failed to configure %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
|
|
firstExecRequestCommand.Command.GetArgs()), err)
|
|
|
|
return sendStartFailure(stream)
|
|
}
|
|
|
|
if firstExecRequestCommand.Command.Detach {
|
|
cmd.Stdout = io.Discard
|
|
cmd.Stderr = io.Discard
|
|
cmd.SysProcAttr.Setsid = true
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
|
|
firstExecRequestCommand.Command.GetArgs()), err)
|
|
|
|
return sendStartFailure(stream)
|
|
}
|
|
|
|
// Release ownership before sending responses so failures do not leak the process handle
|
|
if err := cmd.Process.Release(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Explicitly notify the client that the process was started,
|
|
// but don't provide an exec ID since it's a detached process
|
|
err = sendStartSuccess(stream, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := stream.Send(&ExecResponse{
|
|
Type: &ExecResponse_Exit_{
|
|
Exit: &ExecResponse_Exit{
|
|
Code: 0,
|
|
},
|
|
},
|
|
}); err != nil && !errors.Is(err, context.Canceled) {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Kill the whole process group when the exec stream is canceled
|
|
cmd.Cancel = func() error {
|
|
return signalProcessGroup(cmd.Process, syscall.SIGKILL)
|
|
}
|
|
|
|
var stdin io.WriteCloser
|
|
var stdout, stderr io.ReadCloser
|
|
var ptmx *os.File
|
|
|
|
if firstExecRequestCommand.Command.Tty {
|
|
ptmx, err = pty.StartWithSize(cmd, &pty.Winsize{
|
|
Rows: uint16(firstExecRequestCommand.Command.GetTerminalSize().GetRows()),
|
|
Cols: uint16(firstExecRequestCommand.Command.GetTerminalSize().GetCols()),
|
|
})
|
|
|
|
if firstExecRequestCommand.Command.Interactive {
|
|
stdin = ptmx
|
|
}
|
|
stdout = ptmx
|
|
stderr = ptmx
|
|
} else {
|
|
// Start the command in its own process group so signals reach all descendants
|
|
cmd.SysProcAttr.Setpgid = true
|
|
|
|
if firstExecRequestCommand.Command.Interactive {
|
|
stdin, err = cmd.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
stdout, err = cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stderr, err = cmd.StderrPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = cmd.Start()
|
|
}
|
|
|
|
if err != nil {
|
|
zap.S().Warnf("failed to start %s: %v", formatCommandAndArgs(firstExecRequestCommand.Command.GetName(),
|
|
firstExecRequestCommand.Command.GetArgs()), err)
|
|
|
|
return sendStartFailure(stream)
|
|
}
|
|
|
|
// Ensure the PTY is closed if sending the Started response fails
|
|
if ptmx != nil {
|
|
defer ptmx.Close()
|
|
}
|
|
|
|
execID := uuid.NewString()
|
|
rpc.execs.Store(execID, cmd.Process)
|
|
defer rpc.execs.Delete(execID)
|
|
|
|
// Explicitly notify the client that the process was started
|
|
err = sendStartSuccess(stream, execID)
|
|
if err != nil {
|
|
// Output readers have not started yet, so cancel and reap directly
|
|
_ = cmd.Cancel()
|
|
_ = cmd.Wait()
|
|
|
|
return err
|
|
}
|
|
|
|
// Handle standard input and terminal resize events from the client
|
|
fromClientErrCh := make(chan error, 1)
|
|
reportClientError := func(err error) {
|
|
fromClientErrCh <- err
|
|
_ = cmd.Cancel()
|
|
}
|
|
|
|
go func() {
|
|
var stdinClosed bool
|
|
|
|
for {
|
|
request, err := stream.Recv()
|
|
if err != nil {
|
|
// Allow the client to close its sending side while continuing to receive responses
|
|
if errors.Is(err, io.EOF) {
|
|
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
|
|
reportClientError(err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
reportClientError(err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
switch typedAction := request.Type.(type) {
|
|
case *ExecRequest_StandardInput:
|
|
if !firstExecRequestCommand.Command.Interactive {
|
|
// Ignore standard input from the client
|
|
// as non-interactive command is running
|
|
continue
|
|
}
|
|
|
|
// Check if the remote client has received EOF on their standard input
|
|
if len(typedAction.StandardInput.Data) == 0 {
|
|
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
|
|
reportClientError(err)
|
|
|
|
return
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if _, err := stdin.Write(typedAction.StandardInput.GetData()); err != nil {
|
|
reportClientError(err)
|
|
|
|
return
|
|
}
|
|
case *ExecRequest_TerminalResize:
|
|
// Ignore terminal resize requests
|
|
// when pseudo terminal is disabled
|
|
if !firstExecRequestCommand.Command.Tty {
|
|
continue
|
|
}
|
|
|
|
if err := pty.Setsize(ptmx, &pty.Winsize{
|
|
Rows: uint16(typedAction.TerminalResize.GetRows()),
|
|
Cols: uint16(typedAction.TerminalResize.GetCols()),
|
|
}); err != nil {
|
|
reportClientError(err)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Serialize responses from the stdout and stderr goroutines
|
|
var sendMutex sync.Mutex
|
|
|
|
sendResponse := func(response *ExecResponse) error {
|
|
sendMutex.Lock()
|
|
defer sendMutex.Unlock()
|
|
|
|
return stream.Send(response)
|
|
}
|
|
|
|
group, _ := errgroup.WithContext(stream.Context())
|
|
|
|
// Handle standard output from the command
|
|
group.Go(func() error {
|
|
buf := make([]byte, standardStreamsBufferSize)
|
|
|
|
for {
|
|
n, err := stdout.Read(buf)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
|
|
// PTY way of signalling io.EOF
|
|
if ptmx != nil && strings.Contains(err.Error(), "input/output error") {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if err := sendResponse(&ExecResponse{
|
|
Type: &ExecResponse_StandardOutput{
|
|
StandardOutput: &IOChunk{
|
|
Data: slices.Clone(buf[:n]),
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle standard error from the command
|
|
//
|
|
// Note that it makes no sense to handle standard error when TTY is requested
|
|
// because in this case stdout and stderr will point to the same file descriptor
|
|
if !firstExecRequestCommand.Command.Tty {
|
|
group.Go(func() error {
|
|
buf := make([]byte, standardStreamsBufferSize)
|
|
|
|
for {
|
|
n, err := stderr.Read(buf)
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if err := sendResponse(&ExecResponse{
|
|
Type: &ExecResponse_StandardError{
|
|
StandardError: &IOChunk{
|
|
Data: slices.Clone(buf[:n]),
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
if err := group.Wait(); err != nil {
|
|
zap.S().Warnf("%v", err)
|
|
}
|
|
|
|
// Wait for the command to finish
|
|
err = cmd.Wait()
|
|
|
|
// Minimize the window in which a finished exec can still be signaled
|
|
rpc.execs.Delete(execID)
|
|
|
|
// Prefer a client error over the command exit result
|
|
select {
|
|
case err := <-fromClientErrCh:
|
|
return err
|
|
default:
|
|
}
|
|
|
|
exitCode := 0
|
|
|
|
if err != nil {
|
|
var exitError *exec.ExitError
|
|
if !errors.As(err, &exitError) {
|
|
return err
|
|
}
|
|
|
|
// ExitCode returns -1 for signals; report the containerd-compatible 128 + signal instead
|
|
exitCode = exitError.ExitCode()
|
|
|
|
if waitStatus, ok := exitError.Sys().(syscall.WaitStatus); ok && waitStatus.Signaled() {
|
|
exitCode = signalExitCodeOffset + int(waitStatus.Signal())
|
|
}
|
|
}
|
|
|
|
return stream.Send(&ExecResponse{
|
|
Type: &ExecResponse_Exit_{
|
|
Exit: &ExecResponse_Exit{
|
|
Code: int32(exitCode),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func signalProcessGroup(process *os.Process, signal syscall.Signal) error {
|
|
if err := syscall.Kill(-process.Pid, signal); err != nil {
|
|
// Translate a missing process group into the process-finished error expected by os/exec
|
|
if errors.Is(err, syscall.ESRCH) {
|
|
return os.ErrProcessDone
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func closeStdin(stdin io.WriteCloser, tty bool, closed *bool) error {
|
|
if stdin == nil || *closed {
|
|
return nil
|
|
}
|
|
|
|
if tty {
|
|
// When using pseudo-terminal, we can't simply close the
|
|
// standard input, as the file descriptor is shared for
|
|
// standard output and standard error too, so we send
|
|
// an EOF character instead
|
|
if _, err := stdin.Write([]byte{eofChar}); err != nil {
|
|
return err
|
|
}
|
|
} else if err := stdin.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
*closed = true
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rpc *RPC) Signal(_ context.Context, request *SignalRequest) (*emptypb.Empty, error) {
|
|
process, ok := rpc.execs.Load(request.GetExecId())
|
|
if !ok {
|
|
return nil, fmt.Errorf("exec %q is not running", request.GetExecId())
|
|
}
|
|
|
|
var signal syscall.Signal
|
|
|
|
switch request.GetSignal() {
|
|
case SignalRequest_SIGNAL_SIGTERM:
|
|
signal = syscall.SIGTERM
|
|
case SignalRequest_SIGNAL_SIGKILL:
|
|
signal = syscall.SIGKILL
|
|
default:
|
|
return nil, fmt.Errorf("unsupported exec signal %q", request.GetSignal().String())
|
|
}
|
|
|
|
if err := signalProcessGroup(process, signal); err != nil {
|
|
// The process may exit after lookup, so treat the missing process as a no-op
|
|
if errors.Is(err, os.ErrProcessDone) {
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func sendStartSuccess(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse], execID string) error {
|
|
return stream.Send(&ExecResponse{
|
|
Type: &ExecResponse_Started_{
|
|
Started: &ExecResponse_Started{
|
|
ExecId: execID,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func sendStartFailure(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse]) error {
|
|
return stream.Send(&ExecResponse{
|
|
Type: &ExecResponse_Exit_{
|
|
Exit: &ExecResponse_Exit{
|
|
Code: execRuntimeFailureExitCode,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func applyExecOverrides(cmd *exec.Cmd, command *ExecRequest_Command) error {
|
|
if command.Workdir != "" {
|
|
cmd.Dir = command.Workdir
|
|
}
|
|
|
|
if len(command.Env) > 0 {
|
|
cmd.Env = mergeEnv(command.Env)
|
|
}
|
|
|
|
if user := command.GetUser(); user != "" {
|
|
credential, err := execuser.Resolve(user)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to apply user override %q: %w", user, err)
|
|
}
|
|
|
|
// Avoid changing credentials when the requested user is the guest agent user
|
|
if credential.Uid == uint32(os.Geteuid()) && credential.Gid == uint32(os.Getegid()) {
|
|
return nil
|
|
}
|
|
|
|
cmd.SysProcAttr.Credential = credential
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func mergeEnv(overrides map[string]string) []string {
|
|
if len(overrides) == 0 {
|
|
return os.Environ()
|
|
}
|
|
|
|
envMap := make(map[string]string, len(overrides))
|
|
for _, entry := range os.Environ() {
|
|
parts := strings.SplitN(entry, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
envMap[parts[0]] = parts[1]
|
|
}
|
|
|
|
for key, value := range overrides {
|
|
envMap[key] = value
|
|
}
|
|
|
|
merged := make([]string, 0, len(envMap))
|
|
for key, value := range envMap {
|
|
merged = append(merged, key+"="+value)
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
func formatCommandAndArgs(name string, args []string) string {
|
|
var all []string
|
|
|
|
all = append(all, name)
|
|
all = append(all, args...)
|
|
|
|
all = lo.Map(all, func(item string, _ int) string {
|
|
return fmt.Sprintf("%q", item)
|
|
})
|
|
|
|
return fmt.Sprintf("[%s]", strings.Join(all, ", "))
|
|
}
|