internal/task: add a step to run test for vscode-go extension

Add new method to interface CloudBuildClient:
- RunCustomSteps accept a function as input parameter.
- The input function should return a slice of cloud steps which will be
  eventually executed by the method.
- The input function should accept an input parameter "resultURL" and
  can assume this will be valid so the caller can write output to it.
- The method will generate a random result url and pass that to the
  input function to generate the cloud steps.

A local relui screenshot is at https://github.com/golang/vscode-go/issues/3500#issuecomment-2341760224

For golang/vscode-go#3500

Change-Id: Icdc6411ab8274b71783c15a0ccf7ecae0d61b3a5
Reviewed-on: https://go-review.googlesource.com/c/build/+/610375
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
This commit is contained in:
Hongxiang Jiang 2024-09-03 19:20:14 +00:00
Родитель 4ca568d461
Коммит e0ba88339e
6 изменённых файлов: 231 добавлений и 39 удалений

Просмотреть файл

@ -313,6 +313,7 @@ func main() {
V4: githubv4.NewClient(githubHTTPClient),
},
Gerrit: gerritClient,
CloudBuild: cloudBuildClient,
ApproveAction: relui.ApproveActionDep(dbPool),
}
dh.RegisterDefinition("Create a vscode-go release candidate", releaseVSCodeGoTasks.NewPrereleaseDefinition())

Просмотреть файл

