go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/execution/executor.go (about)

     1  // Copyright 2019 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package execution
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"os/exec"
    14  	"strings"
    15  )
    16  
    17  // Executor supports chaining subcommand execution and error handling.
    18  type Executor struct {
    19  	stdout io.Writer
    20  	stderr io.Writer
    21  	dir    string
    22  }
    23  
    24  // Command represents a command to be run by an executor.
    25  type Command struct {
    26  	Args []string
    27  	// If UserCausedError is set and the command fails with a non-zero exit
    28  	// code, the Executor will wrap the error with a userError.
    29  	UserCausedError bool
    30  }
    31  
    32  type userError struct {
    33  	error
    34  }
    35  
    36  func (e userError) Unwrap() error {
    37  	return e.error
    38  }
    39  
    40  func (e userError) IsInfraFailure() bool {
    41  	return false
    42  }
    43  
    44  // NewExecutor returns a new Executor that writes to stdout and stderr.
    45  func NewExecutor(stdout, stderr io.Writer, dir string) *Executor {
    46  	return &Executor{stdout: stdout, stderr: stderr, dir: dir}
    47  }
    48  
    49  // Exec runs the command at path with args.
    50  func (e Executor) Exec(ctx context.Context, path string, args ...string) error {
    51  	cmd := exec.CommandContext(ctx, path, args...)
    52  	cmd.Env = os.Environ()
    53  	cmd.Stdout = e.stdout
    54  	cmd.Stderr = e.stderr
    55  	cmd.Dir = e.dir
    56  	if err := cmd.Run(); err != nil {
    57  		cmdString := strings.Join(append([]string{path}, args...), " ")
    58  		// Unfortunately, exec.CommandContext doesn't return a context error
    59  		// when the context is canceled – instead it returns the error resulting
    60  		// from the subprocess being killed (e.g. "signal: killed" on Unix). So
    61  		// we assume that if the command failed and the context happens to be
    62  		// canceled, that the subprocess was killed due to a context
    63  		// cancelation.
    64  		if ctx.Err() != nil {
    65  			return fmt.Errorf("command was canceled (%s): %w", cmdString, ctx.Err())
    66  		}
    67  		return fmt.Errorf("command failed (%s): %w", cmdString, err)
    68  	}
    69  	return nil
    70  }
    71  
    72  // ExecAll runs all given commands. Upon encountering an error after execution
    73  // stops and the error is returned. Returns an error if an element in cmds is
    74  // empty.
    75  func (e Executor) ExecAll(ctx context.Context, cmds []Command) error {
    76  	for i, cmd := range cmds {
    77  		if len(cmd.Args) == 0 {
    78  			return fmt.Errorf("forbidden empty list in cmds at position %d", i)
    79  		}
    80  		if err := e.Exec(ctx, cmd.Args[0], cmd.Args[1:]...); err != nil {
    81  			var errExit *exec.ExitError
    82  			if cmd.UserCausedError && errors.As(err, &errExit) {
    83  				return userError{error: err}
    84  			}
    85  			return err
    86  		}
    87  	}
    88  	return nil
    89  }