github.com/stevenmatthewt/agent@v3.5.4+incompatible/bootstrap/shell/shell.go (about)

     1  package shell
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"os/signal"
    11  	"path"
    12  	"path/filepath"
    13  	"runtime"
    14  	"strings"
    15  	"syscall"
    16  	"time"
    17  
    18  	"github.com/buildkite/agent/env"
    19  	"github.com/buildkite/agent/process"
    20  	"github.com/buildkite/shellwords"
    21  	"github.com/nightlyone/lockfile"
    22  	"github.com/pkg/errors"
    23  )
    24  
    25  var (
    26  	lockRetryDuration = time.Second
    27  )
    28  
    29  const (
    30  	termType = `xterm-256color`
    31  )
    32  
    33  // Shell represents a virtual shell, handles logging, executing commands and
    34  // provides hooks for capturing output and exit conditions.
    35  //
    36  // Provides a lowest-common denominator abstraction over macOS, Linux and Windows
    37  type Shell struct {
    38  	Logger
    39  
    40  	// The running environment for the shell
    41  	Env *env.Environment
    42  
    43  	// Whether the shell is a PTY
    44  	PTY bool
    45  
    46  	// Where stdout is written, defaults to os.Stdout
    47  	Writer io.Writer
    48  
    49  	// Whether to run the shell in debug mode
    50  	Debug bool
    51  
    52  	// Current working directory that shell commands get executed in
    53  	wd string
    54  
    55  	// The context for the shell
    56  	ctx context.Context
    57  }
    58  
    59  // New returns a new Shell
    60  func New() (*Shell, error) {
    61  	wd, err := os.Getwd()
    62  	if err != nil {
    63  		return nil, errors.Wrapf(err, "Failed to find current working directory")
    64  	}
    65  
    66  	return &Shell{
    67  		Logger: StderrLogger,
    68  		Env:    env.FromSlice(os.Environ()),
    69  		Writer: os.Stdout,
    70  		wd:     wd,
    71  		ctx:    context.Background(),
    72  	}, nil
    73  }
    74  
    75  // New returns a new Shell with provided context.Context
    76  func NewWithContext(ctx context.Context) (*Shell, error) {
    77  	sh, err := New()
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  
    82  	sh.ctx = ctx
    83  	return sh, nil
    84  }
    85  
    86  // Getwd returns the current working directory of the shell
    87  func (s *Shell) Getwd() string {
    88  	return s.wd
    89  }
    90  
    91  // Chdir changes the working directory of the shell
    92  func (s *Shell) Chdir(path string) error {
    93  	// If the path isn't absolute, prefix it with the current working directory.
    94  	if !filepath.IsAbs(path) {
    95  		path = filepath.Join(s.wd, path)
    96  	}
    97  
    98  	s.Promptf("cd %s", shellwords.Quote(path))
    99  
   100  	if _, err := os.Stat(path); err != nil {
   101  		return fmt.Errorf("Failed to change working: directory does not exist")
   102  	}
   103  
   104  	s.wd = path
   105  	return nil
   106  }
   107  
   108  // AbsolutePath returns the absolute path to an executable based on the PATH and
   109  // PATHEXT of the Shell
   110  func (s *Shell) AbsolutePath(executable string) (string, error) {
   111  	// Is the path already absolute?
   112  	if path.IsAbs(executable) {
   113  		return executable, nil
   114  	}
   115  
   116  	envPath, _ := s.Env.Get("PATH")
   117  	fileExtensions, _ := s.Env.Get("PATHEXT") // For searching .exe, .bat, etc on Windows
   118  
   119  	// Use our custom lookPath that takes a specific path
   120  	absolutePath, err := LookPath(executable, envPath, fileExtensions)
   121  	if err != nil {
   122  		return "", err
   123  	}
   124  
   125  	// Since the path returned by LookPath is relative to the current working
   126  	// directory, we need to get the absolute version of that.
   127  	return filepath.Abs(absolutePath)
   128  }
   129  
   130  // LockFile is a pid-based lock for cross-process locking
   131  type LockFile interface {
   132  	Unlock() error
   133  }
   134  
   135  // Create a cross-process file-based lock based on pid files
   136  func (s *Shell) LockFile(path string, timeout time.Duration) (LockFile, error) {
   137  	absolutePathToLock, err := filepath.Abs(path)
   138  	if err != nil {
   139  		return nil, fmt.Errorf("Failed to find absolute path to lock \"%s\" (%v)", path, err)
   140  	}
   141  
   142  	lock, err := lockfile.New(absolutePathToLock)
   143  	if err != nil {
   144  		return nil, fmt.Errorf("Failed to create lock \"%s\" (%s)", absolutePathToLock, err)
   145  	}
   146  
   147  	ctx, cancel := context.WithTimeout(s.ctx, timeout)
   148  	defer cancel()
   149  
   150  	for {
   151  		// Keep trying the lock until we get it
   152  		if err := lock.TryLock(); err != nil {
   153  			s.Commentf("Could not acquire lock on \"%s\" (%s)", absolutePathToLock, err)
   154  			s.Commentf("Trying again in %s...", lockRetryDuration)
   155  			time.Sleep(lockRetryDuration)
   156  		} else {
   157  			break
   158  		}
   159  
   160  		select {
   161  		case <-ctx.Done():
   162  			return nil, ctx.Err()
   163  		default:
   164  			// No value ready, moving on
   165  		}
   166  	}
   167  
   168  	return &lock, err
   169  }
   170  
   171  // Run runs a command, write stdout and stderr to the logger and return an error
   172  // if it fails
   173  func (s *Shell) Run(command string, arg ...string) error {
   174  	s.Promptf("%s", process.FormatCommand(command, arg))
   175  
   176  	return s.RunWithoutPrompt(command, arg...)
   177  }
   178  
   179  // RunWithoutPrompt runs a command, write stdout and stderr to the logger and
   180  // return an error if it fails. Notably it doesn't show a prompt.
   181  func (s *Shell) RunWithoutPrompt(command string, arg ...string) error {
   182  	cmd, err := s.buildCommand(command, arg...)
   183  	if err != nil {
   184  		s.Errorf("Error building command: %v", err)
   185  		return err
   186  	}
   187  
   188  	return s.executeCommand(cmd, s.Writer, executeFlags{
   189  		Stdout: true,
   190  		Stderr: true,
   191  		PTY:    s.PTY,
   192  	})
   193  }
   194  
   195  // RunAndCapture runs a command and captures the output for processing. Stdout is captured, but
   196  // stderr isn't. If the shell is in debug mode then the command will be eched and both stderr
   197  // and stdout will be written to the logger. A PTY is never used for RunAndCapture.
   198  func (s *Shell) RunAndCapture(command string, arg ...string) (string, error) {
   199  	if s.Debug {
   200  		s.Promptf("%s", process.FormatCommand(command, arg))
   201  	}
   202  
   203  	cmd, err := s.buildCommand(command, arg...)
   204  	if err != nil {
   205  		return "", err
   206  	}
   207  
   208  	var b bytes.Buffer
   209  
   210  	err = s.executeCommand(cmd, &b, executeFlags{
   211  		Stdout: true,
   212  		Stderr: false,
   213  		PTY:    false,
   214  	})
   215  	if err != nil {
   216  		return "", err
   217  	}
   218  
   219  	return strings.TrimSpace(b.String()), nil
   220  }
   221  
   222  // RunScript is like Run, but the target is an interpreted script which has
   223  // some extra checks to ensure it gets to the correct interpreter. Extra environment vars
   224  // can also be passed the the script
   225  func (s *Shell) RunScript(path string, extra *env.Environment) error {
   226  	var command string
   227  	var args []string
   228  
   229  	// we apply a variety of "feature detection checks" to figure out how we should
   230  	// best run the script
   231  
   232  	var isBash = filepath.Ext(path) == "" || filepath.Ext(path) == ".sh"
   233  	var isWindows = runtime.GOOS == "windows"
   234  
   235  	switch {
   236  	case isWindows && isBash:
   237  		if s.Debug {
   238  			s.Commentf("Attempting to run %s with Bash for Windows", path)
   239  		}
   240  		// Find Bash, either part of Cygwin or MSYS. Must be in the path
   241  		bashPath, err := s.AbsolutePath("bash.exe")
   242  		if err != nil {
   243  			return fmt.Errorf("Error finding bash.exe, needed to run scripts: %v. "+
   244  				"Is Git for Windows installed and correctly in your PATH variable?", err)
   245  		}
   246  		command = bashPath
   247  		args = []string{"-c", filepath.ToSlash(path)}
   248  
   249  	case !isWindows && isBash:
   250  		command = "/bin/bash"
   251  		args = []string{"-c", path}
   252  
   253  	default:
   254  		command = path
   255  		args = []string{}
   256  	}
   257  
   258  	cmd, err := s.buildCommand(command, args...)
   259  	if err != nil {
   260  		s.Errorf("Error building command: %v", err)
   261  		return err
   262  	}
   263  
   264  	// Combine the two slices of env, let the latter overwrite the former
   265  	currentEnv := env.FromSlice(cmd.Env)
   266  	customEnv := currentEnv.Merge(extra)
   267  	cmd.Env = customEnv.ToSlice()
   268  
   269  	return s.executeCommand(cmd, s.Writer, executeFlags{
   270  		Stdout: true,
   271  		Stderr: true,
   272  		PTY:    s.PTY,
   273  	})
   274  }
   275  
   276  // buildCommand returns an exec.Cmd that runs in the context of the shell
   277  func (s *Shell) buildCommand(name string, arg ...string) (*exec.Cmd, error) {
   278  	// Always use absolute path as Windows has a hard time finding executables in it's path
   279  	absPath, err := s.AbsolutePath(name)
   280  	if err != nil {
   281  		return nil, err
   282  	}
   283  
   284  	cmd := exec.Command(absPath, arg...)
   285  	cmd.Env = s.Env.ToSlice()
   286  	cmd.Dir = s.wd
   287  
   288  	// Add env that commands expect a shell to set
   289  	cmd.Env = append(cmd.Env,
   290  		`PWD=`+s.wd,
   291  	)
   292  
   293  	return cmd, nil
   294  }
   295  
   296  type executeFlags struct {
   297  	// Whether to capture stdout
   298  	Stdout bool
   299  
   300  	// Whether to capture stderr
   301  	Stderr bool
   302  
   303  	// Run the command in a PTY
   304  	PTY bool
   305  }
   306  
   307  func (s *Shell) executeCommand(cmd *exec.Cmd, w io.Writer, flags executeFlags) error {
   308  	signals := make(chan os.Signal, 1)
   309  	signal.Notify(signals, os.Interrupt,
   310  		syscall.SIGHUP,
   311  		syscall.SIGTERM,
   312  		syscall.SIGINT,
   313  		syscall.SIGQUIT)
   314  	defer signal.Stop(signals)
   315  
   316  	go func() {
   317  		// forward signals to the process
   318  		for sig := range signals {
   319  			if err := signalProcess(cmd, sig); err != nil {
   320  				s.Errorf("Error passing signal to child process: %v", err)
   321  			}
   322  		}
   323  	}()
   324  
   325  	cmdStr := process.FormatCommand(cmd.Path, cmd.Args[1:])
   326  
   327  	if s.Debug {
   328  		t := time.Now()
   329  		defer func() {
   330  			s.Commentf("↳ Command completed in %v", time.Now().Sub(t))
   331  		}()
   332  	}
   333  
   334  	if flags.PTY {
   335  		pty, err := process.StartPTY(cmd)
   336  		if err != nil {
   337  			return fmt.Errorf("Error starting PTY: %v", err)
   338  		}
   339  
   340  		// Copy the pty to our buffer. This will block until it EOF's
   341  		// or something breaks.
   342  		_, err = io.Copy(w, pty)
   343  		if e, ok := err.(*os.PathError); ok && e.Err == syscall.EIO {
   344  			// We can safely ignore this error, because it's just the PTY telling us
   345  			// that it closed successfully.
   346  			// See https://github.com/buildkite/agent/pull/34#issuecomment-46080419
   347  		}
   348  
   349  		// Commands like tput expect a TERM value for a PTY
   350  		cmd.Env = append(cmd.Env, `TERM=`+termType)
   351  	} else {
   352  		cmd.Stdout = nil
   353  		cmd.Stderr = nil
   354  		cmd.Stdin = nil
   355  
   356  		if flags.Stdout {
   357  			cmd.Stdout = w
   358  		} else if s.Debug {
   359  			stdOutStreamer := NewLoggerStreamer(s.Logger)
   360  			defer stdOutStreamer.Close()
   361  			cmd.Stdout = stdOutStreamer
   362  		}
   363  
   364  		if flags.Stderr {
   365  			cmd.Stderr = w
   366  		} else if s.Debug {
   367  			stdErrStreamer := NewLoggerStreamer(s.Logger)
   368  			defer stdErrStreamer.Close()
   369  			cmd.Stderr = stdErrStreamer
   370  		}
   371  
   372  		if err := cmd.Start(); err != nil {
   373  			return errors.Wrapf(err, "Error starting `%s`", cmdStr)
   374  		}
   375  	}
   376  
   377  	if err := cmd.Wait(); err != nil {
   378  		return errors.Wrapf(err, "Error running `%s`", cmdStr)
   379  	}
   380  
   381  	return nil
   382  }
   383  
   384  // GetExitCode extracts an exit code from an error where the platform supports it,
   385  // otherwise returns 0 for no error and 1 for an error
   386  func GetExitCode(err error) int {
   387  	if err == nil {
   388  		return 0
   389  	}
   390  	switch cause := errors.Cause(err).(type) {
   391  	case *ExitError:
   392  		return cause.Code
   393  
   394  	case *exec.ExitError:
   395  		// The program has exited with an exit code != 0
   396  		// There is no platform independent way to retrieve
   397  		// the exit code, but the following will work on Unix/macOS
   398  		if status, ok := cause.Sys().(syscall.WaitStatus); ok {
   399  			return status.ExitStatus()
   400  		}
   401  	}
   402  	return 1
   403  }
   404  
   405  func IsExitError(err error) bool {
   406  	switch errors.Cause(err).(type) {
   407  	case *ExitError:
   408  		return true
   409  	case *exec.ExitError:
   410  		return true
   411  	}
   412  	return false
   413  }
   414  
   415  // ExitError is an error that carries a shell exit code
   416  type ExitError struct {
   417  	Code    int
   418  	Message string
   419  }
   420  
   421  // Error returns the string message and fulfils the error interface
   422  func (ee *ExitError) Error() string {
   423  	return ee.Message
   424  }