| // Copyright 2024 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 ( |
| "context" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strconv" |
| "strings" |
| "sync" |
| |
| "github.com/kballard/go-shellquote" |
| "go.chromium.org/luci/auth" |
| bbpb "go.chromium.org/luci/buildbucket/proto" |
| "go.chromium.org/luci/grpc/prpc" |
| "go.chromium.org/luci/hardcoded/chromeinfra" |
| rdbpb "go.chromium.org/luci/resultdb/proto/v1" |
| sauth "go.chromium.org/luci/server/auth" |
| "golang.org/x/sync/errgroup" |
| "google.golang.org/protobuf/types/known/fieldmaskpb" |
| ) |
| |
| func repro(args []string) error { |
| if luciDisabled() { |
| return fmt.Errorf("repro subcommand is only available for LUCI builds") |
| } |
| fs := flag.NewFlagSet("repro", flag.ContinueOnError) |
| fs.Usage = func() { |
| log := usageLogger |
| log.Print("repro usage: gomote repro [repro-opts] <build ID>") |
| log.Print() |
| log.Print("If there's a valid group specified, new instances are") |
| log.Print("automatically added to the group. If the group in") |
| log.Print("$GOMOTE_GROUP doesn't exist, and there's no other group") |
| log.Print("specified, it will be created and new instances will be") |
| log.Print("added to that group.") |
| fs.PrintDefaults() |
| os.Exit(1) |
| } |
| var cfg createConfig |
| var public bool |
| fs.BoolVar(&cfg.printStatus, "status", true, "print regular status updates while waiting") |
| fs.IntVar(&cfg.count, "count", 1, "number of instances to create") |
| fs.StringVar(&cfg.newGroup, "new-group", "", "also create a new group and add the new instances to it") |
| fs.BoolVar(&public, "public", true, "whether the build you're trying to reproduce is public") |
| cfg.useGolangbuild = true |
| |
| fs.Parse(args) |
| if fs.NArg() != 1 { |
| fs.Usage() |
| } |
| // Parse as a uint even though we'll end up converting to int64 -- negative build IDs are not valid. |
| // Strip an optional "b" prefix. Lots of LUCI URLs and UIs use the prefix, so it makes copy-paste |
| // easier. |
| buildID, err := strconv.ParseUint(strings.TrimPrefix(fs.Arg(0), "b"), 10, 64) |
| if err != nil { |
| return fmt.Errorf("parsing build ID %s: %v", fs.Arg(0), err) |
| } |
| // Always use a default unauthenticated client for public builds. |
| ctx := context.Background() |
| hc := http.DefaultClient |
| if !public { |
| au := createLUCIAuthenticator(ctx) |
| if err := au.CheckLoginRequired(); errors.Is(err, auth.ErrLoginRequired) { |
| log.Println("LUCI login required... initiating interactive login process") |
| if err := au.Login(); err != nil { |
| return err |
| } |
| } else if err != nil { |
| return fmt.Errorf("checking whether LUCI login is required: %w", err) |
| } |
| hc, err = au.Client() |
| if err != nil { |
| return fmt.Errorf("creating HTTP client: %w", err) |
| } |
| } |
| bc := bbpb.NewBuildsPRPCClient(&prpc.Client{ |
| C: hc, |
| Host: chromeinfra.BuildbucketHost, |
| Options: prpc.DefaultOptions(), |
| }) |
| build, err := bc.GetBuild(ctx, &bbpb.GetBuildRequest{ |
| Id: int64(buildID), |
| Mask: &bbpb.BuildMask{ |
| Fields: &fieldmaskpb.FieldMask{ |
| Paths: []string{ |
| "id", |
| "builder", |
| "ancestor_ids", |
| "input.properties", |
| "infra.resultdb.invocation", |
| }, |
| }, |
| }, |
| }) |
| if err != nil { |
| return fmt.Errorf("getting build info for %d: %w", buildID, err) |
| } |
| if build.Builder.Project != "golang" { |
| return fmt.Errorf("build ID is not for a go build: found builder %s", build.Builder.Builder) |
| } |
| |
| // Figure out the builder type and the build ID we'll use for GOMOTE_REPRO. |
| // |
| // This is a bit complex because we want to be able to support users just picking any Go build and |
| // plugging them into this tool. This means we should do the right thing whether or not they're looking |
| // at a worker build or a coordinator build or something else. |
| gomoteBuilderType := build.Builder.Builder // Builder type to pass to createInstances. May not be build.Builder. |
| gomoteReproID := int64(buildID) // Build ID to be passed to GOMOTE_REPRO. May not be build.Id or buildID |
| if strings.HasSuffix(build.Builder.Bucket, "-workers") { |
| // This is a worker builder. We'll use this for GOMOTE_REPRO, but we need the parent builder to pass to createInstances to get the right gomote. |
| // createInstances for example expects a coordinator builder |
| coordBuild, err := bc.GetBuild(ctx, &bbpb.GetBuildRequest{Id: build.AncestorIds[len(build.AncestorIds)-1] /* immediate parent */}) |
| if err != nil { |
| return fmt.Errorf("getting build info for parent build %d: %w", buildID, err) |
| } |
| if coordBuild.Builder.Project != "go" { |
| return fmt.Errorf("parent build for worker build unexpectedly not for the go project: for %q project instead", coordBuild.Builder.Project) |
| } |
| gomoteBuilderType = coordBuild.Builder.Builder |
| } else { |
| // This may be a coordinator builder. Let's check, and if so, fetch one of its children as the poster-child for GOMOTE_REPRO. |
| mode, err := mustGetInputProperty[float64](build, "mode") |
| if err != nil { |
| return err |
| } |
| if int(mode) == 1 /*MODE_COORDINATOR*/ { |
| log.Print("Detected coordinator-mode builder; fetching child builds to use to initialize gomote.") |
| resp, err := bc.SearchBuilds(ctx, &bbpb.SearchBuildsRequest{Predicate: &bbpb.BuildPredicate{ChildOf: int64(buildID)}}) |
| if err != nil { |
| return fmt.Errorf("fetching children of %d: %v", buildID, err) |
| } |
| if len(resp.Builds) == 0 { |
| return fmt.Errorf("found no children of %d: if the build is still in progress, try running this command again in a minute or two", buildID) |
| } |
| // Take any child build. |
| gomoteReproID = resp.Builds[0].Id |
| } |
| } |
| log.Printf("Selected build %d to initialize the gomote.", gomoteReproID) |
| |
| log.Printf("Creating %d instance(s) of type %s...", cfg.count, gomoteBuilderType) |
| instances, group, err := createInstances(ctx, gomoteBuilderType, &cfg) |
| if err != nil { |
| return err |
| } |
| log.Printf("Initializing %d instance(s) with environment of %d...", len(instances), gomoteReproID) |
| if err := initReproInstances(ctx, instances, gomoteReproID); err != nil { |
| return err |
| } |
| return printTestCommands(ctx, hc, build, instances, group) |
| } |
| |
| func initReproInstances(ctx context.Context, instances []string, reproBuildID int64) error { |
| var tmpOutDir string |
| var tmpOutDirOnce sync.Once |
| eg, ctx := errgroup.WithContext(ctx) |
| for _, inst := range instances { |
| inst := inst |
| eg.Go(func() error { |
| var err error |
| tmpOutDirOnce.Do(func() { |
| tmpOutDir, err = os.MkdirTemp("", "gomote") |
| }) |
| if err != nil { |
| return fmt.Errorf("failed to create a temporary directory for setup output: %w", err) |
| } |
| |
| // Create a file to write output to so it doesn't get lost. |
| outf, err := os.Create(filepath.Join(tmpOutDir, fmt.Sprintf("%s.stdout", inst))) |
| if err != nil { |
| return err |
| } |
| defer func() { |
| outf.Close() |
| log.Printf("Wrote results from %q to %q.", inst, outf.Name()) |
| }() |
| log.Printf("Streaming results from %q to %q...", inst, outf.Name()) |
| |
| // If this is the only command running, print to stdout too, for convenience and |
| // backwards compatibility. |
| outputs := []io.Writer{outf} |
| if len(instances) > 1 { |
| // Emit detailed progress. |
| outputs = append(outputs, os.Stdout) |
| } else { |
| log.Printf("Initializing gomote %q...", inst) |
| } |
| return doRun( |
| ctx, |
| inst, |
| "golangbuild", |
| []string{}, |
| runSystem(true), // Run in the work directory. |
| // Set GOMOTE_REPRO and unset GOMOTE_SETUP. |
| runEnv([]string{fmt.Sprintf("GOMOTE_REPRO=%d", reproBuildID), "GOMOTE_SETUP="}), |
| runWriters(outputs...), |
| ) |
| }) |
| } |
| return eg.Wait() |
| } |
| |
| func printTestCommands(ctx context.Context, hc *http.Client, build *bbpb.Build, instances []string, group *groupData) error { |
| // Figure out what project this build is for. |
| project, err := mustGetInputProperty[string](build, "project") |
| if err != nil { |
| return err |
| } |
| |
| // Check if it's a no-network builder. |
| noNetwork, _, err := getInputProperty[bool](build, "no_network") |
| if err != nil { |
| return err |
| } |
| |
| log.Printf("Fetching test results for %d", build.Id) |
| rc := rdbpb.NewResultDBClient(&prpc.Client{ |
| C: hc, |
| Host: chromeinfra.ResultDBHost, |
| }) |
| req := &rdbpb.QueryTestResultsRequest{ |
| Invocations: []string{build.Infra.Resultdb.Invocation}, |
| Predicate: &rdbpb.TestResultPredicate{ |
| TestIdRegexp: ".*", |
| Expectancy: rdbpb.TestResultPredicate_VARIANTS_WITH_UNEXPECTED_RESULTS, |
| }, |
| } |
| resp, err := rc.QueryTestResults(ctx, req) |
| if err != nil { |
| return fmt.Errorf("querying test results: %v", err) |
| } |
| if len(resp.TestResults) > 0 { |
| log.Printf("Found failed tests. Commands to reproduce:") |
| } |
| var unknownTests []string |
| var packageFailures []string |
| specialPackages := make(map[string]struct{}) |
| var benchmarks []test |
| var tests []test |
| for _, result := range resp.TestResults { |
| if result.TestId == "make.bash" { |
| log.Printf("$ gomote run go/src/make.bash") |
| continue |
| } |
| |
| // Try to split by ".Test". |
| var bench bool |
| i := strings.Index(result.TestId, ".Test") |
| if i < 0 { |
| // That didn't work. Try to split by ".Benchmark". |
| i := strings.Index(result.TestId, ".Benchmark") |
| if i < 0 { |
| // Assume the TestId is a package, for a package-level failure. |
| packageFailures = append(packageFailures, result.TestId) |
| continue |
| } |
| bench = true |
| } |
| t := test{ |
| pkg: result.TestId[:i], |
| name: result.TestId[i+1:], |
| } |
| |
| // Look for special packages. These need to be invoked via dist. |
| if strings.IndexByte(t.pkg, ':') >= 0 { |
| if project == "go" { |
| specialPackages[t.pkg] = struct{}{} |
| } else { |
| // We are almost definitely unable to run this test -- something went very wrong. |
| unknownTests = append(unknownTests, result.TestId) |
| } |
| continue |
| } |
| if rest, ok := cutAnyPrefix(t.pkg, "golang.org/x/", "google.golang.org/"); ok { |
| if strings.HasPrefix(rest, project) { |
| t.path = "./x_" + rest |
| } else { |
| // We are almost definitely unable to run this test -- something went very wrong. |
| unknownTests = append(unknownTests, result.TestId) |
| } |
| } else { |
| // Assume it's a std test. |
| t.path = "goroot/src/" + t.pkg |
| } |
| if bench { |
| benchmarks = append(benchmarks, t) |
| } else { |
| tests = append(tests, t) |
| } |
| } |
| wrapCmd := func(dir string, goArgs ...string) string { |
| gomoteCmd := []string{"gomote", "run", "-dir", dir} |
| if group == nil { |
| gomoteCmd = append(gomoteCmd, instances[0]) |
| } |
| if noNetwork { |
| relGoPath, err := filepath.Rel(dir, "./goroot/bin/go") |
| if err != nil { |
| panic(err) |
| } |
| // TODO(go.dev/issue/69599): It's pretty bad that we're copying this logic out of golangbuild. |
| // We really should put this in a wrapper that is more accessible. This is a hack |
| // for now to avoid issues like go.dev/issue/69561 from being quite so hard to debug |
| // in the future. |
| ipCmd := []string{"ip", "link", "set", "dev", "lo", "up"} |
| goCmd := append([]string{relGoPath}, goArgs...) |
| unshareCmd := []string{"unshare", "--net", "--map-root-user", "--", "sh", "-c", shellquote.Join(ipCmd...) + " && " + shellquote.Join(goCmd...)} |
| gomoteCmd = append(gomoteCmd, unshareCmd...) |
| } else { |
| gomoteCmd = append(gomoteCmd, "goroot/bin/go") |
| gomoteCmd = append(gomoteCmd, goArgs...) |
| } |
| result := shellquote.Join(gomoteCmd...) |
| if group != nil { |
| result = "GOMOTE_GROUP=" + group.Name + " " + result |
| } |
| return result |
| } |
| for _, t := range tests { |
| log.Printf("$ %s", wrapCmd(t.path, "test", "-run", t.regexp(), ".")) |
| } |
| for _, t := range benchmarks { |
| log.Printf("$ %s", wrapCmd(t.path, "test", "-run", "^$", "-bench", t.regexp(), ".")) |
| } |
| for pkg := range specialPackages { |
| log.Printf("$ %s", wrapCmd("./goroot", "tool", "dist", "test", pkg)) |
| } |
| for _, pkg := range packageFailures { |
| log.Printf("Note: Found package-level test failure for %s.", pkg) |
| } |
| for _, name := range unknownTests { |
| log.Printf("Note: Unable to parse name of failed test %s.", name) |
| } |
| return nil |
| } |
| |
| func cutAnyPrefix(s string, prefixes ...string) (rest string, ok bool) { |
| for _, p := range prefixes { |
| rest, ok = strings.CutPrefix(s, p) |
| if ok { |
| return |
| } |
| } |
| return "", false |
| } |
| |
| type test struct { |
| pkg string |
| name string |
| path string // Relative to workdir. |
| } |
| |
| // regexp returns a regexp matching this test's name, suitable for passing to -run and -bench. |
| func (t test) regexp() string { |
| cmps := strings.Split(t.name, "/") |
| for i, c := range cmps { |
| cmps[i] = "^" + c + "$" |
| } |
| return strings.Join(cmps, "/") |
| } |
| |
| func createLUCIAuthenticator(ctx context.Context) *auth.Authenticator { |
| authOpts := chromeinfra.SetDefaultAuthOptions(auth.Options{ |
| Scopes: append([]string{ |
| "https://www.googleapis.com/auth/userinfo.email", |
| "https://www.googleapis.com/auth/gerritcodereview", |
| }, sauth.CloudOAuthScopes...), |
| }) |
| return auth.NewAuthenticator(ctx, auth.SilentLogin, authOpts) |
| } |
| |
| func mustGetInputProperty[T any](build *bbpb.Build, propName string) (T, error) { |
| value, present, err := getInputProperty[T](build, propName) |
| if err == nil && !present { |
| return *new(T), fmt.Errorf("expected %s property on build %d but did not find one; try updating gomote?", propName, build.Id) |
| } |
| return value, err |
| } |
| |
| func getInputProperty[T any](build *bbpb.Build, propName string) (T, bool, error) { |
| props := build.Input.Properties.AsMap() |
| propValue, ok := props[propName] |
| if !ok { |
| return *new(T), false, nil |
| } |
| prop, ok := propValue.(T) |
| if !ok { |
| return *new(T), true, fmt.Errorf("expected %s property on build %d to have type %T, but it did not: found %v; try updating gomote?", propName, build.Id, *new(T), propValue) |
| } |
| return prop, true, nil |
| } |