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 }