golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/fakes.go (about)

     1  // Copyright 2023 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package task
     6  
     7  import (
     8  	"archive/tar"
     9  	"bytes"
    10  	"compress/gzip"
    11  	"context"
    12  	"crypto/sha256"
    13  	"encoding/json"
    14  	"errors"
    15  	"fmt"
    16  	"io"
    17  	"io/fs"
    18  	"math/rand"
    19  	"net/http"
    20  	"net/http/httptest"
    21  	"os"
    22  	"os/exec"
    23  	"path"
    24  	"path/filepath"
    25  	"reflect"
    26  	"regexp"
    27  	"strconv"
    28  	"strings"
    29  	"sync"
    30  	"testing"
    31  	"time"
    32  
    33  	"github.com/google/uuid"
    34  	pb "go.chromium.org/luci/buildbucket/proto"
    35  	"golang.org/x/build/gerrit"
    36  	"golang.org/x/build/internal/gcsfs"
    37  	"golang.org/x/build/internal/installer/darwinpkg"
    38  	"golang.org/x/build/internal/installer/windowsmsi"
    39  	"golang.org/x/build/internal/relui/sign"
    40  	wf "golang.org/x/build/internal/workflow"
    41  	"golang.org/x/exp/slices"
    42  	"google.golang.org/protobuf/types/known/structpb"
    43  )
    44  
    45  // ServeTarball serves files as a .tar.gz to w, only if path contains pathMatch.
    46  func ServeTarball(pathMatch string, files map[string]string, w http.ResponseWriter, r *http.Request) {
    47  	if !strings.Contains(r.URL.Path, pathMatch) {
    48  		w.WriteHeader(http.StatusNotFound)
    49  		return
    50  	}
    51  	tgz, err := mapToTgz(files)
    52  	if err != nil {
    53  		panic(err)
    54  	}
    55  	if _, err := w.Write(tgz); err != nil {
    56  		panic(err)
    57  	}
    58  }
    59  
    60  func mapToTgz(files map[string]string) ([]byte, error) {
    61  	w := &bytes.Buffer{}
    62  	gzw := gzip.NewWriter(w)
    63  	tw := tar.NewWriter(gzw)
    64  
    65  	for name, contents := range files {
    66  		if err := tw.WriteHeader(&tar.Header{
    67  			Typeflag:   tar.TypeReg,
    68  			Name:       name,
    69  			Size:       int64(len(contents)),
    70  			Mode:       0777,
    71  			ModTime:    time.Now(),
    72  			AccessTime: time.Now(),
    73  			ChangeTime: time.Now(),
    74  		}); err != nil {
    75  			return nil, err
    76  		}
    77  		if _, err := tw.Write([]byte(contents)); err != nil {
    78  			return nil, err
    79  		}
    80  	}
    81  
    82  	if err := tw.Close(); err != nil {
    83  		return nil, err
    84  	}
    85  	if err := gzw.Close(); err != nil {
    86  		return nil, err
    87  	}
    88  	return w.Bytes(), nil
    89  }
    90  
    91  func NewFakeGerrit(t *testing.T, repos ...*FakeRepo) *FakeGerrit {
    92  	result := &FakeGerrit{
    93  		repos: map[string]*FakeRepo{},
    94  	}
    95  	server := httptest.NewServer(http.HandlerFunc(result.serveHTTP))
    96  	result.serverURL = server.URL
    97  	t.Cleanup(server.Close)
    98  
    99  	for _, r := range repos {
   100  		result.repos[r.name] = r
   101  	}
   102  	return result
   103  }
   104  
   105  type FakeGerrit struct {
   106  	repos     map[string]*FakeRepo
   107  	serverURL string
   108  }
   109  
   110  type FakeRepo struct {
   111  	t    *testing.T
   112  	name string
   113  	dir  *GitDir
   114  }
   115  
   116  func NewFakeRepo(t *testing.T, name string) *FakeRepo {
   117  	if _, err := exec.LookPath("git"); errors.Is(err, exec.ErrNotFound) {
   118  		t.Skip("test requires git")
   119  	}
   120  
   121  	tmpDir := t.TempDir()
   122  	repoDir := filepath.Join(tmpDir, name)
   123  	if err := os.Mkdir(repoDir, 0700); err != nil {
   124  		t.Fatalf("failed to create repository directory: %s", err)
   125  	}
   126  	r := &FakeRepo{
   127  		t:    t,
   128  		name: name,
   129  		dir:  &GitDir{&Git{}, repoDir},
   130  	}
   131  	t.Cleanup(func() { r.dir.Close() })
   132  	r.runGit("init")
   133  	r.runGit("commit", "--allow-empty", "--allow-empty-message", "-m", "")
   134  	return r
   135  }
   136  
   137  // TODO(rfindley): probably every method on FakeRepo should invoke
   138  // repo.t.Helper(), otherwise it's impossible to see where the test failed.
   139  
   140  func (repo *FakeRepo) runGit(args ...string) []byte {
   141  	repo.t.Helper()
   142  	configArgs := []string{
   143  		"-c", "init.defaultBranch=master",
   144  		"-c", "user.email=relui@example.com",
   145  		"-c", "user.name=relui",
   146  	}
   147  	out, err := repo.dir.RunCommand(context.Background(), append(configArgs, args...)...)
   148  	if err != nil {
   149  		repo.t.Fatalf("runGit(%v) failed: %v; output:\n%s", args, err, out)
   150  	}
   151  	return out
   152  }
   153  
   154  func (repo *FakeRepo) Commit(contents map[string]string) string {
   155  	return repo.CommitOnBranch("master", contents)
   156  }
   157  
   158  func (repo *FakeRepo) CommitOnBranch(branch string, contents map[string]string) string {
   159  	repo.runGit("switch", branch)
   160  	for k, v := range contents {
   161  		full := filepath.Join(repo.dir.dir, k)
   162  		if err := os.MkdirAll(filepath.Dir(full), 0777); err != nil {
   163  			repo.t.Fatal(err)
   164  		}
   165  		if err := os.WriteFile(full, []byte(v), 0777); err != nil {
   166  			repo.t.Fatal(err)
   167  		}
   168  	}
   169  	repo.runGit("add", ".")
   170  	repo.runGit("commit", "--allow-empty-message", "-m", "")
   171  	return strings.TrimSpace(string(repo.runGit("rev-parse", "HEAD")))
   172  }
   173  
   174  func (repo *FakeRepo) History() []string {
   175  	return strings.Split(string(repo.runGit("log", "--format=%H")), "\n")
   176  }
   177  
   178  func (repo *FakeRepo) Tag(tag, commit string) {
   179  	repo.runGit("tag", tag, commit)
   180  }
   181  
   182  func (repo *FakeRepo) Branch(branch, commit string) {
   183  	repo.runGit("branch", branch, commit)
   184  }
   185  
   186  func (repo *FakeRepo) ReadFile(commit, file string) ([]byte, error) {
   187  	b, err := repo.dir.RunCommand(context.Background(), "show", commit+":"+file)
   188  	if err != nil && strings.Contains(err.Error(), " does not exist ") {
   189  		err = errors.Join(gerrit.ErrResourceNotExist, err)
   190  	}
   191  	return b, err
   192  }
   193  
   194  var _ GerritClient = (*FakeGerrit)(nil)
   195  
   196  func (g *FakeGerrit) GitilesURL() string {
   197  	return g.serverURL
   198  }
   199  
   200  func (g *FakeGerrit) ListProjects(ctx context.Context) ([]string, error) {
   201  	var names []string
   202  	for k := range g.repos {
   203  		names = append(names, k)
   204  	}
   205  	return names, nil
   206  }
   207  
   208  func (g *FakeGerrit) repo(name string) (*FakeRepo, error) {
   209  	if r, ok := g.repos[name]; ok {
   210  		return r, nil
   211  	} else {
   212  		return nil, fmt.Errorf("no such repo %v: %w", name, gerrit.ErrResourceNotExist)
   213  	}
   214  }
   215  
   216  func (g *FakeGerrit) ReadBranchHead(ctx context.Context, project, branch string) (string, error) {
   217  	repo, err := g.repo(project)
   218  	if err != nil {
   219  		return "", err
   220  	}
   221  	// TODO: If the branch doesn't exist, return an error matching gerrit.ErrResourceNotExist.
   222  	out, err := repo.dir.RunCommand(ctx, "rev-parse", "refs/heads/"+branch)
   223  	return strings.TrimSpace(string(out)), err
   224  }
   225  
   226  func (g *FakeGerrit) ReadFile(ctx context.Context, project string, commit string, file string) ([]byte, error) {
   227  	repo, err := g.repo(project)
   228  	if err != nil {
   229  		return nil, err
   230  	}
   231  	return repo.ReadFile(commit, file)
   232  }
   233  
   234  func (g *FakeGerrit) ListTags(ctx context.Context, project string) ([]string, error) {
   235  	repo, err := g.repo(project)
   236  	if err != nil {
   237  		return nil, err
   238  	}
   239  	out, err := repo.dir.RunCommand(ctx, "tag", "-l")
   240  	if err != nil {
   241  		return nil, err
   242  	}
   243  	if len(out) == 0 {
   244  		return nil, nil // No tags.
   245  	}
   246  	return strings.Split(strings.TrimSpace(string(out)), "\n"), nil
   247  }
   248  
   249  func (g *FakeGerrit) GetTag(ctx context.Context, project string, tag string) (gerrit.TagInfo, error) {
   250  	repo, err := g.repo(project)
   251  	if err != nil {
   252  		return gerrit.TagInfo{}, err
   253  	}
   254  	out, err := repo.dir.RunCommand(ctx, "rev-parse", "refs/tags/"+tag)
   255  	return gerrit.TagInfo{Revision: strings.TrimSpace(string(out))}, err
   256  }
   257  
   258  func (g *FakeGerrit) CreateAutoSubmitChange(_ *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error) {
   259  	repo, err := g.repo(input.Project)
   260  	if err != nil {
   261  		return "", err
   262  	}
   263  	commit := repo.CommitOnBranch(input.Branch, contents)
   264  	return "cl_" + commit, nil
   265  }
   266  
   267  func (g *FakeGerrit) Submitted(ctx context.Context, changeID, baseCommit string) (string, bool, error) {
   268  	return strings.TrimPrefix(changeID, "cl_"), true, nil
   269  }
   270  
   271  func (g *FakeGerrit) Tag(ctx context.Context, project, tag, commit string) error {
   272  	repo, err := g.repo(project)
   273  	if err != nil {
   274  		return err
   275  	}
   276  	repo.Tag(tag, commit)
   277  	return nil
   278  }
   279  
   280  func (g *FakeGerrit) GetCommitsInRefs(ctx context.Context, project string, commits, refs []string) (map[string][]string, error) {
   281  	repo, err := g.repo(project)
   282  	if err != nil {
   283  		return nil, err
   284  	}
   285  	refSet := map[string]bool{}
   286  	for _, ref := range refs {
   287  		refSet[ref] = true
   288  	}
   289  
   290  	result := map[string][]string{}
   291  	for _, commit := range commits {
   292  		out, err := repo.dir.RunCommand(ctx, "branch", "--format=%(refname)", "--contains="+commit)
   293  		if err != nil {
   294  			return nil, err
   295  		}
   296  		for _, branch := range strings.Split(strings.TrimSpace(string(out)), "\n") {
   297  			branch := strings.TrimSpace(branch)
   298  			if refSet[branch] {
   299  				result[commit] = append(result[commit], branch)
   300  			}
   301  		}
   302  	}
   303  	return result, nil
   304  }
   305  
   306  func (g *FakeGerrit) GerritURL() string {
   307  	return g.serverURL
   308  }
   309  
   310  func (g *FakeGerrit) serveHTTP(w http.ResponseWriter, r *http.Request) {
   311  	parts := strings.Split(r.URL.Path, "/")
   312  	if len(parts) != 4 {
   313  		w.WriteHeader(http.StatusNotFound)
   314  		return
   315  	}
   316  	repo, err := g.repo(parts[1])
   317  	if err != nil {
   318  		w.WriteHeader(http.StatusNotFound)
   319  		return
   320  	}
   321  	rev := strings.TrimSuffix(parts[3], ".tar.gz")
   322  	archive, err := repo.dir.RunCommand(r.Context(), "archive", "--format=tgz", rev)
   323  	if err != nil {
   324  		w.WriteHeader(http.StatusInternalServerError)
   325  		return
   326  	}
   327  	http.ServeContent(w, r, parts[3], time.Now(), bytes.NewReader(archive))
   328  }
   329  
   330  func (*FakeGerrit) QueryChanges(_ context.Context, query string) ([]*gerrit.ChangeInfo, error) {
   331  	return nil, nil
   332  }
   333  
   334  func (*FakeGerrit) SetHashtags(_ context.Context, changeID string, _ gerrit.HashtagsInput) error {
   335  	return fmt.Errorf("pretend that SetHashtags failed")
   336  }
   337  
   338  func (*FakeGerrit) GetChange(_ context.Context, _ string, _ ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) {
   339  	return nil, nil
   340  }
   341  
   342  // NewFakeSignService returns a fake signing service that can sign PKGs, MSIs,
   343  // and generate GPG signatures. MSIs are "signed" by adding a suffix to them.
   344  // PKGs must actually be tarballs with a prefix of "I'm a PKG!\n". Any files
   345  // they contain that look like binaries will be "signed".
   346  func NewFakeSignService(t *testing.T, outputDir string) *FakeSignService {
   347  	return &FakeSignService{
   348  		t:             t,
   349  		outputDir:     outputDir,
   350  		completedJobs: map[string][]string{},
   351  	}
   352  }
   353  
   354  type FakeSignService struct {
   355  	t             *testing.T
   356  	outputDir     string
   357  	mu            sync.Mutex
   358  	completedJobs map[string][]string // Job ID → output objectURIs.
   359  }
   360  
   361  func (s *FakeSignService) SignArtifact(_ context.Context, bt sign.BuildType, in []string) (jobID string, _ error) {
   362  	s.t.Logf("fakeSignService: doing %s signing of %q", bt, in)
   363  	jobID = uuid.NewString()
   364  	var out []string
   365  	switch bt {
   366  	case sign.BuildMacOSConstructInstallerOnly:
   367  		if len(in) != 2 {
   368  			return "", fmt.Errorf("got %d inputs, want 2", len(in))
   369  		}
   370  		out = []string{s.fakeConstructPKG(jobID, in[0], in[1], fmt.Sprintf("-installer <%s>", bt))}
   371  	case sign.BuildWindowsConstructInstallerOnly:
   372  		if len(in) != 2 {
   373  			return "", fmt.Errorf("got %d inputs, want 2", len(in))
   374  		}
   375  		out = []string{s.fakeConstructMSI(jobID, in[0], in[1], fmt.Sprintf("-installer <%s>", bt))}
   376  
   377  	case sign.BuildMacOS:
   378  		if len(in) != 1 {
   379  			return "", fmt.Errorf("got %d inputs, want 1", len(in))
   380  		}
   381  		out = []string{s.fakeSignPKG(jobID, in[0], fmt.Sprintf("-signed <%s>", bt))}
   382  	case sign.BuildWindows:
   383  		if len(in) != 1 {
   384  			return "", fmt.Errorf("got %d inputs, want 1", len(in))
   385  		}
   386  		out = []string{s.fakeSignFile(jobID, in[0], fmt.Sprintf("-signed <%s>", bt))}
   387  	case sign.BuildGPG:
   388  		if len(in) == 0 {
   389  			return "", fmt.Errorf("got 0 inputs, want 1 or more")
   390  		}
   391  		for _, f := range in {
   392  			out = append(out, s.fakeGPGFile(jobID, f))
   393  		}
   394  	default:
   395  		return "", fmt.Errorf("SignArtifact: not implemented for %v", bt)
   396  	}
   397  	s.mu.Lock()
   398  	s.completedJobs[jobID] = out
   399  	s.mu.Unlock()
   400  	return jobID, nil
   401  }
   402  
   403  func (s *FakeSignService) ArtifactSigningStatus(_ context.Context, jobID string) (_ sign.Status, desc string, out []string, _ error) {
   404  	s.mu.Lock()
   405  	out, ok := s.completedJobs[jobID]
   406  	s.mu.Unlock()
   407  	if !ok {
   408  		return sign.StatusNotFound, fmt.Sprintf("job %q not found", jobID), nil, nil
   409  	}
   410  	return sign.StatusCompleted, "", out, nil
   411  }
   412  
   413  func (s *FakeSignService) CancelSigning(_ context.Context, jobID string) error {
   414  	s.t.Errorf("CancelSigning was called unexpectedly")
   415  	return fmt.Errorf("intentional fake error")
   416  }
   417  
   418  func (s *FakeSignService) fakeConstructPKG(jobID, f, meta, msg string) string {
   419  	// Check installer metadata.
   420  	b, err := os.ReadFile(strings.TrimPrefix(meta, "file://"))
   421  	if err != nil {
   422  		panic(fmt.Errorf("fakeConstructPKG: os.ReadFile: %v", err))
   423  	}
   424  	var opt darwinpkg.InstallerOptions
   425  	if err := json.Unmarshal(b, &opt); err != nil {
   426  		panic(fmt.Errorf("fakeConstructPKG: json.Unmarshal: %v", err))
   427  	}
   428  	var errs []error
   429  	switch opt.GOARCH {
   430  	case "amd64", "arm64": // OK.
   431  	default:
   432  		errs = append(errs, fmt.Errorf("unexpected GOARCH value: %q", opt.GOARCH))
   433  	}
   434  	switch min, _ := strconv.Atoi(opt.MinMacOSVersion); {
   435  	case min >= 11: // macOS 11 or greater; OK.
   436  	case opt.MinMacOSVersion == "10.15": // OK.
   437  	case opt.MinMacOSVersion == "10.13": // OK. Go 1.20 has macOS 10.13 as its minimum.
   438  	default:
   439  		errs = append(errs, fmt.Errorf("unexpected MinMacOSVersion value: %q", opt.MinMacOSVersion))
   440  	}
   441  	if err := errors.Join(errs...); err != nil {
   442  		panic(fmt.Errorf("fakeConstructPKG: unexpected installer options %#v: %v", opt, err))
   443  	}
   444  
   445  	// Construct fake installer.
   446  	b, err = os.ReadFile(strings.TrimPrefix(f, "file://"))
   447  	if err != nil {
   448  		panic(fmt.Errorf("fakeConstructPKG: os.ReadFile: %v", err))
   449  	}
   450  	return s.writeOutput(jobID, path.Base(f)+".pkg", append([]byte("I'm a PKG!\n"), b...))
   451  }
   452  
   453  func (s *FakeSignService) fakeConstructMSI(jobID, f, meta, msg string) string {
   454  	// Check installer metadata.
   455  	b, err := os.ReadFile(strings.TrimPrefix(meta, "file://"))
   456  	if err != nil {
   457  		panic(fmt.Errorf("fakeConstructMSI: os.ReadFile: %v", err))
   458  	}
   459  	var opt windowsmsi.InstallerOptions
   460  	if err := json.Unmarshal(b, &opt); err != nil {
   461  		panic(fmt.Errorf("fakeConstructMSI: json.Unmarshal: %v", err))
   462  	}
   463  	var errs []error
   464  	switch opt.GOARCH {
   465  	case "386", "amd64", "arm", "arm64": // OK.
   466  	default:
   467  		errs = append(errs, fmt.Errorf("unexpected GOARCH value: %q", opt.GOARCH))
   468  	}
   469  	if err := errors.Join(errs...); err != nil {
   470  		panic(fmt.Errorf("fakeConstructMSI: unexpected installer options %#v: %v", opt, err))
   471  	}
   472  
   473  	// Construct fake installer.
   474  	_, err = os.ReadFile(strings.TrimPrefix(f, "file://"))
   475  	if err != nil {
   476  		panic(fmt.Errorf("fakeConstructMSI: os.ReadFile: %v", err))
   477  	}
   478  	return s.writeOutput(jobID, path.Base(f)+".msi", []byte("I'm an MSI!\n"))
   479  }
   480  
   481  func (s *FakeSignService) fakeSignPKG(jobID, f, msg string) string {
   482  	b, err := os.ReadFile(strings.TrimPrefix(f, "file://"))
   483  	if err != nil {
   484  		panic(fmt.Errorf("fakeSignPKG: os.ReadFile: %v", err))
   485  	}
   486  	b, ok := bytes.CutPrefix(b, []byte("I'm a PKG!\n"))
   487  	if !ok {
   488  		panic(fmt.Errorf("fakeSignPKG: input doesn't look like a PKG to be signed"))
   489  	}
   490  	files, err := tgzToMap(bytes.NewReader(b))
   491  	if err != nil {
   492  		panic(fmt.Errorf("fakeSignPKG: tgzToMap: %v", err))
   493  	}
   494  	for fn, contents := range files {
   495  		if !strings.Contains(fn, "go/bin") && !strings.Contains(fn, "go/pkg/tool") {
   496  			continue
   497  		}
   498  		files[fn] = contents + msg
   499  	}
   500  	b, err = mapToTgz(files)
   501  	if err != nil {
   502  		panic(fmt.Errorf("fakeSignPKG: mapToTgz: %v", err))
   503  	}
   504  	b = append([]byte("I'm a PKG! "+msg+"\n"), b...)
   505  	return s.writeOutput(jobID, path.Base(f), b)
   506  }
   507  
   508  func (s *FakeSignService) writeOutput(jobID, base string, contents []byte) string {
   509  	path := path.Join(s.outputDir, jobID, base)
   510  	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
   511  		panic(fmt.Errorf("fake signing service: os.MkdirAll: %v", err))
   512  	}
   513  	if err := os.WriteFile(path, contents, 0600); err != nil {
   514  		panic(fmt.Errorf("fake signing service: os.WriteFile: %v", err))
   515  	}
   516  	return "file://" + path
   517  }
   518  
   519  func tgzToMap(r io.Reader) (map[string]string, error) {
   520  	gzr, err := gzip.NewReader(r)
   521  	if err != nil {
   522  		return nil, err
   523  	}
   524  	defer gzr.Close()
   525  
   526  	result := map[string]string{}
   527  	tr := tar.NewReader(gzr)
   528  	for {
   529  		h, err := tr.Next()
   530  		if err == io.EOF {
   531  			break
   532  		}
   533  		if err != nil {
   534  			return nil, err
   535  		}
   536  		if h.Typeflag != tar.TypeReg {
   537  			continue
   538  		}
   539  		b, err := io.ReadAll(tr)
   540  		if err != nil {
   541  			return nil, err
   542  		}
   543  		result[h.Name] = string(b)
   544  	}
   545  	return result, nil
   546  }
   547  
   548  func (s *FakeSignService) fakeSignFile(jobID, f, msg string) string {
   549  	b, err := os.ReadFile(strings.TrimPrefix(f, "file://"))
   550  	if err != nil {
   551  		panic(fmt.Errorf("fakeSignFile: os.ReadFile: %v", err))
   552  	}
   553  	b = append(b, []byte(msg)...)
   554  	return s.writeOutput(jobID, path.Base(f), b)
   555  }
   556  
   557  func (s *FakeSignService) fakeGPGFile(jobID, f string) string {
   558  	b, err := os.ReadFile(strings.TrimPrefix(f, "file://"))
   559  	if err != nil {
   560  		panic(fmt.Errorf("fakeGPGFile: os.ReadFile: %v", err))
   561  	}
   562  	gpg := fmt.Sprintf("I'm a GPG signature for %x!", sha256.Sum256(b))
   563  	return s.writeOutput(jobID, path.Base(f)+".asc", []byte(gpg))
   564  }
   565  
   566  var _ CloudBuildClient = (*FakeCloudBuild)(nil)
   567  
   568  const fakeGsutil = `
   569  #!/bin/bash -eux
   570  
   571  case "$1" in
   572  "cp")
   573    in=$2
   574    out=$3
   575    if [[ $in == '-' ]]; then
   576      in=/dev/stdin
   577    fi
   578    if [[ $out == '-' ]]; then
   579      out=/dev/stdout
   580    fi
   581    cp "${in#file://}" "${out#file://}"
   582    ;;
   583  "cat")
   584    cat "${2#file://}"
   585    ;;
   586  *)
   587    echo unexpected command $@ >&2
   588    exit 1
   589    ;;
   590  esac
   591  `
   592  
   593  func NewFakeCloudBuild(t *testing.T, gerrit *FakeGerrit, project string, allowedTriggers map[string]map[string]string, fakeGo string) *FakeCloudBuild {
   594  	toolDir := t.TempDir()
   595  	if err := os.WriteFile(filepath.Join(toolDir, "go"), []byte(fakeGo), 0777); err != nil {
   596  		t.Fatal(err)
   597  	}
   598  	if err := os.WriteFile(filepath.Join(toolDir, "gsutil"), []byte(fakeGsutil), 0777); err != nil {
   599  		t.Fatal(err)
   600  	}
   601  	return &FakeCloudBuild{
   602  		t:               t,
   603  		gerrit:          gerrit,
   604  		project:         project,
   605  		allowedTriggers: allowedTriggers,
   606  		toolDir:         toolDir,
   607  		results:         map[string]error{},
   608  	}
   609  }
   610  
   611  type FakeCloudBuild struct {
   612  	t               *testing.T
   613  	gerrit          *FakeGerrit
   614  	project         string
   615  	allowedTriggers map[string]map[string]string
   616  	toolDir         string
   617  
   618  	mu      sync.Mutex
   619  	results map[string]error
   620  }
   621  
   622  func (cb *FakeCloudBuild) RunBuildTrigger(ctx context.Context, project string, trigger string, substitutions map[string]string) (CloudBuild, error) {
   623  	if project != cb.project {
   624  		return CloudBuild{}, fmt.Errorf("unexpected project %v, want %v", project, cb.project)
   625  	}
   626  	if allowedSubs, ok := cb.allowedTriggers[trigger]; !ok || !reflect.DeepEqual(allowedSubs, substitutions) {
   627  		return CloudBuild{}, fmt.Errorf("unexpected trigger %v: got params %#v, want %#v", trigger, substitutions, allowedSubs)
   628  	}
   629  	id := fmt.Sprintf("build-%v", rand.Int63())
   630  	cb.mu.Lock()
   631  	cb.results[id] = nil
   632  	cb.mu.Unlock()
   633  	return CloudBuild{Project: project, ID: id}, nil
   634  }
   635  
   636  func (cb *FakeCloudBuild) Completed(ctx context.Context, build CloudBuild) (string, bool, error) {
   637  	if build.Project != cb.project {
   638  		return "", false, fmt.Errorf("unexpected build project: got %q, want %q", build.Project, cb.project)
   639  	}
   640  	cb.mu.Lock()
   641  	result, ok := cb.results[build.ID]
   642  	cb.mu.Unlock()
   643  	if !ok {
   644  		return "", false, fmt.Errorf("unknown build ID %q", build.ID)
   645  	}
   646  	return "here's some build detail", true, result
   647  }
   648  
   649  func (c *FakeCloudBuild) ResultFS(ctx context.Context, build CloudBuild) (fs.FS, error) {
   650  	return gcsfs.FromURL(ctx, nil, build.ResultURL)
   651  }
   652  
   653  func (cb *FakeCloudBuild) RunScript(ctx context.Context, script string, gerritProject string, outputs []string) (CloudBuild, error) {
   654  	var wd string
   655  	if gerritProject != "" {
   656  		repo, err := cb.gerrit.repo(gerritProject)
   657  		if err != nil {
   658  			return CloudBuild{}, err
   659  		}
   660  		dir, err := (&Git{}).Clone(ctx, repo.dir.dir)
   661  		if err != nil {
   662  			return CloudBuild{}, err
   663  		}
   664  		defer dir.Close()
   665  		wd = dir.dir
   666  	} else {
   667  		wd = cb.t.TempDir()
   668  	}
   669  
   670  	tempDir := cb.t.TempDir()
   671  	cmd := exec.Command("bash", "-eux")
   672  	cmd.Stdin = strings.NewReader(script)
   673  	cmd.Dir = wd
   674  	cmd.Env = os.Environ()
   675  	cmd.Env = append(cmd.Env, "TEMP="+tempDir, "TMP="+tempDir, "TEMPDIR="+tempDir, "TMPDIR="+tempDir)
   676  	cmd.Env = append(cmd.Env, "PATH="+cb.toolDir+":/bin:/usr/bin")
   677  
   678  	buf := &bytes.Buffer{}
   679  	cmd.Stdout = buf
   680  	cmd.Stderr = buf
   681  
   682  	runErr := cmd.Run()
   683  	if runErr != nil {
   684  		runErr = fmt.Errorf("script failed: %v output:\n%s", runErr, buf.String())
   685  	}
   686  	id := fmt.Sprintf("build-%v", rand.Int63())
   687  	resultDir := cb.t.TempDir()
   688  	if runErr == nil {
   689  		for _, out := range outputs {
   690  			target := filepath.Join(resultDir, out)
   691  			os.MkdirAll(filepath.Dir(target), 0777)
   692  			if err := os.Rename(filepath.Join(wd, out), target); err != nil {
   693  				runErr = fmt.Errorf("collecting outputs: %v", err)
   694  				break
   695  			}
   696  		}
   697  	}
   698  	cb.mu.Lock()
   699  	cb.results[id] = runErr
   700  	cb.mu.Unlock()
   701  	return CloudBuild{Project: cb.project, ID: id, ResultURL: "file://" + resultDir}, nil
   702  }
   703  
   704  type FakeSwarmingClient struct {
   705  	t       *testing.T
   706  	toolDir string
   707  
   708  	mu      sync.Mutex
   709  	results map[string]error
   710  }
   711  
   712  func NewFakeSwarmingClient(t *testing.T, fakeGo string) *FakeSwarmingClient {
   713  	toolDir := t.TempDir()
   714  	if err := os.WriteFile(filepath.Join(toolDir, "go"), []byte(fakeGo), 0777); err != nil {
   715  		t.Fatal(err)
   716  	}
   717  	if err := os.WriteFile(filepath.Join(toolDir, "gsutil"), []byte(fakeGsutil), 0777); err != nil {
   718  		t.Fatal(err)
   719  	}
   720  	return &FakeSwarmingClient{
   721  		t:       t,
   722  		toolDir: toolDir,
   723  		results: map[string]error{},
   724  	}
   725  }
   726  
   727  var _ SwarmingClient = (*FakeSwarmingClient)(nil)
   728  
   729  func (c *FakeSwarmingClient) RunTask(ctx context.Context, dims map[string]string, script string, env map[string]string) (string, error) {
   730  	tempDir := c.t.TempDir()
   731  	cmd := exec.Command("bash", "-eux")
   732  	cmd.Stdin = strings.NewReader("set -o pipefail\n" + script)
   733  	cmd.Dir = c.t.TempDir()
   734  	cmd.Env = os.Environ()
   735  	cmd.Env = append(cmd.Env, "TEMP="+tempDir, "TMP="+tempDir, "TEMPDIR="+tempDir, "TMPDIR="+tempDir)
   736  	cmd.Env = append(cmd.Env, "PATH="+c.toolDir+":/bin:/usr/bin:.") // Note: . is on PATH to help with Windows compatibility
   737  	for k, v := range env {
   738  		cmd.Env = append(cmd.Env, k+"="+v)
   739  	}
   740  	buf := &bytes.Buffer{}
   741  	cmd.Stdout = buf
   742  	cmd.Stderr = buf
   743  
   744  	runErr := cmd.Run()
   745  	if runErr != nil {
   746  		runErr = fmt.Errorf("script failed: %v output:\n%s", runErr, buf.String())
   747  	}
   748  	id := fmt.Sprintf("build-%v", rand.Int63())
   749  	c.mu.Lock()
   750  	c.results[id] = runErr
   751  	c.mu.Unlock()
   752  	return id, nil
   753  }
   754  
   755  func (c *FakeSwarmingClient) Completed(ctx context.Context, id string) (string, bool, error) {
   756  	c.mu.Lock()
   757  	result, ok := c.results[id]
   758  	c.mu.Unlock()
   759  	if !ok {
   760  		return "", false, fmt.Errorf("unknown task ID %q", id)
   761  	}
   762  	return "here's some build detail", true, result
   763  }
   764  
   765  func NewFakeBuildBucketClient(major int, url, bucket string, projects []string) *FakeBuildBucketClient {
   766  	return &FakeBuildBucketClient{
   767  		Bucket:    bucket,
   768  		major:     major,
   769  		GerritURL: url,
   770  		Projects:  projects,
   771  		results:   map[int64]error{},
   772  	}
   773  }
   774  
   775  type FakeBuildBucketClient struct {
   776  	Bucket            string
   777  	FailBuilds        []string
   778  	MissingBuilds     []string
   779  	major             int
   780  	GerritURL, Branch string
   781  	Projects          []string
   782  
   783  	mu      sync.Mutex
   784  	results map[int64]error
   785  }
   786  
   787  var _ BuildBucketClient = (*FakeBuildBucketClient)(nil)
   788  
   789  func (c *FakeBuildBucketClient) ListBuilders(ctx context.Context, bucket string) (map[string]*pb.BuilderConfig, error) {
   790  	if bucket != c.Bucket {
   791  		return nil, fmt.Errorf("unexpected bucket %q", bucket)
   792  	}
   793  	res := map[string]*pb.BuilderConfig{}
   794  	for _, proj := range c.Projects {
   795  		prefix := ""
   796  		if proj != "go" {
   797  			prefix = "x_" + proj + "-"
   798  		}
   799  		for _, v := range []string{"gotip", fmt.Sprintf("go1.%v", c.major)} {
   800  			for _, b := range []string{"linux-amd64", "linux-amd64-longtest", "darwin-amd64_13"} {
   801  				parts := strings.FieldsFunc(b, func(r rune) bool { return r == '-' || r == '_' })
   802  				res[prefix+v+"-"+b] = &pb.BuilderConfig{
   803  					Properties: fmt.Sprintf(`{"project":%q, "is_google":true, "target":{"goos":%q, "goarch":%q}}`, proj, parts[0], parts[1]),
   804  				}
   805  			}
   806  		}
   807  	}
   808  	return res, nil
   809  }
   810  
   811  func (c *FakeBuildBucketClient) RunBuild(ctx context.Context, bucket string, builder string, commit *pb.GitilesCommit, properties map[string]*structpb.Value) (int64, error) {
   812  	if bucket != c.Bucket {
   813  		return 0, fmt.Errorf("unexpected bucket %q", bucket)
   814  	}
   815  	match := regexp.MustCompile(`.*://(.+)`).FindStringSubmatch(c.GerritURL)
   816  	if commit.Host != match[1] || !slices.Contains(c.Projects, commit.Project) {
   817  		return 0, fmt.Errorf("unexpected host or project: got %q, %q want %q, %q", commit.Host, commit.Project, match[1], c.Projects)
   818  	}
   819  	// It would be nice to validate the commit hash and branch, but it's
   820  	// tricky to get the right value because it depends on the release type.
   821  	// At least validate the commit is a commit.
   822  	if len(commit.Id) != 40 {
   823  		return 0, fmt.Errorf("malformed Git commit hash %q", commit.Id)
   824  	}
   825  	var runErr error
   826  	for _, failBuild := range c.FailBuilds {
   827  		if strings.Contains(builder, failBuild) {
   828  			runErr = fmt.Errorf("run of %q is specified to fail", builder)
   829  		}
   830  	}
   831  
   832  	id := rand.Int63()
   833  	c.mu.Lock()
   834  	c.results[id] = runErr
   835  	c.mu.Unlock()
   836  	return id, nil
   837  }
   838  
   839  func (c *FakeBuildBucketClient) Completed(ctx context.Context, id int64) (string, bool, error) {
   840  	c.mu.Lock()
   841  	result, ok := c.results[id]
   842  	c.mu.Unlock()
   843  	if !ok {
   844  		return "", false, fmt.Errorf("unknown task ID %q", id)
   845  	}
   846  	return "here's some build detail", true, result
   847  }
   848  
   849  func (c *FakeBuildBucketClient) SearchBuilds(ctx context.Context, pred *pb.BuildPredicate) ([]int64, error) {
   850  	if slices.Contains(c.MissingBuilds, pred.GetBuilder().GetBuilder()) {
   851  		return nil, nil
   852  	}
   853  	return []int64{rand.Int63()}, nil
   854  }