go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/exec/cmd.go (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package exec 16 17 import ( 18 "bufio" 19 "bytes" 20 "context" 21 "fmt" 22 "io" 23 "os" 24 "os/exec" 25 "path/filepath" 26 "runtime" 27 "strings" 28 "sync" 29 30 "go.chromium.org/luci/common/errors" 31 "go.chromium.org/luci/common/system/environ" 32 33 "go.chromium.org/luci/common/exec/internal/execmockctx" 34 ) 35 36 var curExe, curExeErr = os.Executable() 37 38 // ErrUseConstructor is returned from methods of Cmd if the Cmd struct was 39 // created without using Command or CommandContext. 40 var ErrUseConstructor = errors.New("you must use Command or CommandContext to create a usable Cmd") 41 42 type mockPayload struct { 43 // mocker is only set if mocking is enabled for this process. 44 mocker execmockctx.CreateMockInvocation 45 46 // We store the original user-provided `name` for this Cmd in case we need to 47 // fall back to passthrough mode. 48 originalName string 49 50 // If mocked, this is a unique ID for this invocation. 0 means 'not mocked'. 51 invocationID uint64 52 53 // true iff this Cmd should be `chatty` 54 chatty bool 55 56 // buffering stdout for Output (and other modes), or stdout+stderr for CombinedOutput 57 stdoutBuf *bytes.Buffer 58 // buffering stderr for Output (and other modes). 59 stderrBuf *bytes.Buffer 60 } 61 62 // Cmd behaves like an "os/exec".Cmd, except that it can be mocked using the 63 // "go.chromium.org/luci/common/exec/execmock" package. 64 // 65 // Must be created with Command or CommandContext. 66 // 67 // Mocking this Cmd allows the test program to substitute this Cmd invocation 68 // transparently with another, customized, subprocess. 69 type Cmd struct { 70 // We embed the *Cmd without a field name so that users can drop in our struct 71 // where they previously had an *"os/exec".Cmd with minimal code changes. 72 *exec.Cmd 73 74 // set to true in Command and CommandContext. 75 safelyCreated bool 76 77 mock *mockPayload 78 waitDefer func() 79 } 80 81 func multiWriter(a, b io.Writer) io.Writer { 82 if a == nil { 83 return b 84 } 85 if b == nil { 86 return a 87 } 88 return io.MultiWriter(a, b) 89 } 90 91 var chattyMu sync.Mutex 92 93 // chattySession implements a sprintf-like function to render a line which will 94 // go into an internal buffer, and a closer, which will write the whole buffer 95 // synchronously. 96 // 97 // This construction reduces the amount of mixed chatty output when running 98 // multiple mocked processes in parallel. 99 // 100 // Unfortunately, in chatty mode, there will be some synchronization introduced 101 // to the application which is not present in non-chatty mode, but this probably 102 // can't be helped much; Firing the buffered blocks off to a goroutine sort of 103 // works, but you need to introduce a way to close the chatty channel and wait 104 // for the goroutine to complete; otherwise you risk losing random chunks (or 105 // maybe all!) of the chatty output. 106 type chattySession struct { 107 buf []string 108 } 109 110 func (c *chattySession) printlnf(msg string, args ...any) { 111 c.buf = append(c.buf, fmt.Sprintf(msg, args...)) 112 } 113 114 func (c *chattySession) dump() { 115 toWrite := strings.Join(c.buf, "\n") + "\n" 116 chattyMu.Lock() 117 defer chattyMu.Unlock() 118 os.Stderr.WriteString(toWrite) 119 } 120 121 // applyMock will look up a mock from the `mocker` and then adjust the state of 122 // the underlying Cmd to run in the mocked context. 123 // 124 // This includes adjusting the environment (to set the mock key) changing 125 // c.Path (to point at our own binary), setting c.stdxxxBuf if we're in chatty 126 // mode. 127 // 128 // It is possible for the exact mock result to be `passthrough` which means that 129 // `applyMock` will restore this Cmd to it's original state. 130 // 131 // applyMock will only ever do it's action to cover a single Start event; just 132 // like the underlying exec.Cmd object, this Cmd is not meant to be used 133 // multiple times (it will enter an inconsistent state). 134 func (c *Cmd) applyMock() (restore func(), mock *mockPayload, err error) { 135 if !c.safelyCreated { 136 c.Err = ErrUseConstructor 137 } 138 if c.Err != nil { 139 return nil, nil, c.Err 140 } 141 if c.mock == nil { 142 return func() {}, nil, nil 143 } 144 145 mock = c.mock 146 invocation, err := mock.mocker(execmockctx.NewMockCriteria(c.Cmd), &c.Cmd.Process) 147 c.mock = nil // we should never try to mock this Cmd again 148 if err != nil { 149 c.Err = err 150 return nil, nil, c.Err 151 } 152 if invocation == nil { 153 // passthrough mode; we have to un-mock and possibly do a LookPath. 154 c.Path = mock.originalName 155 if filepath.Base(c.Path) == c.Path { 156 // NOTE: We don't want to use LookPath from this module's namespace to 157 // prevent accidental overrides, so we use exec.LookPath explicitly. 158 lp, err := exec.LookPath(c.Path) 159 if lp != "" { 160 c.Path = lp 161 } 162 if err != nil { 163 c.Err = err 164 } 165 } 166 return func() {}, nil, c.Err 167 } 168 169 mock.invocationID = invocation.ID 170 171 oldPath := c.Path 172 oldEnv := c.Env 173 174 sysEnv := environ.System() 175 176 if mock.chatty { 177 // stdoutBuf or stderrBuf may already be set if StdxxxPipe have already been 178 // called. If they have, then we don't want to touch them again, here, since 179 // StdxxxPipe have already set them to be correctly read by Wait()'s chatty 180 // printer. 181 if mock.stdoutBuf == nil { 182 mock.stdoutBuf = &bytes.Buffer{} 183 c.Stdout = multiWriter(mock.stdoutBuf, c.Stdout) 184 } 185 if mock.stderrBuf == nil { 186 mock.stderrBuf = &bytes.Buffer{} 187 c.Stderr = multiWriter(mock.stderrBuf, c.Stderr) 188 } 189 190 var chat chattySession 191 defer chat.dump() 192 193 chat.printlnf("execmock: Start(invocation=%d): %q", invocation.ID, c.Args) 194 if c.Dir != "" { 195 chat.printlnf(" cwd: %s", c.Dir) 196 } 197 if c.Env != nil { 198 diffEnv := sysEnv.Clone() 199 cmdEnv := environ.New(c.Env) 200 _ = cmdEnv.Iter(func(k, v string) error { 201 sysVal, sysHas := diffEnv.Lookup(k) 202 if sysHas { 203 if sysVal != v { 204 chat.printlnf(" env~ %s=%s", k, v) 205 } 206 } else { 207 chat.printlnf(" env+ %s=%s", k, v) 208 } 209 diffEnv.Remove(k) 210 return nil 211 }) 212 // diffEnv now contains envvars which aren't in cmdEnv 213 _ = diffEnv.Iter(func(k, v string) error { 214 chat.printlnf(" env- %s", k) 215 return nil 216 }) 217 } 218 } 219 220 if curExeErr != nil { 221 // should be impossible due to check in CommandContext... but double check 222 // here just in case. 223 panic(errors.Annotate(curExeErr, "impossible").Err()) 224 } 225 c.Path = curExe 226 if c.Env == nil { 227 c.Env = append(sysEnv.Sorted(), invocation.EnvVar) 228 } else { 229 c.Env = append(c.Env, invocation.EnvVar) 230 } 231 232 if mock.chatty { 233 c.waitDefer = func() { 234 // not entirely sure how ProcessState could be nil, but check it, just the 235 // same. 236 if c.ProcessState != nil { 237 c.waitDefer = nil 238 239 outbuf, errbuf := mock.stdoutBuf, mock.stderrBuf 240 runnerPanic, runnerErr := invocation.GetErrorOutput() 241 242 var chat chattySession 243 defer chat.dump() 244 245 chat.printlnf("execmock: Wait(invocation=%d): %v", mock.invocationID, c.ProcessState) 246 if runnerErr != nil { 247 chat.printlnf(" ERROR: %s", runnerErr) 248 } 249 if runnerPanic != "" { 250 chat.printlnf(" PANIC:") 251 for scn := bufio.NewScanner(strings.NewReader(runnerPanic)); scn.Scan(); { 252 chat.printlnf(" !> %s", scn.Bytes()) 253 } 254 } 255 256 // we use bytes.NewReader because `buf` itself may still be held by 257 // CombinedOutput. This won't actually do any additional allocations, but 258 // it will prevent the buffer in `buf` from being consumed before 259 // CombinedOutput can return it. 260 if outbuf != nil { 261 for scn := bufio.NewScanner(bytes.NewReader(outbuf.Bytes())); scn.Scan(); { 262 chat.printlnf(" O> %s", scn.Bytes()) 263 } 264 } 265 if errbuf != nil { 266 for scn := bufio.NewScanner(bytes.NewReader(errbuf.Bytes())); scn.Scan(); { 267 chat.printlnf(" E> %s", scn.Bytes()) 268 } 269 } 270 } 271 } 272 } 273 274 restore = func() { 275 c.Path = oldPath 276 c.Env = oldEnv 277 } 278 return 279 } 280 281 // CombinedOutput operates the same as "os/exec".Cmd.CombinedOutput. 282 func (c *Cmd) CombinedOutput() ([]byte, error) { 283 stdoutSet := c.Stdout != nil 284 stderrSet := c.Stderr != nil 285 286 restore, mock, err := c.applyMock() 287 if err != nil { 288 return nil, err 289 } 290 defer restore() 291 292 if mock == nil || !mock.chatty { 293 return c.Cmd.CombinedOutput() 294 } 295 296 // we can't use c.Cmd.CombinedOutput in chatty mode because they assert that 297 // Stdout/Stderr aren't already set. 298 // 299 // However, in chatty mode we already have c.stdoutBuf which will collect 300 // exactly what we want for CombinedOutput anyway. 301 if stdoutSet { 302 return nil, errors.New("execmock: Stdout already set") 303 } 304 if stderrSet { 305 return nil, errors.New("execmock: Stderr already set") 306 } 307 308 // At this point applyMock has set up two buffers to capture stdout/stderr. 309 // We want to combine them, so just remove stderrBuf and duplicate stdoutBuf. 310 mock.stderrBuf = nil 311 c.Stderr = mock.stdoutBuf 312 313 // keep a ref to stdoutBuf so that the post-Wait chatty printer doesn't 314 // replace it with nil. 315 buf := mock.stdoutBuf 316 err = c.Run() 317 return buf.Bytes(), err 318 } 319 320 // Output operates the same as "os/exec".Cmd.Output. 321 func (c *Cmd) Output() ([]byte, error) { 322 stdoutSet := c.Stdout != nil 323 captureStderr := c.Cmd.Stderr == nil 324 325 restore, mock, err := c.applyMock() 326 if err != nil { 327 return nil, err 328 } 329 defer restore() 330 331 if mock == nil || !mock.chatty { 332 return c.Cmd.Output() 333 } 334 335 // we can't use c.Cmd.Output in chatty mode because it asserts that Stdout is 336 // not already set. 337 if stdoutSet { 338 return nil, errors.New("execmock: Stdout already set") 339 } 340 341 // keep refs to stdxxxBuf so we can use them after the post-Wait chatty 342 // printer nil's them out. 343 stdoutBuf := mock.stdoutBuf 344 var stderrBuf *bytes.Buffer 345 if captureStderr { 346 stderrBuf = mock.stderrBuf 347 } 348 349 err = c.Run() 350 if err != nil && captureStderr { 351 if xerr, ok := err.(*exec.ExitError); ok { 352 xerr.Stderr = stderrBuf.Bytes() 353 } 354 } 355 356 return stdoutBuf.Bytes(), err 357 } 358 359 // Run operates the same as "os/exec".Cmd.Run. 360 func (c *Cmd) Run() error { 361 if err := c.Start(); err != nil { 362 return err 363 } 364 return c.Wait() 365 } 366 367 // Start operates the same as "os/exec".Cmd.Start. 368 func (c *Cmd) Start() error { 369 restore, _, err := c.applyMock() 370 if err != nil { 371 return err 372 } 373 defer restore() 374 375 return c.Cmd.Start() 376 } 377 378 // Wait operates the same as "os/exec".Cmd.Wait. 379 func (c *Cmd) Wait() error { 380 if c.waitDefer != nil { 381 defer c.waitDefer() 382 } 383 384 return c.Cmd.Wait() 385 } 386 387 type fakePipeReader struct { 388 io.Reader 389 io.Closer 390 } 391 392 // StderrPipe operates the same as "os/exec".Cmd.StderrPipe. 393 func (c *Cmd) StderrPipe() (io.ReadCloser, error) { 394 reader, err := c.Cmd.StderrPipe() 395 if err != nil || c.mock == nil || !c.mock.chatty { 396 return reader, err 397 } 398 399 c.mock.stderrBuf = &bytes.Buffer{} 400 return &fakePipeReader{io.TeeReader(reader, c.mock.stderrBuf), reader}, nil 401 } 402 403 // StdoutPipe operates the same as "os/exec".Cmd.StdoutPipe. 404 func (c *Cmd) StdoutPipe() (io.ReadCloser, error) { 405 reader, err := c.Cmd.StdoutPipe() 406 if err != nil || c.mock == nil || !c.mock.chatty { 407 return reader, err 408 } 409 410 c.mock.stdoutBuf = &bytes.Buffer{} 411 return &fakePipeReader{io.TeeReader(reader, c.mock.stdoutBuf), reader}, nil 412 } 413 414 // We don't need to emulate these; the native cmd impl should take care of it. 415 // func (c *Cmd) String() string 416 // func (c *Cmd) StdinPipe() (io.WriteCloser, error) 417 418 // mocked in tests 419 var getMockCreator = execmockctx.GetMockCreator 420 421 func commandImpl(ctx context.Context, name string, arg []string, mkFn func(ctx context.Context, name string, arg ...string) *exec.Cmd) *Cmd { 422 ret := &Cmd{safelyCreated: true} 423 mocker, chatty := getMockCreator(ctx) 424 if mocker != nil { 425 ret.mock = &mockPayload{mocker: mocker, chatty: chatty} 426 } else { 427 ret.Cmd = mkFn(ctx, name, arg...) 428 return ret 429 } 430 431 ret.mock.originalName = name 432 if curExeErr != nil { 433 ret.Err = errors.Annotate(curExeErr, "cannot resolve os.Executable for execmock").Err() 434 return ret 435 } 436 // We use curExe as the target executable because exec.CommandContext does 437 // an implicit LookPath when `name` is non-absolute; If this is some binary 438 // like "git" then CommandContext would do a LookPath "for real" even during 439 // tests. 440 // 441 // We generate Path and Args as if they were resolved though, so that 442 // programs can print them without unexpected output. 443 ret.Cmd = mkFn(ctx, curExe) 444 if filepath.Base(name) != name { 445 // The user gave a name which would not be resolved via PATH; restore it. 446 ret.Cmd.Path = name 447 } else { 448 // The user gave a `name` with the intent of resolving it from PATH. 449 // 450 // In this branch we "did the LookPath", and Path is expected to be (some) 451 // absolute path; We know this context contains test mocks, so make 452 // something up here which will be obvious if printed. 453 if runtime.GOOS == "windows" { 454 ret.Cmd.Path = filepath.Join(`C:\execmock\PATH`, name) 455 } else { 456 ret.Cmd.Path = filepath.Join(`/execmock/PATH`, name) 457 } 458 } 459 ret.Cmd.Args = append([]string{name}, arg...) 460 return ret 461 462 } 463 464 // CommandContext behaves like "os/exec".CommandContext, except that it can be 465 // mocked via the "go.chromium.org/luci/common/exec/execmock" package. 466 func CommandContext(ctx context.Context, name string, arg ...string) *Cmd { 467 return commandImpl(ctx, name, arg, exec.CommandContext) 468 } 469 470 // Command behaves like "os/exec".Command, except that it can be 471 // mocked via the "go.chromium.org/luci/common/exec/execmock" package. 472 // 473 // Note that although this takes a Context, it does not bind the lifetime of 474 // the returned Cmd to `ctx`. 475 func Command(ctx context.Context, name string, arg ...string) *Cmd { 476 return commandImpl(ctx, name, arg, func(_ context.Context, name string, arg ...string) *exec.Cmd { 477 return exec.Command(name, arg...) 478 }) 479 }