| // 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 ( |
| "archive/tar" |
| "bytes" |
| "compress/gzip" |
| "context" |
| "crypto/sha256" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "io/fs" |
| "math/rand" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "os/exec" |
| "path" |
| "path/filepath" |
| "reflect" |
| "regexp" |
| "strconv" |
| "strings" |
| "sync" |
| "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" |
| pb "go.chromium.org/luci/buildbucket/proto" |
| "golang.org/x/build/gerrit" |
| "golang.org/x/build/internal/gcsfs" |
| "golang.org/x/build/internal/installer/darwinpkg" |
| "golang.org/x/build/internal/installer/windowsmsi" |
| "golang.org/x/build/internal/relui/sign" |
| wf "golang.org/x/build/internal/workflow" |
| "golang.org/x/exp/slices" |
| "google.golang.org/protobuf/types/known/structpb" |
| ) |
| |
| // ServeTarball serves files as a .tar.gz to w, only if path contains pathMatch. |
| func ServeTarball(pathMatch string, files map[string]string, w http.ResponseWriter, r *http.Request) { |
| if !strings.Contains(r.URL.Path, pathMatch) { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| tgz, err := mapToTgz(files) |
| if err != nil { |
| panic(err) |
| } |
| if _, err := w.Write(tgz); err != nil { |
| panic(err) |
| } |
| } |
| |
| func mapToTgz(files map[string]string) ([]byte, error) { |
| w := &bytes.Buffer{} |
| gzw := gzip.NewWriter(w) |
| tw := tar.NewWriter(gzw) |
| |
| for name, contents := range files { |
| if err := tw.WriteHeader(&tar.Header{ |
| Typeflag: tar.TypeReg, |
| Name: name, |
| Size: int64(len(contents)), |
| Mode: 0777, |
| ModTime: time.Now(), |
| AccessTime: time.Now(), |
| ChangeTime: time.Now(), |
| }); err != nil { |
| return nil, err |
| } |
| if _, err := tw.Write([]byte(contents)); err != nil { |
| return nil, err |
| } |
| } |
| |
| if err := tw.Close(); err != nil { |
| return nil, err |
| } |
| if err := gzw.Close(); err != nil { |
| return nil, err |
| } |
| return w.Bytes(), nil |
| } |
| |
| func NewFakeGerrit(t *testing.T, repos ...*FakeRepo) *FakeGerrit { |
| result := &FakeGerrit{ |
| repos: map[string]*FakeRepo{}, |
| } |
| server := httptest.NewServer(http.HandlerFunc(result.serveHTTP)) |
| result.serverURL = server.URL |
| t.Cleanup(server.Close) |
| |
| for _, r := range repos { |
| result.repos[r.name] = r |
| } |
| return result |
| } |
| |
| type FakeGerrit struct { |
| repos map[string]*FakeRepo |
| serverURL string |
| } |
| |
| type FakeRepo struct { |
| t *testing.T |
| name string |
| dir *GitDir |
| } |
| |
| func NewFakeRepo(t *testing.T, name string) *FakeRepo { |
| if _, err := exec.LookPath("git"); errors.Is(err, exec.ErrNotFound) { |
| t.Skip("test requires git") |
| } |
| |
| tmpDir := t.TempDir() |
| repoDir := filepath.Join(tmpDir, name) |
| if err := os.Mkdir(repoDir, 0700); err != nil { |
| t.Fatalf("failed to create repository directory: %s", err) |
| } |
| r := &FakeRepo{ |
| t: t, |
| name: name, |
| dir: &GitDir{&Git{}, repoDir}, |
| } |
| t.Cleanup(func() { r.dir.Close() }) |
| r.runGit("init") |
| r.runGit("commit", "--allow-empty", "--allow-empty-message", "-m", "") |
| return r |
| } |
| |
| // TODO(rfindley): probably every method on FakeRepo should invoke |
| // repo.t.Helper(), otherwise it's impossible to see where the test failed. |
| |
| func (repo *FakeRepo) runGit(args ...string) []byte { |
| repo.t.Helper() |
| configArgs := []string{ |
| "-c", "init.defaultBranch=master", |
| "-c", "user.email=relui@example.com", |
| "-c", "user.name=relui", |
| } |
| out, err := repo.dir.RunCommand(context.Background(), append(configArgs, args...)...) |
| if err != nil { |
| repo.t.Fatalf("runGit(%v) failed: %v; output:\n%s", args, err, out) |
| } |
| return out |
| } |
| |
| func (repo *FakeRepo) Commit(contents map[string]string) string { |
| return repo.CommitOnBranch("master", contents) |
| } |
| |
| func (repo *FakeRepo) CommitOnBranch(branch string, contents map[string]string) string { |
| repo.runGit("switch", branch) |
| for k, v := range contents { |
| full := filepath.Join(repo.dir.dir, k) |
| if err := os.MkdirAll(filepath.Dir(full), 0777); err != nil { |
| repo.t.Fatal(err) |
| } |
| if err := os.WriteFile(full, []byte(v), 0777); err != nil { |
| repo.t.Fatal(err) |
| } |
| } |
| repo.runGit("add", ".") |
| repo.runGit("commit", "--allow-empty-message", "-m", "") |
| return strings.TrimSpace(string(repo.runGit("rev-parse", "HEAD"))) |
| } |
| |
| func (repo *FakeRepo) History() []string { |
| return strings.Split(string(repo.runGit("log", "--format=%H")), "\n") |
| } |
| |
| func (repo *FakeRepo) Tag(tag, commit string) { |
| repo.runGit("tag", tag, commit) |
| } |
| |
| func (repo *FakeRepo) Branch(branch, commit string) { |
| repo.runGit("branch", branch, commit) |
| } |
| |
| func (repo *FakeRepo) ReadFile(commit, file string) ([]byte, error) { |
| b, err := repo.dir.RunCommand(context.Background(), "show", commit+":"+file) |
| if err != nil && strings.Contains(err.Error(), " does not exist ") { |
| err = errors.Join(gerrit.ErrResourceNotExist, err) |
| } |
| return b, err |
| } |
| |
| var _ GerritClient = (*FakeGerrit)(nil) |
| |
| func (g *FakeGerrit) GitilesURL() string { |
| return g.serverURL |
| } |
| |
| func (g *FakeGerrit) ListProjects(ctx context.Context) ([]string, error) { |
| var names []string |
| for k := range g.repos { |
| names = append(names, k) |
| } |
| return names, nil |
| } |
| |
| func (g *FakeGerrit) repo(name string) (*FakeRepo, error) { |
| if r, ok := g.repos[name]; ok { |
| return r, nil |
| } else { |
| return nil, fmt.Errorf("no such repo %v: %w", name, gerrit.ErrResourceNotExist) |
| } |
| } |
| |
| func (g *FakeGerrit) ReadBranchHead(ctx context.Context, project, branch string) (string, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return "", err |
| } |
| out, err := repo.dir.RunCommand(ctx, "rev-parse", "refs/heads/"+branch) |
| if err != nil { |
| // TODO(hxjiang): switch to git show-ref --exists refs/heads/branch after |
| // upgrade git to 2.43.0. |
| // https://git-scm.com/docs/git-show-ref/2.43.0#Documentation/git-show-ref.txt---exists |
| if strings.Contains(err.Error(), "unknown revision or path not in the working tree") { |
| return "", gerrit.ErrResourceNotExist |
| } |
| // Returns empty string if the error is nil to align the same behavior with |
| // the real Gerrit client. |
| return "", err |
| } |
| return strings.TrimSpace(string(out)), nil |
| } |
| |
| func (g *FakeGerrit) ListBranches(ctx context.Context, project string) ([]gerrit.BranchInfo, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return nil, err |
| } |
| out, err := repo.dir.RunCommand(ctx, "for-each-ref", "--format=%(refname) %(objectname:short)", "refs/heads/") |
| if err != nil { |
| return nil, err |
| } |
| var infos []gerrit.BranchInfo |
| for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { |
| branchCommit := strings.Fields(line) |
| infos = append(infos, gerrit.BranchInfo{Ref: branchCommit[0], Revision: branchCommit[1]}) |
| } |
| return infos, nil |
| } |
| |
| func (g *FakeGerrit) CreateBranch(ctx context.Context, project, branch string, input gerrit.BranchInput) (string, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return "", err |
| } |
| if _, err = repo.dir.RunCommand(ctx, "branch", branch, input.Revision); err != nil { |
| return "", err |
| } |
| |
| return g.ReadBranchHead(ctx, project, branch) |
| } |
| |
| func (g *FakeGerrit) ReadFile(ctx context.Context, project string, commit string, file string) ([]byte, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return nil, err |
| } |
| return repo.ReadFile(commit, file) |
| } |
| |
| func (g *FakeGerrit) ListTags(ctx context.Context, project string) ([]string, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return nil, err |
| } |
| out, err := repo.dir.RunCommand(ctx, "tag", "-l") |
| if err != nil { |
| return nil, err |
| } |
| if len(out) == 0 { |
| return nil, nil // No tags. |
| } |
| return strings.Split(strings.TrimSpace(string(out)), "\n"), nil |
| } |
| |
| func (g *FakeGerrit) GetTag(ctx context.Context, project string, tag string) (gerrit.TagInfo, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return gerrit.TagInfo{}, err |
| } |
| out, err := repo.dir.RunCommand(ctx, "rev-parse", "refs/tags/"+tag) |
| return gerrit.TagInfo{Revision: strings.TrimSpace(string(out))}, err |
| } |
| |
| func (g *FakeGerrit) CreateAutoSubmitChange(_ *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, contents map[string]string) (string, error) { |
| repo, err := g.repo(input.Project) |
| if err != nil { |
| return "", err |
| } |
| commit := repo.CommitOnBranch(input.Branch, contents) |
| return "cl_" + commit, nil |
| } |
| |
| func (g *FakeGerrit) Submitted(ctx context.Context, changeID, baseCommit string) (string, bool, error) { |
| return strings.TrimPrefix(changeID, "cl_"), true, nil |
| } |
| |
| func (g *FakeGerrit) Tag(ctx context.Context, project, tag, commit string) error { |
| repo, err := g.repo(project) |
| if err != nil { |
| return err |
| } |
| repo.Tag(tag, commit) |
| return nil |
| } |
| |
| func (g *FakeGerrit) GetCommitsInRefs(ctx context.Context, project string, commits, refs []string) (map[string][]string, error) { |
| repo, err := g.repo(project) |
| if err != nil { |
| return nil, err |
| } |
| refSet := map[string]bool{} |
| for _, ref := range refs { |
| refSet[ref] = true |
| } |
| |
| result := map[string][]string{} |
| for _, commit := range commits { |
| out, err := repo.dir.RunCommand(ctx, "branch", "--format=%(refname)", "--contains="+commit) |
| if err != nil { |
| return nil, err |
| } |
| for _, branch := range strings.Split(strings.TrimSpace(string(out)), "\n") { |
| branch := strings.TrimSpace(branch) |
| if refSet[branch] { |
| result[commit] = append(result[commit], branch) |
| } |
| } |
| } |
| return result, nil |
| } |
| |
| func (g *FakeGerrit) GerritURL() string { |
| return g.serverURL |
| } |
| |
| func (g *FakeGerrit) serveHTTP(w http.ResponseWriter, req *http.Request) { |
| switch url := req.URL.String(); { |
| // Serve a revision tarball (.tar.gz) like Gerrit does. |
| case strings.HasSuffix(url, ".tar.gz"): |
| parts := strings.Split(req.URL.Path, "/") |
| if len(parts) != 4 { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| repo, err := g.repo(parts[1]) |
| if err != nil { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| rev := strings.TrimSuffix(parts[3], ".tar.gz") |
| archive, err := repo.dir.RunCommand(req.Context(), "archive", "--format=tgz", rev) |
| if err != nil { |
| w.WriteHeader(http.StatusInternalServerError) |
| return |
| } |
| http.ServeContent(w, req, parts[3], time.Now(), bytes.NewReader(archive)) |
| return |
| |
| // Serve a git repository over HTTP like Gerrit does. |
| case strings.HasSuffix(url, "/info/refs?service=git-upload-pack"): |
| parts := strings.Split(url[:len(url)-len("/info/refs?service=git-upload-pack")], "/") |
| if len(parts) != 2 { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| repo, err := g.repo(parts[1]) |
| if err != nil { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| g.serveGitInfoRefsUploadPack(w, req, repo) |
| return |
| case strings.HasSuffix(url, "/git-upload-pack"): |
| parts := strings.Split(url[:len(url)-len("/git-upload-pack")], "/") |
| if len(parts) != 2 { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| repo, err := g.repo(parts[1]) |
| if err != nil { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| g.serveGitUploadPack(w, req, repo) |
| return |
| |
| default: |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| } |
| func (*FakeGerrit) serveGitInfoRefsUploadPack(w http.ResponseWriter, req *http.Request, repo *FakeRepo) { |
| if req.Method != http.MethodGet { |
| w.Header().Set("Allow", http.MethodGet) |
| http.Error(w, "method should be GET", http.StatusMethodNotAllowed) |
| return |
| } |
| cmd := exec.CommandContext(req.Context(), "git", "upload-pack", "--strict", "--advertise-refs", ".") |
| cmd.Dir = filepath.Join(repo.dir.dir, ".git") |
| cmd.Env = append(os.Environ(), "GIT_PROTOCOL="+req.Header.Get("Git-Protocol")) |
| var buf bytes.Buffer |
| cmd.Stdout = &buf |
| err := cmd.Run() |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") |
| io.WriteString(w, "001e# service=git-upload-pack\n0000") |
| io.Copy(w, &buf) |
| } |
| func (*FakeGerrit) serveGitUploadPack(w http.ResponseWriter, req *http.Request, repo *FakeRepo) { |
| if req.Method != http.MethodPost { |
| w.Header().Set("Allow", http.MethodPost) |
| http.Error(w, "method should be POST", http.StatusMethodNotAllowed) |
| return |
| } |
| if req.Header.Get("Content-Type") != "application/x-git-upload-pack-request" { |
| http.Error(w, "unexpected Content-Type", http.StatusBadRequest) |
| return |
| } |
| cmd := exec.CommandContext(req.Context(), "git", "upload-pack", "--strict", "--stateless-rpc", ".") |
| cmd.Dir = filepath.Join(repo.dir.dir, ".git") |
| cmd.Env = append(os.Environ(), "GIT_PROTOCOL="+req.Header.Get("Git-Protocol")) |
| cmd.Stdin = req.Body |
| var buf bytes.Buffer |
| cmd.Stdout = &buf |
| err := cmd.Run() |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| w.Header().Set("Content-Type", "application/x-git-upload-pack-result") |
| io.Copy(w, &buf) |
| } |
| |
| func (*FakeGerrit) QueryChanges(_ context.Context, query string) ([]*gerrit.ChangeInfo, error) { |
| return nil, nil |
| } |
| |
| func (*FakeGerrit) SetHashtags(_ context.Context, changeID string, _ gerrit.HashtagsInput) error { |
| return fmt.Errorf("pretend that SetHashtags failed") |
| } |
| |
| func (*FakeGerrit) GetChange(_ context.Context, _ string, _ ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) { |
| return nil, nil |
| } |
| |
| // NewFakeSignService returns a fake signing service that can sign PKGs, MSIs, |
| // and generate GPG signatures. MSIs are "signed" by adding a suffix to them. |
| // PKGs must actually be tarballs with a prefix of "I'm a PKG!\n". Any files |
| // they contain that look like binaries will be "signed". |
| func NewFakeSignService(t *testing.T, outputDir string) *FakeSignService { |
| return &FakeSignService{ |
| t: t, |
| outputDir: outputDir, |
| completedJobs: map[string][]string{}, |
| } |
| } |
| |
| type FakeSignService struct { |
| t *testing.T |
| outputDir string |
| mu sync.Mutex |
| completedJobs map[string][]string // Job ID → output objectURIs. |
| } |
| |
| func (s *FakeSignService) SignArtifact(_ context.Context, bt sign.BuildType, in []string) (jobID string, _ error) { |
| s.t.Logf("fakeSignService: doing %s signing of %q", bt, in) |
| jobID = uuid.NewString() |
| var out []string |
| switch bt { |
| case sign.BuildMacOSConstructInstallerOnly: |
| if len(in) != 2 { |
| return "", fmt.Errorf("got %d inputs, want 2", len(in)) |
| } |
| out = []string{s.fakeConstructPKG(jobID, in[0], in[1], fmt.Sprintf("-installer <%s>", bt))} |
| case sign.BuildWindowsConstructInstallerOnly: |
| if len(in) != 2 { |
| return "", fmt.Errorf("got %d inputs, want 2", len(in)) |
| } |
| out = []string{s.fakeConstructMSI(jobID, in[0], in[1], fmt.Sprintf("-installer <%s>", bt))} |
| |
| case sign.BuildMacOS: |
| if len(in) != 1 { |
| return "", fmt.Errorf("got %d inputs, want 1", len(in)) |
| } |
| out = []string{s.fakeSignPKG(jobID, in[0], fmt.Sprintf("-signed <%s>", bt))} |
| case sign.BuildWindows: |
| if len(in) != 1 { |
| return "", fmt.Errorf("got %d inputs, want 1", len(in)) |
| } |
| out = []string{s.fakeSignFile(jobID, in[0], fmt.Sprintf("-signed <%s>", bt))} |
| case sign.BuildGPG: |
| if len(in) == 0 { |
| return "", fmt.Errorf("got 0 inputs, want 1 or more") |
| } |
| for _, f := range in { |
| out = append(out, s.fakeGPGFile(jobID, f)) |
| } |
| default: |
| return "", fmt.Errorf("SignArtifact: not implemented for %v", bt) |
| } |
| s.mu.Lock() |
| s.completedJobs[jobID] = out |
| s.mu.Unlock() |
| return jobID, nil |
| } |
| |
| func (s *FakeSignService) ArtifactSigningStatus(_ context.Context, jobID string) (_ sign.Status, desc string, out []string, _ error) { |
| s.mu.Lock() |
| out, ok := s.completedJobs[jobID] |
| s.mu.Unlock() |
| if !ok { |
| return sign.StatusNotFound, fmt.Sprintf("job %q not found", jobID), nil, nil |
| } |
| return sign.StatusCompleted, "", out, nil |
| } |
| |
| func (s *FakeSignService) CancelSigning(_ context.Context, jobID string) error { |
| s.t.Errorf("CancelSigning was called unexpectedly") |
| return fmt.Errorf("intentional fake error") |
| } |
| |
| func (s *FakeSignService) fakeConstructPKG(jobID, f, meta, msg string) string { |
| // Check installer metadata. |
| b, err := os.ReadFile(strings.TrimPrefix(meta, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeConstructPKG: os.ReadFile: %v", err)) |
| } |
| var opt darwinpkg.InstallerOptions |
| if err := json.Unmarshal(b, &opt); err != nil { |
| panic(fmt.Errorf("fakeConstructPKG: json.Unmarshal: %v", err)) |
| } |
| var errs []error |
| switch opt.GOARCH { |
| case "amd64", "arm64": // OK. |
| default: |
| errs = append(errs, fmt.Errorf("unexpected GOARCH value: %q", opt.GOARCH)) |
| } |
| switch min, _ := strconv.Atoi(opt.MinMacOSVersion); { |
| case min >= 11: // macOS 11 or greater; OK. |
| case opt.MinMacOSVersion == "10.15": // OK. |
| case opt.MinMacOSVersion == "10.13": // OK. Go 1.20 has macOS 10.13 as its minimum. |
| default: |
| errs = append(errs, fmt.Errorf("unexpected MinMacOSVersion value: %q", opt.MinMacOSVersion)) |
| } |
| if err := errors.Join(errs...); err != nil { |
| panic(fmt.Errorf("fakeConstructPKG: unexpected installer options %#v: %v", opt, err)) |
| } |
| |
| // Construct fake installer. |
| b, err = os.ReadFile(strings.TrimPrefix(f, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeConstructPKG: os.ReadFile: %v", err)) |
| } |
| return s.writeOutput(jobID, path.Base(f)+".pkg", append([]byte("I'm a PKG!\n"), b...)) |
| } |
| |
| func (s *FakeSignService) fakeConstructMSI(jobID, f, meta, msg string) string { |
| // Check installer metadata. |
| b, err := os.ReadFile(strings.TrimPrefix(meta, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeConstructMSI: os.ReadFile: %v", err)) |
| } |
| var opt windowsmsi.InstallerOptions |
| if err := json.Unmarshal(b, &opt); err != nil { |
| panic(fmt.Errorf("fakeConstructMSI: json.Unmarshal: %v", err)) |
| } |
| var errs []error |
| switch opt.GOARCH { |
| case "386", "amd64", "arm", "arm64": // OK. |
| default: |
| errs = append(errs, fmt.Errorf("unexpected GOARCH value: %q", opt.GOARCH)) |
| } |
| if err := errors.Join(errs...); err != nil { |
| panic(fmt.Errorf("fakeConstructMSI: unexpected installer options %#v: %v", opt, err)) |
| } |
| |
| // Construct fake installer. |
| _, err = os.ReadFile(strings.TrimPrefix(f, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeConstructMSI: os.ReadFile: %v", err)) |
| } |
| return s.writeOutput(jobID, path.Base(f)+".msi", []byte("I'm an MSI!\n")) |
| } |
| |
| func (s *FakeSignService) fakeSignPKG(jobID, f, msg string) string { |
| b, err := os.ReadFile(strings.TrimPrefix(f, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeSignPKG: os.ReadFile: %v", err)) |
| } |
| b, ok := bytes.CutPrefix(b, []byte("I'm a PKG!\n")) |
| if !ok { |
| panic(fmt.Errorf("fakeSignPKG: input doesn't look like a PKG to be signed")) |
| } |
| files, err := tgzToMap(bytes.NewReader(b)) |
| if err != nil { |
| panic(fmt.Errorf("fakeSignPKG: tgzToMap: %v", err)) |
| } |
| for fn, contents := range files { |
| if !strings.Contains(fn, "go/bin") && !strings.Contains(fn, "go/pkg/tool") { |
| continue |
| } |
| files[fn] = contents + msg |
| } |
| b, err = mapToTgz(files) |
| if err != nil { |
| panic(fmt.Errorf("fakeSignPKG: mapToTgz: %v", err)) |
| } |
| b = append([]byte("I'm a PKG! "+msg+"\n"), b...) |
| return s.writeOutput(jobID, path.Base(f), b) |
| } |
| |
| func (s *FakeSignService) writeOutput(jobID, base string, contents []byte) string { |
| path := path.Join(s.outputDir, jobID, base) |
| if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| panic(fmt.Errorf("fake signing service: os.MkdirAll: %v", err)) |
| } |
| if err := os.WriteFile(path, contents, 0600); err != nil { |
| panic(fmt.Errorf("fake signing service: os.WriteFile: %v", err)) |
| } |
| return "file://" + path |
| } |
| |
| func tgzToMap(r io.Reader) (map[string]string, error) { |
| gzr, err := gzip.NewReader(r) |
| if err != nil { |
| return nil, err |
| } |
| defer gzr.Close() |
| |
| result := map[string]string{} |
| tr := tar.NewReader(gzr) |
| for { |
| h, err := tr.Next() |
| if err == io.EOF { |
| break |
| } |
| if err != nil { |
| return nil, err |
| } |
| if h.Typeflag != tar.TypeReg { |
| continue |
| } |
| b, err := io.ReadAll(tr) |
| if err != nil { |
| return nil, err |
| } |
| result[h.Name] = string(b) |
| } |
| return result, nil |
| } |
| |
| func (s *FakeSignService) fakeSignFile(jobID, f, msg string) string { |
| b, err := os.ReadFile(strings.TrimPrefix(f, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeSignFile: os.ReadFile: %v", err)) |
| } |
| b = append(b, []byte(msg)...) |
| return s.writeOutput(jobID, path.Base(f), b) |
| } |
| |
| func (s *FakeSignService) fakeGPGFile(jobID, f string) string { |
| b, err := os.ReadFile(strings.TrimPrefix(f, "file://")) |
| if err != nil { |
| panic(fmt.Errorf("fakeGPGFile: os.ReadFile: %v", err)) |
| } |
| gpg := fmt.Sprintf("I'm a GPG signature for %x!", sha256.Sum256(b)) |
| return s.writeOutput(jobID, path.Base(f)+".asc", []byte(gpg)) |
| } |
| |
| var _ CloudBuildClient = (*FakeCloudBuild)(nil) |
| |
| const fakeGsutil = ` |
| #!/bin/bash -eux |
| |
| case "$1" in |
| "cp") |
| in=$2 |
| out=$3 |
| if [[ $in == '-' ]]; then |
| in=/dev/stdin |
| fi |
| if [[ $out == '-' ]]; then |
| out=/dev/stdout |
| fi |
| cp "${in#file://}" "${out#file://}" |
| ;; |
| "cat") |
| cat "${2#file://}" |
| ;; |
| *) |
| echo unexpected command $@ >&2 |
| exit 1 |
| ;; |
| esac |
| ` |
| |
| const fakeEmptyBinary = ` |
| #!/bin/bash -eux |
| echo "this binary will always exit without any error" |
| exit 0 |
| ` |
| |
| type FakeBinary struct { |
| Name string |
| // Implementation defines the script content. This script is written to the |
| // tool directory and executed when the corresponding command is invoked. |
| Implementation string |
| } |
| |
| func NewFakeCloudBuild(t *testing.T, gerrit *FakeGerrit, project string, allowedTriggers map[string]map[string]string, fakeBinaries ...FakeBinary) *FakeCloudBuild { |
| toolDir := t.TempDir() |
| if err := os.WriteFile(filepath.Join(toolDir, "gsutil"), []byte(fakeGsutil), 0777); err != nil { |
| t.Fatal(err) |
| } |
| |
| for _, binary := range fakeBinaries { |
| if err := os.WriteFile(filepath.Join(toolDir, binary.Name), []byte(binary.Implementation), 0777); err != nil { |
| t.Fatal(err) |
| } |
| } |
| return &FakeCloudBuild{ |
| t: t, |
| gerrit: gerrit, |
| project: project, |
| allowedTriggers: allowedTriggers, |
| toolDir: toolDir, |
| results: map[string]error{}, |
| } |
| } |
| |
| type FakeCloudBuild struct { |
| t *testing.T |
| gerrit *FakeGerrit |
| project string |
| allowedTriggers map[string]map[string]string |
| toolDir string |
| |
| mu sync.Mutex |
| results map[string]error |
| } |
| |
| func (cb *FakeCloudBuild) RunBuildTrigger(ctx context.Context, project string, trigger string, substitutions map[string]string) (CloudBuild, error) { |
| if project != cb.project { |
| return CloudBuild{}, fmt.Errorf("unexpected project %v, want %v", project, cb.project) |
| } |
| if allowedSubs, ok := cb.allowedTriggers[trigger]; !ok || !reflect.DeepEqual(allowedSubs, substitutions) { |
| return CloudBuild{}, fmt.Errorf("unexpected trigger %v: got params %#v, want %#v", trigger, substitutions, allowedSubs) |
| } |
| id := fmt.Sprintf("build-%v", rand.Int63()) |
| cb.mu.Lock() |
| cb.results[id] = nil |
| cb.mu.Unlock() |
| return CloudBuild{Project: project, ID: id}, nil |
| } |
| |
| func (cb *FakeCloudBuild) Completed(ctx context.Context, build CloudBuild) (string, bool, error) { |
| if build.Project != cb.project { |
| return "", false, fmt.Errorf("unexpected build project: got %q, want %q", build.Project, cb.project) |
| } |
| cb.mu.Lock() |
| result, ok := cb.results[build.ID] |
| cb.mu.Unlock() |
| if !ok { |
| return "", false, fmt.Errorf("unknown build ID %q", build.ID) |
| } |
| return "here's some build detail", true, result |
| } |
| |
| func (c *FakeCloudBuild) ResultFS(ctx context.Context, build CloudBuild) (fs.FS, error) { |
| return gcsfs.FromURL(ctx, nil, build.ResultURL) |
| } |
| |
| func (cb *FakeCloudBuild) RunScript(ctx context.Context, script string, gerritProject string, outputs []string) (CloudBuild, error) { |
| var wd string |
| if gerritProject != "" { |
| repo, err := cb.gerrit.repo(gerritProject) |
| if err != nil { |
| return CloudBuild{}, err |
| } |
| dir, err := (&Git{}).Clone(ctx, repo.dir.dir) |
| if err != nil { |
| return CloudBuild{}, err |
| } |
| defer dir.Close() |
| wd = dir.dir |
| } else { |
| wd = cb.t.TempDir() |
| } |
| |
| tempDir := cb.t.TempDir() |
| cmd := exec.Command("bash", "-eux") |
| cmd.Stdin = strings.NewReader(script) |
| cmd.Dir = wd |
| cmd.Env = os.Environ() |
| cmd.Env = append(cmd.Env, "TEMP="+tempDir, "TMP="+tempDir, "TEMPDIR="+tempDir, "TMPDIR="+tempDir) |
| cmd.Env = append(cmd.Env, "PATH="+cb.toolDir+":/bin:/usr/bin") |
| |
| buf := &bytes.Buffer{} |
| cmd.Stdout = buf |
| cmd.Stderr = buf |
| |
| runErr := cmd.Run() |
| if runErr != nil { |
| runErr = fmt.Errorf("script failed: %v output:\n%s", runErr, buf.String()) |
| } |
| id := fmt.Sprintf("build-%v", rand.Int63()) |
| resultDir := cb.t.TempDir() |
| if runErr == nil { |
| for _, out := range outputs { |
| target := filepath.Join(resultDir, out) |
| os.MkdirAll(filepath.Dir(target), 0777) |
| if err := os.Rename(filepath.Join(wd, out), target); err != nil { |
| runErr = fmt.Errorf("collecting outputs: %v", err) |
| break |
| } |
| } |
| } |
| cb.mu.Lock() |
| cb.results[id] = runErr |
| cb.mu.Unlock() |
| return CloudBuild{Project: cb.project, ID: id, ResultURL: "file://" + resultDir}, nil |
| } |
| |
| func (cb *FakeCloudBuild) RunCustomSteps(ctx context.Context, steps func(resultURL string) []*cloudbuildpb.BuildStep, _ *CloudBuildOptions) (CloudBuild, error) { |
| var gerritProject, fullScript string |
| resultURL := "file://" + cb.t.TempDir() |
| for i, step := range steps(resultURL) { |
| // Cloud Build support docker hub images like "bash". See more details: |
| // https://cloud.google.com/build/docs/interacting-with-dockerhub-images |
| // Currently, the Bash script is solely for downloading the Go binary. |
| // The RunScripts mock implementation provides the Go binary, allowing us |
| // to bypass the Bash script for now. |
| if step.Name == "bash" && step.Script == cloudBuildClientDownloadGoScript { |
| continue |
| } |
| 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 |
| } |
| |
| // As documented by the cloudbuildpb.BuildStep, when the script field is |
| // provided, the user cannot specify the entrypoint or args. |
| if step.Script != "" && len(step.Args) > 0 { |
| return CloudBuild{}, fmt.Errorf("step[%v] can not have script and arguments", i) |
| } |
| if step.Script != "" && step.Entrypoint != "" { |
| return CloudBuild{}, fmt.Errorf("step[%v] can not have script and entrypoint", i) |
| } |
| |
| // RunCustomSteps allows execution of commands or scripts in any directory, |
| // while RunScript always executes in the repo's root directory. |
| // To use RunScript within RunCustomSteps, we must first navigate to the |
| // target directory if it differs from the repo root. |
| if relative := strings.TrimPrefix(step.Dir, gerritProject+"/"); step.Dir != gerritProject && relative != "" { |
| fullScript += "pushd " + relative + "\n" |
| } |
| |
| if len(step.Args) > 0 { |
| fullScript += tool + " " + strings.Join(step.Args, " ") + "\n" |
| } |
| if step.Script != "" { |
| fullScript += step.Script + "\n" |
| } |
| |
| // Return to the previous dir after finish the commands or scripts execution. |
| if relative := strings.TrimPrefix(step.Dir, gerritProject+"/"); step.Dir != gerritProject && relative != "" { |
| fullScript += "popd\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 |
| |
| mu sync.Mutex |
| results map[string]error |
| } |
| |
| func NewFakeSwarmingClient(t *testing.T, fakeGo string) *FakeSwarmingClient { |
| toolDir := t.TempDir() |
| if err := os.WriteFile(filepath.Join(toolDir, "go"), []byte(fakeGo), 0777); err != nil { |
| t.Fatal(err) |
| } |
| if err := os.WriteFile(filepath.Join(toolDir, "gsutil"), []byte(fakeGsutil), 0777); err != nil { |
| t.Fatal(err) |
| } |
| return &FakeSwarmingClient{ |
| t: t, |
| toolDir: toolDir, |
| results: map[string]error{}, |
| } |
| } |
| |
| var _ SwarmingClient = (*FakeSwarmingClient)(nil) |
| |
| func (c *FakeSwarmingClient) RunTask(ctx context.Context, dims map[string]string, script string, env map[string]string) (string, error) { |
| tempDir := c.t.TempDir() |
| cmd := exec.Command("bash", "-eux") |
| cmd.Stdin = strings.NewReader("set -o pipefail\n" + script) |
| cmd.Dir = c.t.TempDir() |
| cmd.Env = os.Environ() |
| cmd.Env = append(cmd.Env, "TEMP="+tempDir, "TMP="+tempDir, "TEMPDIR="+tempDir, "TMPDIR="+tempDir) |
| cmd.Env = append(cmd.Env, "PATH="+c.toolDir+":/bin:/usr/bin:.") // Note: . is on PATH to help with Windows compatibility |
| for k, v := range env { |
| cmd.Env = append(cmd.Env, k+"="+v) |
| } |
| buf := &bytes.Buffer{} |
| cmd.Stdout = buf |
| cmd.Stderr = buf |
| |
| runErr := cmd.Run() |
| if runErr != nil { |
| runErr = fmt.Errorf("script failed: %v output:\n%s", runErr, buf.String()) |
| } |
| id := fmt.Sprintf("build-%v", rand.Int63()) |
| c.mu.Lock() |
| c.results[id] = runErr |
| c.mu.Unlock() |
| return id, nil |
| } |
| |
| func (c *FakeSwarmingClient) Completed(ctx context.Context, id string) (string, bool, error) { |
| c.mu.Lock() |
| result, ok := c.results[id] |
| c.mu.Unlock() |
| if !ok { |
| return "", false, fmt.Errorf("unknown task ID %q", id) |
| } |
| return "here's some build detail", true, result |
| } |
| |
| func NewFakeBuildBucketClient(major int, url, bucket string, projects []string) *FakeBuildBucketClient { |
| return &FakeBuildBucketClient{ |
| Bucket: bucket, |
| major: major, |
| GerritURL: url, |
| Projects: projects, |
| results: map[int64]error{}, |
| } |
| } |
| |
| type FakeBuildBucketClient struct { |
| Bucket string |
| FailBuilds []string |
| MissingBuilds []string |
| major int |
| GerritURL, Branch string |
| Projects []string |
| |
| mu sync.Mutex |
| results map[int64]error |
| } |
| |
| var _ BuildBucketClient = (*FakeBuildBucketClient)(nil) |
| |
| func (c *FakeBuildBucketClient) ListBuilders(ctx context.Context, bucket string) (map[string]*pb.BuilderConfig, error) { |
| if bucket != c.Bucket { |
| return nil, fmt.Errorf("unexpected bucket %q", bucket) |
| } |
| res := map[string]*pb.BuilderConfig{} |
| for _, proj := range c.Projects { |
| prefix := "" |
| if proj != "go" { |
| prefix = "x_" + proj + "-" |
| } |
| for _, v := range []string{"gotip", fmt.Sprintf("go1.%v", c.major)} { |
| for _, b := range []string{"linux-amd64", "linux-amd64-longtest", "darwin-amd64_13"} { |
| parts := strings.FieldsFunc(b, func(r rune) bool { return r == '-' || r == '_' }) |
| res[prefix+v+"-"+b] = &pb.BuilderConfig{ |
| Properties: fmt.Sprintf(`{"project":%q, "is_google":true, "target":{"goos":%q, "goarch":%q}}`, proj, parts[0], parts[1]), |
| } |
| } |
| } |
| } |
| return res, nil |
| } |
| |
| func (c *FakeBuildBucketClient) RunBuild(ctx context.Context, bucket string, builder string, commit *pb.GitilesCommit, properties map[string]*structpb.Value) (int64, error) { |
| if bucket != c.Bucket { |
| return 0, fmt.Errorf("unexpected bucket %q", bucket) |
| } |
| match := regexp.MustCompile(`.*://(.+)`).FindStringSubmatch(c.GerritURL) |
| if commit.Host != match[1] || !slices.Contains(c.Projects, commit.Project) { |
| return 0, fmt.Errorf("unexpected host or project: got %q, %q want %q, %q", commit.Host, commit.Project, match[1], c.Projects) |
| } |
| // It would be nice to validate the commit hash and branch, but it's |
| // tricky to get the right value because it depends on the release type. |
| // At least validate the commit is a commit. |
| if len(commit.Id) != 40 { |
| return 0, fmt.Errorf("malformed Git commit hash %q", commit.Id) |
| } |
| var runErr error |
| for _, failBuild := range c.FailBuilds { |
| if strings.Contains(builder, failBuild) { |
| runErr = fmt.Errorf("run of %q is specified to fail", builder) |
| } |
| } |
| |
| id := rand.Int63() |
| c.mu.Lock() |
| c.results[id] = runErr |
| c.mu.Unlock() |
| return id, nil |
| } |
| |
| func (c *FakeBuildBucketClient) Completed(ctx context.Context, id int64) (string, bool, error) { |
| c.mu.Lock() |
| result, ok := c.results[id] |
| c.mu.Unlock() |
| if !ok { |
| return "", false, fmt.Errorf("unknown task ID %q", id) |
| } |
| return "here's some build detail", true, result |
| } |
| |
| func (c *FakeBuildBucketClient) SearchBuilds(ctx context.Context, pred *pb.BuildPredicate) ([]int64, error) { |
| if slices.Contains(c.MissingBuilds, pred.GetBuilder().GetBuilder()) { |
| return nil, nil |
| } |
| return []int64{rand.Int63()}, nil |
| } |
| |
| type FakeGitHub struct { |
| // Milestones is a map from milestone ID to milestone name. |
| Milestones map[int]string |
| // Issues is a map from issue number to issue details. |
| // this map contains all the Issues attached to all milestones and Issues that |
| // does not attach to milestone. |
| Issues map[int]*github.Issue |
| |
| // The following fields modify behavior of the fake to test |
| // certain special scenarios. |
| |
| DisallowComments bool // if set, return an error from PostComment |
| lastIssueNumber int // last issue number created by nextIssueNumber, or 0 |
| lastMilestoneID int // last milestone ID created by nextMilestoneID, or 0 |
| } |
| |
| func (f *FakeGitHub) nextMilestoneID() int { |
| for { |
| f.lastMilestoneID++ |
| if _, ok := f.Milestones[f.lastMilestoneID]; !ok { |
| return f.lastMilestoneID |
| } |
| } |
| } |
| |
| func (f *FakeGitHub) nextIssueNumber() int { |
| for { |
| f.lastIssueNumber++ |
| if _, ok := f.Issues[f.lastIssueNumber]; !ok { |
| return f.lastIssueNumber |
| } |
| } |
| } |
| |
| func (f *FakeGitHub) FetchMilestone(_ context.Context, owner, repo, name string, create bool) (int, error) { |
| for id, n := range f.Milestones { |
| if n == name { |
| return id, nil |
| } |
| } |
| |
| if create { |
| newID := f.nextMilestoneID() |
| if f.Milestones == nil { |
| f.Milestones = map[int]string{} |
| } |
| f.Milestones[newID] = name |
| return newID, nil |
| } |
| return 0, fmt.Errorf("milestone %q not found and create parameter is false", name) |
| } |
| |
| func (f *FakeGitHub) FetchMilestoneIssues(_ context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) { |
| if _, ok := f.Milestones[milestoneID]; !ok { |
| return nil, fmt.Errorf("milestone %v not found", milestoneID) |
| } |
| issueLabels := map[int]map[string]bool{} |
| for number, issue := range f.Issues { |
| if issue.Milestone == nil { |
| continue |
| } |
| |
| if *issue.Milestone.ID != int64(milestoneID) { |
| continue |
| } |
| |
| issueLabels[number] = map[string]bool{} |
| for _, label := range issue.Labels { |
| issueLabels[number][*label.Name] = true |
| } |
| } |
| return issueLabels, nil |
| } |
| |
| func (*FakeGitHub) UploadReleaseAsset(ctx context.Context, owner, repo string, releaseID int64, fileName string, file fs.File) (*github.ReleaseAsset, error) { |
| return nil, nil |
| } |
| |
| func (*FakeGitHub) CreateRelease(ctx context.Context, owner, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, error) { |
| return nil, nil |
| } |
| |
| func (*FakeGitHub) EditIssue(_ context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { |
| return nil, nil, nil |
| } |
| |
| func (f *FakeGitHub) CreateIssue(ctx context.Context, owner string, repo string, request *github.IssueRequest) (*github.Issue, *github.Response, error) { |
| if f.Issues == nil { |
| f.Issues = map[int]*github.Issue{} |
| } |
| |
| issueNumber := f.nextIssueNumber() |
| f.Issues[issueNumber] = &github.Issue{Number: &issueNumber, Title: request.Title, Body: request.Body} |
| if request.Labels != nil { |
| for _, l := range *request.Labels { |
| f.Issues[issueNumber].Labels = append(f.Issues[issueNumber].Labels, &github.Label{Name: &l}) |
| } |
| } |
| if request.Milestone != nil { |
| if _, ok := f.Milestones[*request.Milestone]; !ok { |
| return nil, nil, fmt.Errorf("the milestone does not exist: %v", *request.Milestone) |
| } |
| f.Issues[issueNumber].Milestone = &github.Milestone{ID: github.Int64(int64(*request.Milestone))} |
| } |
| return f.GetIssue(ctx, owner, repo, issueNumber) |
| } |
| |
| func (f *FakeGitHub) GetIssue(_ context.Context, owner string, repo string, number int) (*github.Issue, *github.Response, error) { |
| if issue, ok := f.Issues[number]; !ok { |
| return nil, nil, fmt.Errorf("the issue %v does not exist", number) |
| } else { |
| return issue, nil, nil |
| } |
| } |
| |
| func (*FakeGitHub) EditMilestone(_ context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) { |
| return nil, nil, nil |
| } |
| |
| func (f *FakeGitHub) PostComment(_ context.Context, _ githubv4.ID, _ string) error { |
| if f.DisallowComments { |
| return fmt.Errorf("pretend that PostComment failed") |
| } |
| return nil |
| } |