| // 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. |
| // 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 version of Go |
| // and some version of gsutil 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 'gsutil 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 to customize 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 { |
| saveOutputsScript.WriteString(fmt.Sprintf("gsutil 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/gsutil", |
| Script: cloudBuildClientScriptPrefix + script, |
| Dir: dir, |
| }, |
| &cloudbuildpb.BuildStep{ |
| Name: "gcr.io/cloud-builders/gsutil", |
| 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) |
| 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. |
| // |
| // Whether the push successfully created a CL or not will be determined from |
| // the output text. |
| Args: []string{"-c", `git push origin ` + refspec + ` 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) |
| } |