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 }