| // Copyright 2023 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 task | |
| import ( | |
| "bytes" | |
| "context" | |
| cryptorand "crypto/rand" | |
| "fmt" | |
| "io/fs" | |
| "math/rand" | |
| "regexp" | |
| "strings" | |
| "time" | |
| cloudbuild "cloud.google.com/go/cloudbuild/apiv1/v2" | |
| "cloud.google.com/go/cloudbuild/apiv1/v2/cloudbuildpb" | |
| "cloud.google.com/go/storage" | |
| "golang.org/x/build/gerrit" | |
| "golang.org/x/build/internal/gcsfs" | |
| wf "golang.org/x/build/internal/workflow" | |
| ) | |
| const gitGenerateVersion = "v0.0.0-20250226160655-0213393d1768" | |
| type CloudBuildClient interface { | |
| // RunBuildTrigger runs an existing trigger in project with the given | |
| // substitutions. | |
| RunBuildTrigger(ctx context.Context, project, trigger string, substitutions map[string]string) (CloudBuild, error) | |
| // GenerateAutoSubmitChange generates a change with the given metadata and | |
| // contents generated via the [git-generate] script that must be in the commit message, | |
| // starts trybots with auto-submit enabled, and returns its change ID. | |
| // The script will have the latest stable version of Go on $PATH. | |
| // If the requested contents match the state of the repository, no change | |
| // is created and the returned change ID will be empty. | |
| // | |
| // Reviewers is the username part of a golang.org or google.com email address. | |
| GenerateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string) (changeID string, _ error) | |
| // RunScript runs the given script under bash -eux -o pipefail in | |
| // ScriptProject. Outputs are collected into the build's ResultURL, | |
| // readable with ResultFS. The script will have the latest stable version of Go | |
| // and some version of gcloud on $PATH. | |
| // If gerritProject is provided, the script operates within a checkout of the | |
| // latest commit on the default branch of that repository. | |
| RunScript(ctx context.Context, script string, gerritProject string, outputs []string) (CloudBuild, error) | |
| // RunCustomSteps is a low-level API that provides direct control over | |
| // individual Cloud Build steps. It creates a random result directory | |
| // and provides that as a parameter to the steps function, so that it | |
| // may write output to it with 'gcloud storage cp' for accessing via ResultFS. | |
| // Prefer RunScript for simpler scenarios. | |
| // Reference: https://cloud.google.com/build/docs/build-config-file-schema | |
| RunCustomSteps(ctx context.Context, steps func(resultURL string) []*cloudbuildpb.BuildStep, opts *CloudBuildOptions) (CloudBuild, error) | |
| // Completed reports whether a build has finished, returning an error if | |
| // it's failed. It's suitable for use with AwaitCondition. | |
| Completed(ctx context.Context, build CloudBuild) (detail string, completed bool, _ error) | |
| // ResultFS returns an FS that contains the results of the given build. | |
| // The build must've been created by RunScript or RunCustomSteps. | |
| ResultFS(ctx context.Context, build CloudBuild) (fs.FS, error) | |
| } | |
| // CloudBuildOptions allows customizing CloudBuild configurations. | |
| type CloudBuildOptions struct { | |
| AvailableSecrets *cloudbuildpb.Secrets | |
| } | |
| type RealCloudBuildClient struct { | |
| BuildClient *cloudbuild.Client | |
| StorageClient *storage.Client | |
| ScriptProject string | |
| ScriptAccount string | |
| ScratchURL string | |
| } | |
| // CloudBuild represents a Cloud Build that can be queried with the status | |
| // methods on CloudBuildClient. | |
| type CloudBuild struct { | |
| Project, ID string | |
| ResultURL string | |
| } | |
| func (c *RealCloudBuildClient) RunBuildTrigger(ctx context.Context, project, trigger string, substitutions map[string]string) (CloudBuild, error) { | |
| op, err := c.BuildClient.RunBuildTrigger(ctx, &cloudbuildpb.RunBuildTriggerRequest{ | |
| ProjectId: project, | |
| TriggerId: trigger, | |
| Source: &cloudbuildpb.RepoSource{ | |
| Substitutions: substitutions, | |
| }, | |
| }) | |
| if err != nil { | |
| return CloudBuild{}, err | |
| } | |
| if _, err = op.Poll(ctx); err != nil { | |
| return CloudBuild{}, err | |
| } | |
| meta, err := op.Metadata() | |
| if err != nil { | |
| return CloudBuild{}, err | |
| } | |
| return CloudBuild{Project: project, ID: meta.Build.Id}, nil | |
| } | |
| const cloudBuildClientScriptPrefix = `#!/usr/bin/env bash | |
| set -eux | |
| set -o pipefail | |
| export PATH=/workspace/released_go/bin:$PATH | |
| ` | |
| const cloudBuildClientDownloadGoScript = `#!/usr/bin/env bash | |
| set -eux | |
| archive=$(wget -qO - 'https://go.dev/dl/?mode=json' | grep -Eo 'go.*linux-amd64.tar.gz' | head -n 1) | |
| wget -qO - https://go.dev/dl/${archive} | tar -xz | |
| mv go /workspace/released_go | |
| ` | |
| func (c *RealCloudBuildClient) RunScript(ctx context.Context, script string, gerritProject string, outputs []string) (CloudBuild, error) { | |
| steps := func(resultURL string) []*cloudbuildpb.BuildStep { | |
| // Cloud build loses directory structure when it saves artifacts, which is | |
| // a problem since (e.g.) we have multiple files named go.mod in the | |
| // tagging tasks. It's not very complicated, so reimplement it ourselves. | |
| var saveOutputsScript strings.Builder | |
| saveOutputsScript.WriteString(cloudBuildClientScriptPrefix) | |
| for _, out := range outputs { | |
| fmt.Fprintf(&saveOutputsScript, "gcloud storage cp %q %q\n", out, resultURL+"/"+strings.TrimPrefix(out, "./")) | |
| } | |
| var steps []*cloudbuildpb.BuildStep | |
| var dir string | |
| if gerritProject != "" { | |
| steps = append(steps, &cloudbuildpb.BuildStep{ | |
| Name: "gcr.io/cloud-builders/git", | |
| Args: []string{"clone", "https://go.googlesource.com/" + gerritProject, "checkout"}, | |
| }) | |
| dir = "checkout" | |
| } | |
| steps = append(steps, | |
| &cloudbuildpb.BuildStep{ | |
| Name: "bash", | |
| Script: cloudBuildClientDownloadGoScript, | |
| }, | |
| &cloudbuildpb.BuildStep{ | |
| Name: "gcr.io/cloud-builders/gcloud", | |
| Script: cloudBuildClientScriptPrefix + script, | |
| Dir: dir, | |
| }, | |
| &cloudbuildpb.BuildStep{ | |
| Name: "gcr.io/cloud-builders/gcloud", | |
| Script: saveOutputsScript.String(), | |
| Dir: dir, | |
| }, | |
| ) | |
| return steps | |
| } | |
| return c.RunCustomSteps(ctx, steps, nil) | |
| } | |
| func (c *RealCloudBuildClient) GenerateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string) (changeID string, _ error) { | |
| if input.Project == "" { | |
| return "", fmt.Errorf("input.Project must be specified") | |
| } else if input.Branch == "" { | |
| return "", fmt.Errorf("input.Branch must be specified") | |
| } else if !strings.Contains(input.Subject, "\n[git-generate]\n") { | |
| return "", fmt.Errorf("a commit message with a [git-generate] script must be provided") | |
| } | |
| // Add a Change-Id trailer to the commit message if it's not already present. | |
| var changeIDTrailers int | |
| if strings.HasPrefix(input.Subject, "Change-Id: ") { | |
| changeIDTrailers++ | |
| } | |
| changeIDTrailers += strings.Count(input.Subject, "\nChange-Id: ") | |
| if changeIDTrailers > 1 { | |
| return "", fmt.Errorf("multiple Change-Id lines") | |
| } | |
| if changeIDTrailers == 0 { | |
| // randomBytes returns 20 random bytes suitable for use in a Change-Id line. | |
| randomBytes := func() []byte { var id [20]byte; cryptorand.Read(id[:]); return id[:] } | |
| // endsWithMetadataLine reports whether the given commit message ends with a | |
| // metadata line such as "Bug: #42" or "Signed-off-by: Al <al@example.com>". | |
| metadataLineRE := regexp.MustCompile(`^[a-zA-Z0-9-]+: `) | |
| endsWithMetadataLine := func(msg string) bool { | |
| i := strings.LastIndexByte(msg, '\n') | |
| return i >= 0 && metadataLineRE.MatchString(msg[i+1:]) | |
| } | |
| msg := strings.TrimRight(input.Subject, "\n") | |
| sep := "\n\n" | |
| if endsWithMetadataLine(msg) { | |
| sep = "\n" | |
| } | |
| input.Subject += fmt.Sprintf("%sChange-Id: I%x", sep, randomBytes()) | |
| } | |
| refspec := fmt.Sprintf("HEAD:refs/for/%s%%l=Auto-Submit,l=Commit-Queue+1", input.Branch) | |
| refspecNoReviewers := refspec | |
| reviewerEmails, err := coordinatorEmails(reviewers) | |
| if err != nil { | |
| return "", err | |
| } | |
| for _, r := range reviewerEmails { | |
| refspec += ",r=" + r | |
| } | |
| const ( | |
| buildStepGitCommit = 4 | |
| buildStepGitPush = 6 | |
| ) | |
| buildStepOutput := func(b *cloudbuildpb.Build, buildStep int) []byte { | |
| out := b.GetResults().GetBuildStepOutputs() | |
| if buildStep >= len(out) { | |
| return nil | |
| } | |
| return out[buildStep] | |
| } | |
| // Create a Cloud Build that will generate and mail the CL. | |
| // | |
| // To remove the possibility of mailing multiple CLs due to | |
| // automated retries, allow only manual retries from this point. | |
| ctx.DisableRetries() | |
| op, err := c.BuildClient.CreateBuild(ctx, &cloudbuildpb.CreateBuildRequest{ | |
| ProjectId: c.ScriptProject, | |
| Build: &cloudbuildpb.Build{ | |
| Steps: []*cloudbuildpb.BuildStep{ | |
| { | |
| Name: "bash", Script: cloudBuildClientDownloadGoScript, | |
| }, | |
| { | |
| Name: "gcr.io/cloud-builders/git", | |
| Args: []string{"clone", "--branch=" + input.Branch, "--depth=1", "--", | |
| "https://go.googlesource.com/" + input.Project, "checkout"}, | |
| }, | |
| { | |
| Name: "gcr.io/cloud-builders/git", | |
| Args: []string{"-c", "user.name=Gopher Robot", "-c", "user.email=gobot@golang.org", | |
| "commit", "--allow-empty", "-m", input.Subject}, | |
| Dir: "checkout", | |
| }, | |
| { | |
| Name: "gcr.io/cloud-builders/git", | |
| Entrypoint: "/workspace/released_go/bin/go", | |
| Args: []string{"run", "rsc.io/rf/git-generate@" + gitGenerateVersion}, | |
| Dir: "checkout", | |
| }, | |
| buildStepGitCommit: { | |
| Name: "gcr.io/cloud-builders/git", | |
| Entrypoint: "bash", | |
| // Note: Use 'set -o pipefail' here to stop the build short of mailing if this | |
| // fails. The '--allow-empty' flag is intentionally not included here to force | |
| // this command to fail whenever the commit is still empty. | |
| // | |
| // Whenever this step fails with output that has a "\nNo changes\n" suffix, it | |
| // will be reported as a successful "no change is needed" result. | |
| Args: []string{"-c", "set -o pipefail && " + | |
| "git -c user.name='Gopher Robot' -c user.email=gobot@golang.org commit " + | |
| `--amend --no-edit | tee "$$BUILDER_OUTPUT/output"`}, | |
| Dir: "checkout", | |
| }, | |
| { | |
| Name: "gcr.io/cloud-builders/git", | |
| Args: []string{"show", "HEAD"}, | |
| Dir: "checkout", | |
| }, | |
| buildStepGitPush: { | |
| Name: "gcr.io/cloud-builders/git", | |
| Entrypoint: "bash", | |
| // Note: This intentionally doesn't do 'set -o pipefail' because we want the | |
| // git push output to be written to $BUILDER_OUTPUT/output regardless of its | |
| // exit code, and complete the nearly-done build. | |
| // | |
| // First try a regular push, but also try again without reviewers in case it | |
| // helps. See go.dev/issue/68276. | |
| // | |
| // Whether the push successfully created a CL or not will be determined from | |
| // the output text. | |
| Args: []string{"-c", fmt.Sprintf("(git push origin %s || git push origin %s)", refspec, refspecNoReviewers) + | |
| ` 2>&1 | tee "$$BUILDER_OUTPUT/output"`}, | |
| Dir: "checkout", | |
| }, | |
| }, | |
| Options: &cloudbuildpb.BuildOptions{ | |
| MachineType: cloudbuildpb.BuildOptions_E2_HIGHCPU_8, | |
| Logging: cloudbuildpb.BuildOptions_CLOUD_LOGGING_ONLY, | |
| }, | |
| ServiceAccount: c.ScriptAccount, | |
| }, | |
| }) | |
| if err != nil { | |
| return "", fmt.Errorf("creating build: %w", err) | |
| } | |
| if _, err = op.Poll(ctx); err != nil { | |
| return "", fmt.Errorf("polling: %w", err) | |
| } | |
| meta, err := op.Metadata() | |
| if err != nil { | |
| return "", fmt.Errorf("reading metadata: %w", err) | |
| } | |
| // completedGeneratingCL reports whether a build has finished, | |
| // returning the change ID that the given build generated, or | |
| // an empty string if no change was needed. | |
| // It's suitable for use with AwaitCondition, as done below. | |
| completedGeneratingCL := func() (changeID string, completed bool, _ error) { | |
| b, err := c.BuildClient.GetBuild(ctx, &cloudbuildpb.GetBuildRequest{ | |
| ProjectId: c.ScriptProject, | |
| Id: meta.Build.Id, | |
| }) | |
| if err != nil { | |
| return "", false, err | |
| } | |
| if b.FinishTime == nil { | |
| return "", false, nil | |
| } | |
| if b.Status != cloudbuildpb.Build_SUCCESS { | |
| if b.Status == cloudbuildpb.Build_FAILURE { | |
| gitCommitOutput := buildStepOutput(b, buildStepGitCommit) | |
| if bytes.HasSuffix(gitCommitOutput, []byte("\nNo changes\n")) { | |
| // No files changed, report that no CL was mailed. | |
| return "", true, nil | |
| } | |
| } | |
| return "", false, fmt.Errorf("build %q failed, see %v: %v", meta.Build.Id, b.LogUrl, b.FailureInfo) | |
| } | |
| // Extract the CL number from the output using a simple regexp. | |
| re := regexp.MustCompile(`https:\/\/go-review\.googlesource\.com\/c\/([a-zA-Z0-9_\-]+)\/\+\/(\d+)`) | |
| gitPushOutput := buildStepOutput(b, buildStepGitPush) | |
| if matches := re.FindSubmatch(gitPushOutput); len(matches) == 3 { | |
| changeID = fmt.Sprintf("%s~%s", matches[1], matches[2]) | |
| } else { | |
| return "", false, fmt.Errorf("no match for successful mail of generated CL in git push output:\n%s", gitPushOutput) | |
| } | |
| return changeID, true, nil | |
| } | |
| // Await the Cloud Build and extract the ID of the CL that was mailed. | |
| ctx.Printf("Awaiting completion of build %q in %s.", meta.Build.Id, c.ScriptProject) | |
| return AwaitCondition(ctx, 30*time.Second, completedGeneratingCL) | |
| } | |
| func (c *RealCloudBuildClient) RunCustomSteps(ctx context.Context, steps func(resultURL string) []*cloudbuildpb.BuildStep, options *CloudBuildOptions) (CloudBuild, error) { | |
| resultURL := fmt.Sprintf("%v/script-build-%v", c.ScratchURL, rand.Int63()) | |
| build := &cloudbuildpb.Build{ | |
| Steps: steps(resultURL), | |
| Options: &cloudbuildpb.BuildOptions{ | |
| MachineType: cloudbuildpb.BuildOptions_E2_HIGHCPU_8, | |
| Logging: cloudbuildpb.BuildOptions_CLOUD_LOGGING_ONLY, | |
| }, | |
| ServiceAccount: c.ScriptAccount, | |
| } | |
| if options != nil { | |
| build.AvailableSecrets = options.AvailableSecrets | |
| } | |
| op, err := c.BuildClient.CreateBuild(ctx, &cloudbuildpb.CreateBuildRequest{ | |
| ProjectId: c.ScriptProject, | |
| Build: build, | |
| }) | |
| if err != nil { | |
| return CloudBuild{}, fmt.Errorf("creating build: %w", err) | |
| } | |
| if _, err = op.Poll(ctx); err != nil { | |
| return CloudBuild{}, fmt.Errorf("polling: %w", err) | |
| } | |
| meta, err := op.Metadata() | |
| if err != nil { | |
| return CloudBuild{}, fmt.Errorf("reading metadata: %w", err) | |
| } | |
| return CloudBuild{Project: c.ScriptProject, ID: meta.Build.Id, ResultURL: resultURL}, nil | |
| } | |
| func (c *RealCloudBuildClient) Completed(ctx context.Context, build CloudBuild) (string, bool, error) { | |
| b, err := c.BuildClient.GetBuild(ctx, &cloudbuildpb.GetBuildRequest{ | |
| ProjectId: build.Project, | |
| Id: build.ID, | |
| }) | |
| if err != nil { | |
| return "", false, err | |
| } | |
| if b.FinishTime == nil { | |
| return "", false, nil | |
| } | |
| if b.Status != cloudbuildpb.Build_SUCCESS { | |
| return "", false, fmt.Errorf("build %q failed, see %v: %v", build.ID, b.LogUrl, b.FailureInfo) | |
| } | |
| return b.StatusDetail, true, nil | |
| } | |
| func (c *RealCloudBuildClient) ResultFS(ctx context.Context, build CloudBuild) (fs.FS, error) { | |
| return gcsfs.FromURL(ctx, c.StorageClient, build.ResultURL) | |
| } |