blob: 318fd8747f296743cb97f45713126c53ef261846 [file] [log] [blame]
// 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)
}