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  }