golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gomote/run.go (about)

     1  // Copyright 2015 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"errors"
    11  	"flag"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"path/filepath"
    16  	"regexp"
    17  	"strings"
    18  	"sync"
    19  
    20  	"golang.org/x/build/internal/gomote/protos"
    21  	"golang.org/x/sync/errgroup"
    22  	"google.golang.org/grpc/codes"
    23  	"google.golang.org/grpc/status"
    24  )
    25  
    26  // stringSlice implements flag.Value, specifically for storing environment
    27  // variable key=value pairs.
    28  type stringSlice []string
    29  
    30  func (*stringSlice) String() string { return "" } // default value
    31  
    32  func (ss *stringSlice) Set(v string) error {
    33  	if v != "" {
    34  		if !strings.Contains(v, "=") {
    35  			return fmt.Errorf("-e argument %q doesn't contains an '=' sign.", v)
    36  		}
    37  		*ss = append(*ss, v)
    38  	}
    39  	return nil
    40  }
    41  
    42  func run(args []string) error {
    43  	fs := flag.NewFlagSet("run", flag.ContinueOnError)
    44  	fs.Usage = func() {
    45  		fmt.Fprintln(os.Stderr, "run usage: gomote run [run-opts] <instance> <cmd> [args...]")
    46  		fs.PrintDefaults()
    47  		os.Exit(1)
    48  	}
    49  	var sys bool
    50  	fs.BoolVar(&sys, "system", false, "run inside the system, and not inside the workdir; this is implicit if cmd starts with '/'")
    51  	var debug bool
    52  	fs.BoolVar(&debug, "debug", false, "write debug info about the command's execution before it begins")
    53  	var env stringSlice
    54  	fs.Var(&env, "e", "Environment variable KEY=value. The -e flag may be repeated multiple times to add multiple things to the environment.")
    55  	var firewall bool
    56  	fs.BoolVar(&firewall, "firewall", false, "Enable outbound firewall on machine. This is on by default on many builders (where supported) but disabled by default on gomote for ease of debugging. Once any command has been run with the -firewall flag on, it's on for the lifetime of that gomote instance.")
    57  	var path string
    58  	fs.StringVar(&path, "path", "", "Comma-separated list of ExecOpts.Path elements. The special string 'EMPTY' means to run without any $PATH. The empty string (default) does not modify the $PATH. Otherwise, the following expansions apply: the string '$PATH' expands to the current PATH element(s), the substring '$WORKDIR' expands to the buildlet's temp workdir.")
    59  
    60  	var dir string
    61  	fs.StringVar(&dir, "dir", "", "Directory to run from. Defaults to the directory of the command, or the work directory if -system is true.")
    62  	var builderEnv string
    63  	fs.StringVar(&builderEnv, "builderenv", "", "Optional alternate builder to act like. Must share the same underlying buildlet host type, or it's an error. For instance, linux-amd64-race or linux-386-387 are compatible with linux-amd64, but openbsd-amd64 and openbsd-386 are different hosts.")
    64  
    65  	var collect bool
    66  	fs.BoolVar(&collect, "collect", false, "Collect artifacts (stdout, work dir .tar.gz) into $PWD once complete.")
    67  
    68  	var untilPattern string
    69  	fs.StringVar(&untilPattern, "until", "", "Run command repeatedly until the output matches the provided regexp.")
    70  
    71  	fs.Parse(args)
    72  	if fs.NArg() == 0 {
    73  		fs.Usage()
    74  	}
    75  
    76  	var until *regexp.Regexp
    77  	var err error
    78  	if untilPattern != "" {
    79  		until, err = regexp.Compile(untilPattern)
    80  		if err != nil {
    81  			return fmt.Errorf("bad regexp %q for 'until': %w", untilPattern, err)
    82  		}
    83  	}
    84  
    85  	var cmd string
    86  	var cmdArgs []string
    87  	var runSet []string
    88  
    89  	// First check if the instance name refers to a live instance.
    90  	ctx := context.Background()
    91  	if err := doPing(ctx, fs.Arg(0)); instanceDoesNotExist(err) {
    92  		// When there's no active group, this is just an error.
    93  		if activeGroup == nil {
    94  			return fmt.Errorf("instance %q: %w", fs.Arg(0), err)
    95  		}
    96  		// When there is an active group, this just means that we're going
    97  		// to use the group instead and assume the rest is a command.
    98  		for _, inst := range activeGroup.Instances {
    99  			runSet = append(runSet, inst)
   100  		}
   101  		cmd = fs.Arg(0)
   102  		cmdArgs = fs.Args()[1:]
   103  	} else if err == nil {
   104  		runSet = append(runSet, fs.Arg(0))
   105  		if fs.NArg() == 1 {
   106  			fmt.Fprintln(os.Stderr, "missing command")
   107  			fs.Usage()
   108  		}
   109  		cmd = fs.Arg(1)
   110  		cmdArgs = fs.Args()[2:]
   111  	} else {
   112  		return fmt.Errorf("checking instance %q: %w", fs.Arg(0), err)
   113  	}
   114  
   115  	var pathOpt []string
   116  	if path == "EMPTY" {
   117  		pathOpt = []string{} // non-nil
   118  	} else if path != "" {
   119  		pathOpt = strings.Split(path, ",")
   120  	}
   121  
   122  	// Create temporary directory for output.
   123  	// This is useful even if we don't have multiple gomotes running, since
   124  	// it's easy to accidentally lose the output.
   125  	var outDir string
   126  	if collect {
   127  		outDir, err = os.Getwd()
   128  		if err != nil {
   129  			return err
   130  		}
   131  	} else {
   132  		outDir, err = os.MkdirTemp("", "gomote")
   133  		if err != nil {
   134  			return err
   135  		}
   136  	}
   137  
   138  	var cmdsFailedMu sync.Mutex
   139  	var cmdsFailed []*cmdFailedError
   140  	eg, ctx := errgroup.WithContext(context.Background())
   141  	for _, inst := range runSet {
   142  		inst := inst
   143  		if len(runSet) > 1 {
   144  			// There's more than one instance running the command, so let's
   145  			// be explicit about that.
   146  			fmt.Fprintf(os.Stderr, "# Running command on %q...\n", inst)
   147  		}
   148  		eg.Go(func() error {
   149  			// Create a file to write output to so it doesn't get lost.
   150  			outf, err := os.Create(filepath.Join(outDir, fmt.Sprintf("%s.stdout", inst)))
   151  			if err != nil {
   152  				return err
   153  			}
   154  			defer func() {
   155  				outf.Close()
   156  				fmt.Fprintf(os.Stderr, "# Wrote results from %q to %q.\n", inst, outf.Name())
   157  			}()
   158  			fmt.Fprintf(os.Stderr, "# Streaming results from %q to %q...\n", inst, outf.Name())
   159  
   160  			outputs := []io.Writer{outf}
   161  			// If this is the only command running, print to stdout too, for convenience and
   162  			// backwards compatibility.
   163  			if len(runSet) == 1 {
   164  				outputs = append(outputs, os.Stdout)
   165  			}
   166  			// Give ourselves the output too so that we can match against it.
   167  			var outBuf bytes.Buffer
   168  			if until != nil {
   169  				outputs = append(outputs, &outBuf)
   170  			}
   171  			var ce *cmdFailedError
   172  			for {
   173  				err := doRun(
   174  					ctx,
   175  					inst,
   176  					cmd,
   177  					cmdArgs,
   178  					runDir(dir),
   179  					runBuilderEnv(builderEnv),
   180  					runEnv(env),
   181  					runPath(pathOpt),
   182  					runSystem(sys),
   183  					runDebug(debug),
   184  					runFirewall(firewall),
   185  					runWriters(outputs...),
   186  				)
   187  				// If it's just that the command failed, don't exit just yet, and don't return
   188  				// an error to the errgroup because we want the other commands to keep going.
   189  				if err != nil {
   190  					var ok bool
   191  					ce, ok = err.(*cmdFailedError)
   192  					if !ok {
   193  						return err
   194  					}
   195  				}
   196  				if until == nil || until.Match(outBuf.Bytes()) {
   197  					break
   198  				}
   199  				// Reset the output file and our buffer for the next run.
   200  				outBuf.Reset()
   201  				if err := outf.Truncate(0); err != nil {
   202  					return fmt.Errorf("failed to truncate output file %q: %w", outf.Name(), err)
   203  				}
   204  
   205  				fmt.Fprintf(os.Stderr, "# No match found on %q, running again...\n", inst)
   206  			}
   207  			if until != nil {
   208  				fmt.Fprintf(os.Stderr, "# Match found on %q.\n", inst)
   209  			}
   210  			if ce != nil {
   211  				// N.B. If err this wasn't a cmdFailedError
   212  				cmdsFailedMu.Lock()
   213  				cmdsFailed = append(cmdsFailed, ce)
   214  				cmdsFailedMu.Unlock()
   215  				// Write out the error.
   216  				_, err := io.MultiWriter(outputs...).Write([]byte(ce.Error() + "\n"))
   217  				if err != nil {
   218  					fmt.Fprintf(os.Stderr, "failed to write error to output: %v", err)
   219  				}
   220  			}
   221  			if collect {
   222  				f, err := os.Create(fmt.Sprintf("%s.tar.gz", inst))
   223  				if err != nil {
   224  					fmt.Fprintf(os.Stderr, "failed to create file to write instance tarball: %v", err)
   225  					return nil
   226  				}
   227  				defer f.Close()
   228  				fmt.Fprintf(os.Stderr, "# Downloading work dir tarball for %q to %q...\n", inst, f.Name())
   229  				if err := doGetTar(ctx, inst, ".", f); err != nil {
   230  					fmt.Fprintf(os.Stderr, "failed to retrieve instance tarball: %v", err)
   231  					return nil
   232  				}
   233  			}
   234  			return nil
   235  		})
   236  	}
   237  	if err := eg.Wait(); err != nil {
   238  		return err
   239  	}
   240  	// Handle failed commands separately so that we can let all the instances finish
   241  	// running. We still want to handle them, though, because we want to make sure
   242  	// we exit with a non-zero exit code to reflect the command failure.
   243  	for _, ce := range cmdsFailed {
   244  		fmt.Fprintf(os.Stderr, "# Command %q failed on %q: %v\n", ce.cmd, ce.inst, err)
   245  	}
   246  	if len(cmdsFailed) > 0 {
   247  		return errors.New("one or more commands failed")
   248  	}
   249  	return nil
   250  }
   251  
   252  func doRun(ctx context.Context, inst, cmd string, cmdArgs []string, opts ...runOpt) error {
   253  	cfg := &runCfg{
   254  		req: protos.ExecuteCommandRequest{
   255  			AppendEnvironment: []string{},
   256  			Args:              cmdArgs,
   257  			Command:           cmd,
   258  			Path:              []string{},
   259  			GomoteId:          inst,
   260  		},
   261  	}
   262  	for _, opt := range opts {
   263  		opt(cfg)
   264  	}
   265  	if !cfg.req.SystemLevel {
   266  		cfg.req.SystemLevel = strings.HasPrefix(cmd, "/")
   267  	}
   268  
   269  	outWriter := io.MultiWriter(cfg.outputs...)
   270  	client := gomoteServerClient(ctx)
   271  	stream, err := client.ExecuteCommand(ctx, &cfg.req)
   272  	if err != nil {
   273  		return fmt.Errorf("unable to execute %s: %w", cmd, err)
   274  	}
   275  	for {
   276  		update, err := stream.Recv()
   277  		if err == io.EOF {
   278  			return nil
   279  		}
   280  		if err != nil {
   281  			// execution error
   282  			if status.Code(err) == codes.Aborted {
   283  				return &cmdFailedError{inst: inst, cmd: cmd, err: err}
   284  			}
   285  			// remote error
   286  			return fmt.Errorf("unable to execute %s: %w", cmd, err)
   287  		}
   288  		fmt.Fprint(outWriter, string(update.GetOutput()))
   289  	}
   290  }
   291  
   292  type cmdFailedError struct {
   293  	inst, cmd string
   294  	err       error
   295  }
   296  
   297  func (e *cmdFailedError) Error() string {
   298  	return fmt.Sprintf("Error trying to execute %s: %v", e.cmd, e.err)
   299  }
   300  
   301  func (e *cmdFailedError) Unwrap() error {
   302  	return e.err
   303  }
   304  
   305  type runCfg struct {
   306  	outputs []io.Writer
   307  	req     protos.ExecuteCommandRequest
   308  }
   309  
   310  type runOpt func(*runCfg)
   311  
   312  func runBuilderEnv(builderEnv string) runOpt {
   313  	return func(r *runCfg) {
   314  		r.req.ImitateHostType = builderEnv
   315  	}
   316  }
   317  
   318  func runDir(dir string) runOpt {
   319  	return func(r *runCfg) {
   320  		r.req.Directory = dir
   321  	}
   322  }
   323  
   324  func runEnv(env []string) runOpt {
   325  	return func(r *runCfg) {
   326  		r.req.AppendEnvironment = append(r.req.AppendEnvironment, env...)
   327  	}
   328  }
   329  
   330  func runPath(path []string) runOpt {
   331  	return func(r *runCfg) {
   332  		r.req.Path = append(r.req.Path, path...)
   333  	}
   334  }
   335  
   336  func runDebug(debug bool) runOpt {
   337  	return func(r *runCfg) {
   338  		r.req.Debug = debug
   339  	}
   340  }
   341  
   342  func runSystem(sys bool) runOpt {
   343  	return func(r *runCfg) {
   344  		r.req.SystemLevel = sys
   345  	}
   346  }
   347  
   348  func runFirewall(firewall bool) runOpt {
   349  	return func(r *runCfg) {
   350  		r.req.AppendEnvironment = append(r.req.AppendEnvironment, "GO_DISABLE_OUTBOUND_NETWORK="+fmt.Sprint(firewall))
   351  	}
   352  }
   353  
   354  func runWriters(writers ...io.Writer) runOpt {
   355  	return func(r *runCfg) {
   356  		r.outputs = writers
   357  	}
   358  }