blob: 766f2861572c763bbad565eb9f6e7420cbebc612 [file] [log] [blame]
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"golang.org/x/build/internal/gomote/protos"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// stringSlice implements flag.Value, specifically for storing environment
// variable key=value pairs.
type stringSlice []string
func (*stringSlice) String() string { return "" } // default value
func (ss *stringSlice) Set(v string) error {
if v != "" {
if !strings.Contains(v, "=") {
return fmt.Errorf("-e argument %q doesn't contains an '=' sign.", v)
}
*ss = append(*ss, v)
}
return nil
}
func run(args []string) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "run usage: gomote run [run-opts] <instance> <cmd> [args...]")
fs.PrintDefaults()
os.Exit(1)
}
var sys bool
fs.BoolVar(&sys, "system", false, "run inside the system, and not inside the workdir; this is implicit if cmd starts with '/'")
var debug bool
fs.BoolVar(&debug, "debug", false, "write debug info about the command's execution before it begins")
var env stringSlice
fs.Var(&env, "e", "Environment variable KEY=value. The -e flag may be repeated multiple times to add multiple things to the environment.")
var firewall bool
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.")
var path string
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.")
var dir string
fs.StringVar(&dir, "dir", "", "Directory to run from. Defaults to the directory of the command, or the work directory if -system is true.")
var builderEnv string
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.")
var collect bool
fs.BoolVar(&collect, "collect", false, "Collect artifacts (stdout, work dir .tar.gz) into $PWD once complete.")
var untilPattern string
fs.StringVar(&untilPattern, "until", "", "Run command repeatedly until the output matches the provided regexp.")
fs.Parse(args)
if fs.NArg() == 0 {
fs.Usage()
}
var until *regexp.Regexp
var err error
if untilPattern != "" {
until, err = regexp.Compile(untilPattern)
if err != nil {
return fmt.Errorf("bad regexp %q for 'until': %w", untilPattern, err)
}
}
var cmd string
var cmdArgs []string
var runSet []string
// First check if the instance name refers to a live instance.
ctx := context.Background()
if err := doPing(ctx, fs.Arg(0)); instanceDoesNotExist(err) {
// When there's no active group, this is just an error.
if activeGroup == nil {
return fmt.Errorf("instance %q: %w", fs.Arg(0), err)
}
// When there is an active group, this just means that we're going
// to use the group instead and assume the rest is a command.
for _, inst := range activeGroup.Instances {
runSet = append(runSet, inst)
}
cmd = fs.Arg(0)
cmdArgs = fs.Args()[1:]
} else if err == nil {
runSet = append(runSet, fs.Arg(0))
if fs.NArg() == 1 {
fmt.Fprintln(os.Stderr, "missing command")
fs.Usage()
}
cmd = fs.Arg(1)
cmdArgs = fs.Args()[2:]
} else {
return fmt.Errorf("checking instance %q: %w", fs.Arg(0), err)
}
var pathOpt []string
if path == "EMPTY" {
pathOpt = []string{} // non-nil
} else if path != "" {
pathOpt = strings.Split(path, ",")
}
// Create temporary directory for output.
// This is useful even if we don't have multiple gomotes running, since
// it's easy to accidentally lose the output.
var outDir string
if collect {
outDir, err = os.Getwd()
if err != nil {
return err
}
} else {
outDir, err = os.MkdirTemp("", "gomote")
if err != nil {
return err
}
}
var cmdsFailedMu sync.Mutex
var cmdsFailed []*cmdFailedError
eg, ctx := errgroup.WithContext(context.Background())
for _, inst := range runSet {
inst := inst
if len(runSet) > 1 {
// There's more than one instance running the command, so let's
// be explicit about that.
fmt.Fprintf(os.Stderr, "# Running command on %q...\n", inst)
}
eg.Go(func() error {
// Create a file to write output to so it doesn't get lost.
outf, err := os.Create(filepath.Join(outDir, fmt.Sprintf("%s.stdout", inst)))
if err != nil {
return err
}
defer func() {
outf.Close()
fmt.Fprintf(os.Stderr, "# Wrote results from %q to %q.\n", inst, outf.Name())
}()
fmt.Fprintf(os.Stderr, "# Streaming results from %q to %q...\n", inst, outf.Name())
outputs := []io.Writer{outf}
// If this is the only command running, print to stdout too, for convenience and
// backwards compatibility.
if len(runSet) == 1 {
outputs = append(outputs, os.Stdout)
}
// Give ourselves the output too so that we can match against it.
var outBuf bytes.Buffer
if until != nil {
outputs = append(outputs, &outBuf)
}
var ce *cmdFailedError
for {
err := doRun(
ctx,
inst,
cmd,
cmdArgs,
runDir(dir),
runBuilderEnv(builderEnv),
runEnv(env),
runPath(pathOpt),
runSystem(sys),
runDebug(debug),
runFirewall(firewall),
runWriters(outputs...),
)
// If it's just that the command failed, don't exit just yet, and don't return
// an error to the errgroup because we want the other commands to keep going.
if err != nil {
var ok bool
ce, ok = err.(*cmdFailedError)
if !ok {
return err
}
}
if until == nil || until.Match(outBuf.Bytes()) {
break
}
// Reset the output file and our buffer for the next run.
outBuf.Reset()
if err := outf.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate output file %q: %w", outf.Name(), err)
}
fmt.Fprintf(os.Stderr, "# No match found on %q, running again...\n", inst)
}
if until != nil {
fmt.Fprintf(os.Stderr, "# Match found on %q.\n", inst)
}
if ce != nil {
// N.B. If err this wasn't a cmdFailedError
cmdsFailedMu.Lock()
cmdsFailed = append(cmdsFailed, ce)
cmdsFailedMu.Unlock()
// Write out the error.
_, err := io.MultiWriter(outputs...).Write([]byte(ce.Error() + "\n"))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to write error to output: %v", err)
}
}
if collect {
f, err := os.Create(fmt.Sprintf("%s.tar.gz", inst))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create file to write instance tarball: %v", err)
return nil
}
defer f.Close()
fmt.Fprintf(os.Stderr, "# Downloading work dir tarball for %q to %q...\n", inst, f.Name())
if err := doGetTar(ctx, inst, ".", f); err != nil {
fmt.Fprintf(os.Stderr, "failed to retrieve instance tarball: %v", err)
return nil
}
}
return nil
})
}
if err := eg.Wait(); err != nil {
return err
}
// Handle failed commands separately so that we can let all the instances finish
// running. We still want to handle them, though, because we want to make sure
// we exit with a non-zero exit code to reflect the command failure.
for _, ce := range cmdsFailed {
fmt.Fprintf(os.Stderr, "# Command %q failed on %q: %v\n", ce.cmd, ce.inst, err)
}
if len(cmdsFailed) > 0 {
return errors.New("one or more commands failed")
}
return nil
}
func doRun(ctx context.Context, inst, cmd string, cmdArgs []string, opts ...runOpt) error {
cfg := &runCfg{
req: protos.ExecuteCommandRequest{
AppendEnvironment: []string{},
Args: cmdArgs,
Command: cmd,
Path: []string{},
GomoteId: inst,
},
}
for _, opt := range opts {
opt(cfg)
}
if !cfg.req.SystemLevel {
cfg.req.SystemLevel = strings.HasPrefix(cmd, "/")
}
outWriter := io.MultiWriter(cfg.outputs...)
client := gomoteServerClient(ctx)
stream, err := client.ExecuteCommand(ctx, &cfg.req)
if err != nil {
return fmt.Errorf("unable to execute %s: %w", cmd, err)
}
for {
update, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
// execution error
if status.Code(err) == codes.Aborted {
return &cmdFailedError{inst: inst, cmd: cmd, err: err}
}
// remote error
return fmt.Errorf("unable to execute %s: %w", cmd, err)
}
fmt.Fprint(outWriter, string(update.GetOutput()))
}
}
type cmdFailedError struct {
inst, cmd string
err error
}
func (e *cmdFailedError) Error() string {
return fmt.Sprintf("Error trying to execute %s: %v", e.cmd, e.err)
}
func (e *cmdFailedError) Unwrap() error {
return e.err
}
type runCfg struct {
outputs []io.Writer
req protos.ExecuteCommandRequest
}
type runOpt func(*runCfg)
func runBuilderEnv(builderEnv string) runOpt {
return func(r *runCfg) {
r.req.ImitateHostType = builderEnv
}
}
func runDir(dir string) runOpt {
return func(r *runCfg) {
r.req.Directory = dir
}
}
func runEnv(env []string) runOpt {
return func(r *runCfg) {
r.req.AppendEnvironment = append(r.req.AppendEnvironment, env...)
}
}
func runPath(path []string) runOpt {
return func(r *runCfg) {
r.req.Path = append(r.req.Path, path...)
}
}
func runDebug(debug bool) runOpt {
return func(r *runCfg) {
r.req.Debug = debug
}
}
func runSystem(sys bool) runOpt {
return func(r *runCfg) {
r.req.SystemLevel = sys
}
}
func runFirewall(firewall bool) runOpt {
return func(r *runCfg) {
r.req.AppendEnvironment = append(r.req.AppendEnvironment, "GO_DISABLE_OUTBOUND_NETWORK="+fmt.Sprint(firewall))
}
}
func runWriters(writers ...io.Writer) runOpt {
return func(r *runCfg) {
r.outputs = writers
}
}