github.com/tiagovtristao/plz@v13.4.0+incompatible/src/parse/asp/exec.go (about)

     1  package asp
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"os"
     8  	"os/exec"
     9  	"strings"
    10  	"sync"
    11  
    12  	"github.com/thought-machine/please/src/core"
    13  )
    14  
    15  type execKey struct {
    16  	args       string
    17  	wantStdout bool
    18  	wantStderr bool
    19  }
    20  
    21  type execPromise struct {
    22  	wg   *sync.WaitGroup
    23  	lock sync.Mutex
    24  }
    25  type execOut struct {
    26  	out     string
    27  	success bool
    28  }
    29  
    30  var (
    31  	// The output from doExec() is memoized by default
    32  	execCachedOuts sync.Map
    33  
    34  	// The absolute path of commands
    35  	execCmdPath sync.Map
    36  
    37  	execPromisesLock sync.Mutex
    38  	execPromises     map[execKey]*execPromise
    39  )
    40  
    41  func init() {
    42  	execPromisesLock.Lock()
    43  	defer execPromisesLock.Unlock()
    44  
    45  	const initCacheSize = 8
    46  	execPromises = make(map[execKey]*execPromise, initCacheSize)
    47  }
    48  
    49  // doExec fork/exec's a command and returns the output as a string.  exec
    50  // accepts either a string or a list of commands and arguments.  The output from
    51  // exec() is memoized by default to prevent side effects and aid in performance
    52  // of duplicate calls to the same command with the same arguments (e.g. `git
    53  // rev-parse --short HEAD`).  The output from exec()'ed commands must be
    54  // reproducible.  If storeNegative is true, it is possible for success to return
    55  // successfully and return an error (i.e. we're expecing a command to fail and
    56  // want to cache the failure).
    57  //
    58  // NOTE: Commands that rely on the current working directory must not be cached.
    59  func doExec(s *scope, cmdIn pyObject, wantStdout bool, wantStderr bool, cacheOutput bool, storeNegative bool) (pyObj pyObject, success bool, err error) {
    60  	if !wantStdout && !wantStderr {
    61  		return s.Error("exec() must have at least stdout or stderr set to true, both can not be false"), false, nil
    62  	}
    63  
    64  	var argv []string
    65  	if isType(cmdIn, "str") {
    66  		argv = strings.Fields(string(cmdIn.(pyString)))
    67  	} else if isType(cmdIn, "list") {
    68  		pl := cmdIn.(pyList)
    69  		argv = make([]string, 0, pl.Len())
    70  		for i := 0; i < pl.Len(); i++ {
    71  			argv = append(argv, pl[i].String())
    72  		}
    73  	}
    74  
    75  	// The cache key is tightly coupled to the operating parameters
    76  	key := execMakeKey(argv, wantStdout, wantStderr)
    77  
    78  
    79  	if cacheOutput {
    80  		out, found := execGetCachedOutput(key, argv)
    81  		if found {
    82  			return pyString(out.out), out.success, nil
    83  		}
    84  	}
    85  
    86  	ctx, cancel := context.WithTimeout(context.TODO(), core.TargetTimeoutOrDefault(nil, s.state))
    87  	defer cancel()
    88  
    89  	cmdPath, err := execFindCmd(argv[0])
    90  	if err != nil {
    91  		return s.Error("exec() unable to find %q in PATH %q", argv[0], os.Getenv("PATH")), false, err
    92  	}
    93  	cmdArgs := argv[1:]
    94  
    95  	var out []byte
    96  	cmd := exec.CommandContext(ctx, cmdPath, cmdArgs...)
    97  	if wantStdout && wantStderr {
    98  		out, err = cmd.CombinedOutput()
    99  	} else {
   100  		buf := &bytes.Buffer{}
   101  		switch {
   102  		case wantStdout:
   103  			cmd.Stderr = nil
   104  			cmd.Stdout = buf
   105  		case wantStderr:
   106  			cmd.Stderr = buf
   107  			cmd.Stdout = nil
   108  		}
   109  
   110  		err = cmd.Run()
   111  		out = buf.Bytes()
   112  	}
   113  	out = bytes.TrimSpace(out)
   114  	outStr := string(out)
   115  
   116  	if err != nil {
   117  		if cacheOutput && storeNegative {
   118  			// Completed successfully and returned an error.  Store the negative value
   119  			// since we're also returning an error, which tells the caller to
   120  			// fallthrough their logic if a command returns with a non-zero exit code.
   121  			outStr = execSetCachedOutput(key, argv, &execOut{out: outStr, success: false})
   122  			return pyString(outStr), true, err
   123  		}
   124  
   125  		return pyString(fmt.Sprintf("exec() unable to run command %q: %v", argv, err)), false, err
   126  	}
   127  
   128  	if cacheOutput {
   129  		outStr = execSetCachedOutput(key, argv, &execOut{out: outStr, success: true})
   130  	}
   131  
   132  	return pyString(outStr), true, nil
   133  }
   134  
   135  // execFindCmd looks for a command using PATH and returns a cached abspath.
   136  func execFindCmd(cmdName string) (path string, err error) {
   137  	pathRaw, found := execCmdPath.Load(cmdName)
   138  	if !found {
   139  		// Perform a racy LookPath assuming the path is stable between concurrent
   140  		// lookups for the same cmdName.
   141  		path, err := exec.LookPath(cmdName)
   142  		if err != nil {
   143  			return "", err
   144  		}
   145  
   146  		// First write wins
   147  		pathRaw, _ = execCmdPath.LoadOrStore(cmdName, path)
   148  	}
   149  
   150  	return pathRaw.(string), nil
   151  }
   152  
   153  // execGetCachedOutput returns the output if found, sets found to true if found,
   154  // and returns a held promise that must be completed.
   155  func execGetCachedOutput(key execKey, args []string) (output *execOut, found bool) {
   156  	outputRaw, found := execCachedOuts.Load(key)
   157  	if found {
   158  		return outputRaw.(*execOut), true
   159  	}
   160  
   161  	// Re-check with promises exclusive lock held
   162  	execPromisesLock.Lock()
   163  	outputRaw, found = execCachedOuts.Load(key)
   164  	if found {
   165  		execPromisesLock.Unlock()
   166  		return outputRaw.(*execOut), true
   167  	}
   168  
   169  	// Create a new promise.  Increment the WaitGroup while the lock is held.
   170  	promise, found := execPromises[key]
   171  	if !found {
   172  		promise = &execPromise{
   173  			wg: &sync.WaitGroup{},
   174  		}
   175  		promise.wg.Add(1)
   176  		execPromises[key] = promise
   177  
   178  		execPromisesLock.Unlock()
   179  		return nil, false // Let the caller fulfill the promise
   180  	}
   181  	execPromisesLock.Unlock()
   182  
   183  	promise.wg.Wait() // Block until the promise is completed
   184  	execPromisesLock.Lock()
   185  	defer execPromisesLock.Unlock()
   186  
   187  	outputRaw, found = execCachedOuts.Load(key)
   188  	if found {
   189  		return outputRaw.(*execOut), true
   190  	}
   191  
   192  	if !found {
   193  		panic(fmt.Sprintf("blocked on promise %v, didn't find value", key))
   194  	}
   195  
   196  	return outputRaw.(*execOut), true
   197  }
   198  
   199  // execGitBranch returns the output of a git_branch() command.
   200  //
   201  // git_branch() returns the output of `git symbolic-ref -q --short HEAD`
   202  func execGitBranch(s *scope, args []pyObject) pyObject {
   203  	short := args[0].IsTruthy()
   204  
   205  	cmdIn := make([]pyObject, 3, 5)
   206  	cmdIn[0] = pyString("git")
   207  	cmdIn[1] = pyString("symbolic-ref")
   208  	cmdIn[2] = pyString("-q")
   209  	if short {
   210  		cmdIn = append(cmdIn, pyString("--short"))
   211  	}
   212  	cmdIn = append(cmdIn, pyString("HEAD"))
   213  
   214  	wantStdout := true
   215  	wantStderr := false
   216  	cacheOutput := true
   217  	storeNegative := true
   218  	gitSymRefResult, success, err := doExec(s, pyList(cmdIn), wantStdout, wantStderr, cacheOutput, storeNegative)
   219  	switch {
   220  	case success && err == nil:
   221  		return gitSymRefResult
   222  	case success && err != nil:
   223  		//  ran a thing that failed, handle case below
   224  	case !success && err == nil:
   225  		//  previous invocation cached a negative value
   226  	default:
   227  		return s.Error("exec() %q failed: %v", pyList(cmdIn).String(), err)
   228  	}
   229  
   230  	// We're in a detached head
   231  	cmdIn = make([]pyObject, 4)
   232  	cmdIn[0] = pyString("git")
   233  	cmdIn[1] = pyString("show")
   234  	cmdIn[2] = pyString("-q")
   235  	cmdIn[3] = pyString("--format=%D")
   236  	storeNegative = false
   237  	gitShowResult, success, err := doExec(s, pyList(cmdIn), wantStdout, wantStderr, cacheOutput, storeNegative)
   238  	if !success {
   239  		// doExec returns a formatted error string
   240  		return s.Error("exec() %q failed: %v", pyList(cmdIn).String(), err)
   241  	}
   242  
   243  	results := strings.Fields(gitShowResult.String())
   244  	if len(results) == 0 {
   245  		// We're seeing something unknown and unexpected, go back to the original error message
   246  		return gitSymRefResult
   247  	}
   248  
   249  	return pyString(results[len(results)-1])
   250  }
   251  
   252  // execGitCommit returns the output of a git_commit() command.
   253  //
   254  // git_commit() returns the output of `git rev-parse HEAD`
   255  func execGitCommit(s *scope, args []pyObject) pyObject {
   256  	cmdIn := []pyObject{
   257  		pyString("git"),
   258  		pyString("rev-parse"),
   259  		pyString("HEAD"),
   260  	}
   261  
   262  	wantStdout := true
   263  	wantStderr := false
   264  	cacheOutput := true
   265  	storeNegative := false
   266  	// No error handling required since we don't want to retry
   267  	pyResult, success, err := doExec(s, pyList(cmdIn), wantStdout, wantStderr, cacheOutput, storeNegative)
   268  	if !success {
   269  		return s.Error("git_commit() failed: %v", err)
   270  	}
   271  
   272  	return pyResult
   273  }
   274  
   275  // execGitShow returns the output of a git_show() command with a strict format.
   276  //
   277  // git_show() returns the output of `git show -s --format=%{fmt}`
   278  //
   279  // %ci == commit-date:
   280  //   `git show -s --format=%ci` = 2018-12-10 00:53:35 -0800
   281  func execGitShow(s *scope, args []pyObject) pyObject {
   282  	formatVerb := args[0].(pyString)
   283  	switch formatVerb {
   284  	case "%H": // commit hash
   285  	case "%T": // tree hash
   286  	case "%P": // parent hashes
   287  	case "%an": // author name
   288  	case "%ae": // author email
   289  	case "%at": // author date, UNIX timestamp
   290  	case "%cn": // committer name
   291  	case "%ce": // committer email
   292  	case "%ct": // committer date, UNIX timestamp
   293  	case "%D": // ref names without the " (", ")" wrapping.
   294  	case "%e": // encoding
   295  	case "%s": // subject
   296  	case "%f": // sanitized subject line, suitable for a filename
   297  	case "%b": // body
   298  	case "%B": // raw body (unwrapped subject and body)
   299  	case "%N": // commit notes
   300  	case "%GG": // raw verification message from GPG for a signed commit
   301  	case "%G?": // show "G" for a good (valid) signature, "B" for a bad signature, "U" for a good signature with unknown validity, "X" for a good signature that has expired, "Y" for a good signature made by an expired key, "R" for a good signature made by a revoked key, "E" if the signature cannot be checked (e.g. missing key) and "N" for no signature
   302  	case "%GS": // show the name of the signer for a signed commit
   303  	case "%GK": // show the key used to sign a signed commit
   304  	case "%n": // newline
   305  	case "%%": // a raw %
   306  	default:
   307  		return s.Error("git_show() unsupported format code: %q", formatVerb)
   308  	}
   309  
   310  	cmdIn := []pyObject{
   311  		pyString("git"),
   312  		pyString("show"),
   313  		pyString("-s"),
   314  		pyString(fmt.Sprintf("--format=%s", formatVerb)),
   315  	}
   316  
   317  	wantStdout := true
   318  	wantStderr := false
   319  	cacheOutput := true
   320  	storeNegative := false
   321  	pyResult, success, err := doExec(s, pyList(cmdIn), wantStdout, wantStderr, cacheOutput, storeNegative)
   322  	if !success {
   323  		return s.Error("git_show() failed: %v", err)
   324  	}
   325  	return pyResult
   326  }
   327  
   328  // execGitState returns the output of a git_state() command.
   329  //
   330  // git_state() returns the output of `git status --porcelain`.
   331  func execGitState(s *scope, args []pyObject) pyObject {
   332  	cleanLabel := args[0].(pyString)
   333  	dirtyLabel := args[1].(pyString)
   334  
   335  	cmdIn := []pyObject{
   336  		pyString("git"),
   337  		pyString("status"),
   338  		pyString("--porcelain"),
   339  	}
   340  
   341  	wantStdout := true
   342  	wantStderr := false
   343  	cacheOutput := true
   344  	storeNegative := false
   345  	pyResult, success, err := doExec(s, pyList(cmdIn), wantStdout, wantStderr, cacheOutput, storeNegative)
   346  	if !success {
   347  		return s.Error("git_state() failed: %v", err)
   348  	}
   349  
   350  	if !isType(pyResult, "str") {
   351  		return pyResult
   352  	}
   353  
   354  	result := pyResult.String()
   355  	if len(result) != 0 {
   356  		return dirtyLabel
   357  	}
   358  	return cleanLabel
   359  }
   360  
   361  // execMakeKey returns an execKey.
   362  func execMakeKey(args []string, wantStdout bool, wantStderr bool) execKey {
   363  	return execKey{
   364  		args:       strings.Join(args, ""),
   365  		wantStdout: wantStdout,
   366  		wantStderr: wantStderr,
   367  	}
   368  }
   369  
   370  // execSetCachedOutput sets a value to be cached
   371  func execSetCachedOutput(key execKey, args []string, output *execOut) string {
   372  	outputRaw, alreadyLoaded := execCachedOuts.LoadOrStore(key, output)
   373  	if alreadyLoaded {
   374  		panic(fmt.Sprintf("race detected for key %v", key))
   375  	}
   376  
   377  	execPromisesLock.Lock()
   378  	defer execPromisesLock.Unlock()
   379  	if promise, found := execPromises[key]; found {
   380  		delete(execPromises, key)
   381  		promise.lock.Lock()
   382  		defer promise.lock.Unlock()
   383  		promise.wg.Done()
   384  	}
   385  
   386  	out := outputRaw.(*execOut).out
   387  	return out
   388  }