github.com/fastly/cli@v1.7.2-0.20240304164155-9d0f1d77c3bf/pkg/exec/exec.go (about)

     1  package exec
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"os/exec"
     9  	"os/signal"
    10  	"syscall"
    11  	"time"
    12  
    13  	fsterr "github.com/fastly/cli/pkg/errors"
    14  	"github.com/fastly/cli/pkg/text"
    15  	"github.com/fastly/cli/pkg/threadsafe"
    16  )
    17  
    18  // divider is used as separator lines around shell output.
    19  const divider = "--------------------------------------------------------------------------------"
    20  
    21  // Streaming models a generic command execution that consumers can use to
    22  // execute commands and stream their output to an io.Writer. For example
    23  // compute commands can use this to standardize the flow control for each
    24  // compiler toolchain.
    25  type Streaming struct {
    26  	// Args are the command positional arguments.
    27  	Args []string
    28  	// Command is the command to be executed.
    29  	Command string
    30  	// Env is the environment variables to set.
    31  	Env []string
    32  	// ForceOutput ensures output is displayed (default: only display on error).
    33  	ForceOutput bool
    34  	// Output is where to write output (e.g. stdout)
    35  	Output io.Writer
    36  	// Process is the process to terminal if signal received.
    37  	Process *os.Process
    38  	// SignalCh is a channel handling signal events.
    39  	SignalCh chan os.Signal
    40  	// Spinner is a specific spinner instance.
    41  	Spinner text.Spinner
    42  	// SpinnerMessage is the messaging to use.
    43  	SpinnerMessage string
    44  	// Timeout is the command timeout.
    45  	Timeout time.Duration
    46  	// Verbose outputs additional information.
    47  	Verbose bool
    48  }
    49  
    50  // MonitorSignals spawns a goroutine that configures signal handling so that
    51  // the long running subprocess can be killed using SIGINT/SIGTERM.
    52  func (s *Streaming) MonitorSignals() {
    53  	go s.MonitorSignalsAsync()
    54  }
    55  
    56  // MonitorSignalsAsync configures the signal notifications.
    57  func (s *Streaming) MonitorSignalsAsync() {
    58  	signals := []os.Signal{
    59  		syscall.SIGINT,
    60  		syscall.SIGTERM,
    61  	}
    62  
    63  	signal.Notify(s.SignalCh, signals...)
    64  
    65  	<-s.SignalCh
    66  	signal.Stop(s.SignalCh)
    67  
    68  	// NOTE: We don't do error handling here because the user might be doing local
    69  	// development with the --watch flag and that workflow will have already
    70  	// killed the process. The reason this line still exists is for users running
    71  	// their application locally without the --watch flag and who then execute
    72  	// Ctrl-C to kill the process.
    73  	_ = s.Signal(os.Kill)
    74  }
    75  
    76  // Exec executes the compiler command and pipes the child process stdout and
    77  // stderr output to the supplied io.Writer, it waits for the command to exit
    78  // cleanly or returns an error.
    79  func (s *Streaming) Exec() error {
    80  	// Construct the command with given arguments and environment.
    81  	var cmd *exec.Cmd
    82  	if s.Timeout > 0 {
    83  		ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
    84  		defer cancel()
    85  		// gosec flagged this:
    86  		// G204 (CWE-78): Subprocess launched with variable
    87  		// Disabling as the variables come from trusted sources.
    88  		// #nosec
    89  		// nosemgrep
    90  		cmd = exec.CommandContext(ctx, s.Command, s.Args...)
    91  	} else {
    92  		// gosec flagged this:
    93  		// G204 (CWE-78): Subprocess launched with variable
    94  		// Disabling as the variables come from trusted sources.
    95  		// #nosec
    96  		// nosemgrep
    97  		cmd = exec.Command(s.Command, s.Args...)
    98  	}
    99  	cmd.Env = append(os.Environ(), s.Env...)
   100  
   101  	// We store all output in a buffer to hide it unless there was an error.
   102  	var buf threadsafe.Buffer
   103  	var output io.Writer
   104  	output = &buf
   105  
   106  	// We only display the stored output if there is an error.
   107  	// But some commands like `compute serve` expect the full output regardless.
   108  	// So for those scenarios they can force all output.
   109  	if s.ForceOutput {
   110  		output = s.Output
   111  	}
   112  
   113  	if !s.Verbose {
   114  		text.Break(output)
   115  	}
   116  	text.Info(output, "Command output:")
   117  	text.Output(output, divider)
   118  
   119  	cmd.Stdout = output
   120  	cmd.Stderr = output
   121  
   122  	if err := cmd.Start(); err != nil {
   123  		text.Output(output, divider)
   124  		return err
   125  	}
   126  
   127  	// Store off os.Process so it can be killed by signal listener.
   128  	//
   129  	// NOTE: argparser.Process is nil until exec.Start() returns successfully.
   130  	s.Process = cmd.Process
   131  
   132  	if err := cmd.Wait(); err != nil {
   133  		// IMPORTANT: We MUST wrap the original error.
   134  		// This is because the `compute serve` command requires it for --watch
   135  		// Specifically we need to check the error message for "killed".
   136  		// This enables the watching logic to restart the Viceroy binary.
   137  		err = fmt.Errorf("error during execution process (see 'command output' above): %w", err)
   138  
   139  		text.Output(output, divider)
   140  
   141  		// If we're in verbose mode, the build output is shown.
   142  		// So in that case we don't want to have a spinner as it'll interweave output.
   143  		// In non-verbose mode we have a spinner running while the build is happening.
   144  		if !s.Verbose && s.Spinner != nil {
   145  			s.Spinner.StopFailMessage(s.SpinnerMessage)
   146  			if spinErr := s.Spinner.StopFail(); spinErr != nil {
   147  				return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err)
   148  			}
   149  		}
   150  
   151  		// Display the buffer stored output as we have an error.
   152  		fmt.Fprintf(s.Output, "%s", buf.String())
   153  
   154  		return err
   155  	}
   156  
   157  	text.Output(output, divider)
   158  	return nil
   159  }
   160  
   161  // Signal enables spawned subprocess to accept given signal.
   162  func (s *Streaming) Signal(sig os.Signal) error {
   163  	if s.Process != nil {
   164  		err := s.Process.Signal(sig)
   165  		if err != nil {
   166  			return err
   167  		}
   168  	}
   169  	return nil
   170  }
   171  
   172  // CommandOpts are arguments for executing a streaming command.
   173  type CommandOpts struct {
   174  	// Args are the command positional arguments.
   175  	Args []string
   176  	// Command is the command to be executed.
   177  	Command string
   178  	// Env is the environment variables to set.
   179  	Env []string
   180  	// ErrLog provides an interface for recording errors to disk.
   181  	ErrLog fsterr.LogInterface
   182  	// Output is where to write output (e.g. stdout)
   183  	Output io.Writer
   184  	// Spinner is a specific spinner instance.
   185  	Spinner text.Spinner
   186  	// SpinnerMessage is the messaging to use.
   187  	SpinnerMessage string
   188  	// Timeout is the command timeout.
   189  	Timeout int
   190  	// Verbose outputs additional information.
   191  	Verbose bool
   192  }
   193  
   194  // Command is an abstraction over a Streaming type. It is used by both the
   195  // `compute init` and `compute build` commands to run post init/build scripts.
   196  func Command(opts CommandOpts) error {
   197  	s := Streaming{
   198  		Command:        opts.Command,
   199  		Args:           opts.Args,
   200  		Env:            opts.Env,
   201  		Output:         opts.Output,
   202  		Spinner:        opts.Spinner,
   203  		SpinnerMessage: opts.SpinnerMessage,
   204  		Verbose:        opts.Verbose,
   205  	}
   206  	if opts.Verbose {
   207  		s.ForceOutput = true
   208  	}
   209  	if opts.Timeout > 0 {
   210  		s.Timeout = time.Duration(opts.Timeout) * time.Second
   211  	}
   212  	if err := s.Exec(); err != nil {
   213  		opts.ErrLog.Add(err)
   214  		return err
   215  	}
   216  	return nil
   217  }