@ -28,6 +28,13 @@ type CloudBuildClient interface {
// 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) (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)
@ -78,17 +85,16 @@ archive=$(wget -qO - 'https://go.dev/dl/?mode=json' | grep -Eo 'go.*linux-amd64.
wget -qO - https://go.dev/dl/${archive} | tar -xz
mv go /workspace/released_go
`
const scriptPrefix = `#!/usr/bin/env bash
set -eux
set -o pipefail
export PATH=/workspace/released_go/bin:$PATH
`
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.
resultURL := fmt.Sprintf("%v/script-build-%v", c.ScratchURL, rand.Int63())
var saveOutputsScript strings.Builder
saveOutputsScript.WriteString(scriptPrefix)
for _, out := range outputs {
@ -104,9 +110,7 @@ export PATH=/workspace/released_go/bin:$PATH
})
dir = "checkout"
}
build := &cloudbuildpb.Build{
Steps: append(steps,
steps = append(steps,
&cloudbuildpb.BuildStep{
Name: "bash",
Script: downloadGoScript,
@ -121,7 +125,17 @@ export PATH=/workspace/released_go/bin:$PATH
Script: saveOutputsScript.String(),
Dir: dir,
},
),
)
return steps
}
return c.RunCustomSteps(ctx, steps)
}
func (c *RealCloudBuildClient) RunCustomSteps(ctx context.Context, steps func(resultURL string) []*cloudbuildpb.BuildStep) (CloudBuild, error) {
build := &cloudbuildpb.Build{
Steps: steps(fmt.Sprintf("%v/script-build-%v", c.ScratchURL, rand.Int63())),
Options: &cloudbuildpb.BuildOptions{
MachineType: cloudbuildpb.BuildOptions_E2_HIGHCPU_8,
Logging: cloudbuildpb.BuildOptions_CLOUD_LOGGING_ONLY,
@ -142,8 +156,7 @@ export PATH=/workspace/released_go/bin:$PATH
if err != nil {
return CloudBuild{}, fmt.Errorf("reading metadata: %w", err)
}
return CloudBuild{Project: c.ScriptProject, ID: meta.Build.Id, ResultURL: resultURL}, nil
return CloudBuild{Project: c.ScriptProject, ID: meta.Build.Id}, nil
}
func (c *RealCloudBuildClient) Completed(ctx context.Context, build CloudBuild) (string, bool, error) {

Просмотреть файл

@ -30,6 +30,7 @@ import (
"testing"
"time"
"cloud.google.com/go/cloudbuild/apiv1/v2/cloudbuildpb"
"github.com/google/go-github/v48/github"
"github.com/google/uuid"
"github.com/shurcooL/githubv4"
@ -631,6 +632,12 @@ case "$1" in
esac
`
const fakeChown = `
#!/bin/bash -eux
echo "chown change owner successful"
exit 0
`
func NewFakeCloudBuild(t *testing.T, gerrit *FakeGerrit, project string, allowedTriggers map[string]map[string]string, fakeGo string) *FakeCloudBuild {
toolDir := t.TempDir()
if err := os.WriteFile(filepath.Join(toolDir, "go"), []byte(fakeGo), 0777); err != nil {
@ -639,6 +646,9 @@ func NewFakeCloudBuild(t *testing.T, gerrit *FakeGerrit, project string, allowed
if err := os.WriteFile(filepath.Join(toolDir, "gsutil"), []byte(fakeGsutil), 0777); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(toolDir, "chown"), []byte(fakeChown), 0777); err != nil {
t.Fatal(err)
}
return &FakeCloudBuild{
t: t,
gerrit: gerrit,
@ -742,6 +752,44 @@ func (cb *FakeCloudBuild) RunScript(ctx context.Context, script string, gerritPr
return CloudBuild{Project: cb.project, ID: id, ResultURL: "file://" + resultDir}, nil
}
func (cb *FakeCloudBuild) RunCustomSteps(ctx context.Context, steps func(resultURL string) []*cloudbuildpb.BuildStep) (CloudBuild, error) {
var gerritProject, fullScript string
resultURL := "file://" + cb.t.TempDir()
for _, step := range steps(resultURL) {
tool, found := strings.CutPrefix(step.Name, "gcr.io/cloud-builders/")
if !found {
return CloudBuild{}, fmt.Errorf("does not support custom image: %s", step.Name)
}
if tool == "git" && len(step.Args) > 0 && step.Args[0] == "clone" {
for _, arg := range step.Args {
project, found := strings.CutPrefix(arg, "https://go.googlesource.com/")
if found {
gerritProject = project
break
}
}
continue
}
if len(step.Args) > 0 {
fullScript += tool + " " + strings.Join(step.Args, " ") + "\n"
}
if step.Script != "" {
fullScript += step.Script + "\n"
}
}
// In real CloudBuild client, the RunScript calls this lower level method.
build, err := cb.RunScript(ctx, fullScript, gerritProject, nil)
if err != nil {
return CloudBuild{}, err
}
// Overwrites the ResultURL as the actual output is written to a unique result
// directory generated by this method.
// Unit tests should verify the contents of this directory.
// The ResultURL returned by RunScript is not used for output and will always
// point to a new, empty directory.
return CloudBuild{ID: build.ID, Project: build.Project, ResultURL: resultURL}, nil
}
type FakeSwarmingClient struct {
t *testing.T
toolDir string

Просмотреть файл

@ -673,7 +673,7 @@ func (r *ReleaseGoplsTasks) updateVSCodeGoGoplsVersion(ctx *wf.TaskContext, revi
}
branches := []string{"master"}
if prerelease == "" {
releaseBranch, err := vsCodeGoActiveReleaseBranch(ctx, r.Gerrit)
releaseBranch, err := vscodeGoActiveReleaseBranch(ctx, r.Gerrit)
if err != nil {
return nil, err
}

Просмотреть файл

@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"cloud.google.com/go/cloudbuild/apiv1/v2/cloudbuildpb"
"github.com/google/go-github/v48/github"
"golang.org/x/build/gerrit"
"golang.org/x/build/internal/relui/groups"
@ -52,6 +53,7 @@ import (
type ReleaseVSCodeGoTasks struct {
Gerrit GerritClient
GitHub GitHubClientInterface
CloudBuild CloudBuildClient
ApproveAction func(*wf.TaskContext) error
}
@ -68,11 +70,11 @@ var nextVersionParam = wf.ParamDef[string]{
}
//go:embed template/vscode-go-release-issue.md
var vscodeGOReleaseIssueTmplStr string
var vscodeGoReleaseIssueTmplStr string
// vsCodeGoActiveReleaseBranch returns the current active release branch in
// vscodeGoActiveReleaseBranch returns the current active release branch in
// vscode-go project.
func vsCodeGoActiveReleaseBranch(ctx *wf.TaskContext, gerrit GerritClient) (string, error) {
func vscodeGoActiveReleaseBranch(ctx *wf.TaskContext, gerrit GerritClient) (string, error) {
branches, err := gerrit.ListBranches(ctx, "vscode-go")
if err != nil {
return "", err
@ -132,17 +134,78 @@ func (r *ReleaseVSCodeGoTasks) NewPrereleaseDefinition() *wf.Definition {
release := wf.Task1(wd, "determine the release version", r.determineReleaseVersion, versionBumpStrategy)
prerelease := wf.Task1(wd, "find the next pre-release version", r.nextPrereleaseVersion, release)
revision := wf.Task2(wd, "find the revision for the pre-release version", r.findRevision, release, prerelease)
approved := wf.Action2(wd, "await release coordinator's approval", r.approvePrereleaseVersion, release, prerelease)
_ = wf.Task1(wd, "create release milestone and issue", r.createReleaseMilestoneAndIssue, release, wf.After(approved))
verified := wf.Action1(wd, "verify the release candidate", r.verifyTestResults, revision, wf.After(approved))
branched := wf.Action2(wd, "create release branch", r.createReleaseBranch, release, prerelease, wf.After(approved))
// TODO(hxjiang): replace empty commit with the branch's head once verified.
_ = wf.Action3(wd, "tag release candidate", r.tag, wf.Const(""), release, prerelease, wf.After(branched))
_ = wf.Task1(wd, "create release milestone and issue", r.createReleaseMilestoneAndIssue, release, wf.After(verified))
branched := wf.Action2(wd, "create release branch", r.createReleaseBranch, release, prerelease, wf.After(verified))
_ = wf.Action3(wd, "tag release candidate", r.tag, revision, release, prerelease, wf.After(branched))
return wd
}
// findRevision determines the appropriate revision for the current release.
// Returns the head of the master branch if this is the first release candidate
// for a stable minor version (as no release branch exists yet).
// Returns the head of the corresponding release branch otherwise.
func (r *ReleaseVSCodeGoTasks) findRevision(ctx *wf.TaskContext, release releaseVersion, prerelease string) (string, error) {
branch := fmt.Sprintf("release-v%v.%v", release.Major, release.Minor)
if release.Patch == 0 && prerelease == "rc.1" {
branch = "master"
}
return r.Gerrit.ReadBranchHead(ctx, "vscode-go", branch)
}
func (r *ReleaseVSCodeGoTasks) verifyTestResults(ctx *wf.TaskContext, revision string) error {
// We are running all tests in a docker as a user 'node' (uid: 1000)
// Let the user own the directory.
testScript := `#!/usr/bin/env bash
set -eux
set -o pipefail
chown -R 1000:1000 .
./build/all.bash testlocal &> output.log
`
build, err := r.CloudBuild.RunCustomSteps(ctx, func(resultURL string) []*cloudbuildpb.BuildStep {
return []*cloudbuildpb.BuildStep{
{
Name: "gcr.io/cloud-builders/git",
Args: []string{"clone", "https://go.googlesource.com/vscode-go", "vscode-go"},
},
{
Name: "gcr.io/cloud-builders/git",
Args: []string{"checkout", revision},
Dir: "vscode-go",
},
{
Name: "gcr.io/cloud-builders/docker",
Script: testScript,
Dir: "vscode-go",
},
{
Name: "gcr.io/cloud-builders/gsutil",
Args: []string{"cp", "output.log", fmt.Sprintf("%s/output.log", resultURL)},
Dir: "vscode-go",
},
}
})
if err != nil {
return err
}
outputs, err := buildToOutputs(ctx, r.CloudBuild, build)
if err != nil {
return err
}
ctx.Printf("the output from test run:\n%s\n", outputs["output.log"])
return nil
}
func (r *ReleaseVSCodeGoTasks) createReleaseMilestoneAndIssue(ctx *wf.TaskContext, semv releaseVersion) (int, error) {
version := fmt.Sprintf("v%v.%v.%v", semv.Major, semv.Minor, semv.Patch)
@ -168,7 +231,7 @@ func (r *ReleaseVSCodeGoTasks) createReleaseMilestoneAndIssue(ctx *wf.TaskContex
}
}
content := fmt.Sprintf(vscodeGOReleaseIssueTmplStr, version)
content := fmt.Sprintf(vscodeGoReleaseIssueTmplStr, version)
issue, _, err := r.GitHub.CreateIssue(ctx, "golang", "vscode-go", &github.IssueRequest{
Title: &title,
Body: &content,

Просмотреть файл

@ -346,13 +346,80 @@ func TestVSCodeGoActiveReleaseBranch(t *testing.T) {
Context: context.Background(),
Logger: &testLogger{t, ""},
}
got, err := vsCodeGoActiveReleaseBranch(ctx, gerrit)
got, err := vscodeGoActiveReleaseBranch(ctx, gerrit)
if err != nil {
t.Fatal(err)
}
if tc.want != got {
t.Errorf("vsCodeGoActiveReleaseBranch() = %q, want %q", got, tc.want)
t.Errorf("vscodeGoActiveReleaseBranch() = %q, want %q", got, tc.want)
}
})
}
}
func TestVerifyTestResults(t *testing.T) {
mustHaveShell(t)
fakeScriptFmt := `#!/bin/bash -exu
case "$1" in
"testlocal")
echo "the testlocal return %v"
exit %v
;;
*)
echo unexpected command $@
exit 1
;;
esac
`
testcases := []struct {
name string
rc int
wantErr bool
}{
{
name: "test failed, return error",
rc: 1,
wantErr: true,
},
{
name: "test passed, return nil",
rc: 0,
wantErr: false,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
vscodego := NewFakeRepo(t, "vscode-go")
commit := vscodego.Commit(map[string]string{
"go.mod": "module github.com/golang/vscode-go\n",
"go.sum": "\n",
"build/all.bash": fmt.Sprintf(fakeScriptFmt, tc.rc, tc.rc),
})
// Overwrite the script to empty to make sure vscode-go flow will checkout
// the specific commit.
_ = vscodego.Commit(map[string]string{
"build/all.bash": "",
})
gerrit := NewFakeGerrit(t, vscodego)
ctx := &workflow.TaskContext{
Context: context.Background(),
Logger: &testLogger{t, ""},
}
tasks := &ReleaseVSCodeGoTasks{
Gerrit: gerrit,
CloudBuild: NewFakeCloudBuild(t, gerrit, "vscode-go", nil, ""),
}
err := tasks.verifyTestResults(ctx, commit)
if tc.wantErr && err == nil {
t.Errorf("verifyTestResult() should return error but return nil")
} else if !tc.wantErr && err != nil {
t.Errorf("verifyTestResult() should return nil but return err: %v", err)
}
})
}