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 }