golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gomote/push.go (about)

     1  // Copyright 2015 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 main
     6  
     7  import (
     8  	"archive/tar"
     9  	"bufio"
    10  	"bytes"
    11  	"compress/gzip"
    12  	"context"
    13  	"crypto/sha1"
    14  	"errors"
    15  	"flag"
    16  	"fmt"
    17  	"io"
    18  	"log"
    19  	"os"
    20  	"os/exec"
    21  	"path/filepath"
    22  	"sort"
    23  	"strings"
    24  
    25  	"golang.org/x/build/buildlet"
    26  	"golang.org/x/build/internal/gomote/protos"
    27  	"golang.org/x/sync/errgroup"
    28  )
    29  
    30  func push(args []string) error {
    31  	fs := flag.NewFlagSet("push", flag.ContinueOnError)
    32  	var dryRun bool
    33  	fs.BoolVar(&dryRun, "dry-run", false, "print what would be done only")
    34  	fs.Usage = func() {
    35  		fmt.Fprintln(os.Stderr, "push usage: gomote push <instance>")
    36  		fs.PrintDefaults()
    37  		os.Exit(1)
    38  	}
    39  	fs.Parse(args)
    40  
    41  	goroot, err := getGOROOT()
    42  	if err != nil {
    43  		return err
    44  	}
    45  
    46  	var pushSet []string
    47  	if fs.NArg() == 1 {
    48  		pushSet = append(pushSet, fs.Arg(0))
    49  	} else if activeGroup != nil {
    50  		for _, inst := range activeGroup.Instances {
    51  			pushSet = append(pushSet, inst)
    52  		}
    53  	} else {
    54  		fs.Usage()
    55  	}
    56  
    57  	detailedProgress := len(pushSet) == 1
    58  	eg, ctx := errgroup.WithContext(context.Background())
    59  	for _, inst := range pushSet {
    60  		inst := inst
    61  		eg.Go(func() error {
    62  			fmt.Fprintf(os.Stderr, "# Pushing GOROOT %q to %q...\n", goroot, inst)
    63  			return doPush(ctx, inst, goroot, dryRun, detailedProgress)
    64  		})
    65  	}
    66  	return eg.Wait()
    67  }
    68  
    69  func doPush(ctx context.Context, name, goroot string, dryRun, detailedProgress bool) error {
    70  	logf := func(s string, a ...interface{}) {
    71  		if detailedProgress {
    72  			log.Printf(s, a...)
    73  		}
    74  	}
    75  	remote := map[string]buildlet.DirEntry{} // keys like "src/make.bash"
    76  
    77  	client := gomoteServerClient(ctx)
    78  	resp, err := client.ListDirectory(ctx, &protos.ListDirectoryRequest{
    79  		GomoteId:  name,
    80  		Directory: ".",
    81  		Recursive: true,
    82  		SkipFiles: []string{
    83  			// Ignore binary output directories:
    84  			"go/pkg", "go/bin",
    85  			// We don't care about the digest of
    86  			// particular source files for Go 1.4.  And
    87  			// exclude /pkg. This leaves go1.4/bin, which
    88  			// is enough to know whether we have Go 1.4 or
    89  			// not.
    90  			"go1.4/src", "go1.4/pkg",
    91  			// Ignore the cache and tmp directories, these slowly grow, and will
    92  			// eventually cause the listing to exceed the maximum gRPC message
    93  			// size.
    94  			"gocache", "goplscache", "tmp",
    95  		},
    96  		Digest: true,
    97  	})
    98  	if err != nil {
    99  		return fmt.Errorf("error listing buildlet's existing files: %w", err)
   100  	}
   101  	for _, entry := range resp.GetEntries() {
   102  		de := buildlet.DirEntry{Line: entry}
   103  		en := de.Name()
   104  		if strings.HasPrefix(en, "go/") && en != "go/" {
   105  			remote[en[len("go/"):]] = de
   106  		}
   107  	}
   108  	// TODO(66635) remove once gomotes can no longer be created via the coordinator.
   109  	if luciDisabled() {
   110  		logf("installing go-bootstrap version in the working directory")
   111  		if dryRun {
   112  			logf("(Dry-run) Would have pushed go-bootstrap")
   113  		} else {
   114  			_, err := client.AddBootstrap(ctx, &protos.AddBootstrapRequest{
   115  				GomoteId: name,
   116  			})
   117  			if err != nil {
   118  				return fmt.Errorf("unable to add bootstrap version of Go to instance: %w", err)
   119  			}
   120  		}
   121  	}
   122  
   123  	type fileInfo struct {
   124  		fi   os.FileInfo
   125  		sha1 string // if regular file
   126  	}
   127  	local := map[string]fileInfo{} // keys like "src/make.bash"
   128  
   129  	// Ensure that the goroot passed to filepath.Walk ends in a trailing slash,
   130  	// so that if GOROOT is a symlink we walk the underlying directory.
   131  	walkRoot := goroot
   132  	if walkRoot != "" && !os.IsPathSeparator(walkRoot[len(walkRoot)-1]) {
   133  		walkRoot += string(filepath.Separator)
   134  	}
   135  	absToRel := make(map[string]string)
   136  	if err := filepath.Walk(walkRoot, func(path string, fi os.FileInfo, err error) error {
   137  		if isEditorBackup(path) {
   138  			return nil
   139  		}
   140  		if err != nil {
   141  			return err
   142  		}
   143  		rel, err := filepath.Rel(goroot, path)
   144  		if err != nil {
   145  			return fmt.Errorf("error calculating relative path from %q to %q", goroot, path)
   146  		}
   147  		rel = filepath.ToSlash(rel)
   148  		if rel == "." {
   149  			return nil
   150  		}
   151  		if rel == ".git" {
   152  			if fi.IsDir() {
   153  				return filepath.SkipDir
   154  			}
   155  			return nil // .git is a file in `git worktree` checkouts.
   156  		}
   157  		if fi.IsDir() {
   158  			switch rel {
   159  			case "pkg", "bin":
   160  				return filepath.SkipDir
   161  			}
   162  		}
   163  		inf := fileInfo{fi: fi}
   164  		absToRel[path] = rel
   165  		if fi.Mode().IsRegular() {
   166  			inf.sha1, err = fileSHA1(path)
   167  			if err != nil {
   168  				return err
   169  			}
   170  		}
   171  		local[rel] = inf
   172  		return nil
   173  	}); err != nil {
   174  		return fmt.Errorf("error enumerating local GOROOT files: %w", err)
   175  	}
   176  
   177  	ignored := make(map[string]bool)
   178  	for _, path := range gitIgnored(goroot, absToRel) {
   179  		ignored[absToRel[path]] = true
   180  		delete(local, absToRel[path])
   181  	}
   182  
   183  	var toDel []string
   184  	for rel := range remote {
   185  		if rel == "VERSION" {
   186  			// Don't delete this. It's harmless, and
   187  			// necessary. Clients can overwrite it if they
   188  			// want. But if there's no VERSION file there,
   189  			// make.bash/bat assumes there's a git repo in
   190  			// place, but there's not only not a git repo
   191  			// there with gomote, but there's no git tool
   192  			// available either.
   193  			continue
   194  		}
   195  		// Also don't delete the auto-generated files from cmd/dist.
   196  		// Otherwise gomote users can't gomote push + gomote run make.bash
   197  		// and then iteratively:
   198  		// -- hack locally
   199  		// -- gomote push
   200  		// -- gomote run go test -v ...
   201  		// Because the go test would fail remotely without
   202  		// these files if they were deleted by gomote push.
   203  		if isGoToolDistGenerated(rel) {
   204  			continue
   205  		}
   206  		if ignored[rel] {
   207  			// Don't delete remote gitignored files; this breaks built toolchains.
   208  			continue
   209  		}
   210  		rel = strings.TrimRight(rel, "/")
   211  		if rel == "" {
   212  			continue
   213  		}
   214  		if _, ok := local[rel]; !ok {
   215  			toDel = append(toDel, rel)
   216  		}
   217  	}
   218  	if len(toDel) > 0 {
   219  		withGo := make([]string, len(toDel)) // with the "go/" prefix
   220  		for i, v := range toDel {
   221  			withGo[i] = "go/" + v
   222  		}
   223  		sort.Strings(withGo)
   224  		if dryRun {
   225  			logf("(Dry-run) Would have deleted remote files: %q", withGo)
   226  		} else {
   227  			logf("Deleting remote files: %q", withGo)
   228  			if _, err := client.RemoveFiles(ctx, &protos.RemoveFilesRequest{
   229  				GomoteId: name,
   230  				Paths:    withGo,
   231  			}); err != nil {
   232  				return fmt.Errorf("failed to delete remote unwanted files: %w", err)
   233  			}
   234  		}
   235  	}
   236  	var toSend []string
   237  	notHave := 0
   238  	const maxNotHavePrint = 5
   239  	for rel, inf := range local {
   240  		if isGoToolDistGenerated(rel) || rel == "VERSION.cache" {
   241  			continue
   242  		}
   243  		if !inf.fi.Mode().IsRegular() {
   244  			if !inf.fi.IsDir() {
   245  				logf("Ignoring local non-regular, non-directory file %s: %v", rel, inf.fi.Mode())
   246  			}
   247  			continue
   248  		}
   249  		rem, ok := remote[rel]
   250  		if !ok {
   251  			if notHave++; notHave <= maxNotHavePrint {
   252  				logf("Remote doesn't have %q", rel)
   253  			}
   254  			toSend = append(toSend, rel)
   255  			continue
   256  		}
   257  		if rem.Digest() != inf.sha1 {
   258  			logf("Remote's %s digest is %q; want %q", rel, rem.Digest(), inf.sha1)
   259  			toSend = append(toSend, rel)
   260  		}
   261  	}
   262  	if notHave > maxNotHavePrint {
   263  		logf("Remote doesn't have %d files (only showed %d).", notHave, maxNotHavePrint)
   264  	}
   265  	_, localHasVersion := local["VERSION"]
   266  	if _, remoteHasVersion := remote["VERSION"]; !remoteHasVersion && !localHasVersion {
   267  		logf("Remote lacks a VERSION file; sending a fake one")
   268  		toSend = append(toSend, "VERSION")
   269  	}
   270  	if len(toSend) > 0 {
   271  		sort.Strings(toSend)
   272  		tgz, err := generateDeltaTgz(goroot, toSend)
   273  		if err != nil {
   274  			return err
   275  		}
   276  		logf("Uploading %d new/changed files; %d byte .tar.gz", len(toSend), tgz.Len())
   277  		if dryRun {
   278  			logf("(Dry-run mode; not doing anything.")
   279  			return nil
   280  		}
   281  		resp, err := client.UploadFile(ctx, &protos.UploadFileRequest{})
   282  		if err != nil {
   283  			return fmt.Errorf("unable to request credentials for a file upload: %w", err)
   284  		}
   285  		if err := uploadToGCS(ctx, resp.GetFields(), tgz, resp.GetObjectName(), resp.GetUrl()); err != nil {
   286  			return fmt.Errorf("unable to upload file to GCS: %w", err)
   287  		}
   288  		if _, err := client.WriteTGZFromURL(ctx, &protos.WriteTGZFromURLRequest{
   289  			GomoteId:  name,
   290  			Url:       fmt.Sprintf("%s%s", resp.GetUrl(), resp.GetObjectName()),
   291  			Directory: "go",
   292  		}); err != nil {
   293  			return fmt.Errorf("failed writing tarball to buildlet: %w", err)
   294  		}
   295  	}
   296  	return nil
   297  }
   298  
   299  func isGoToolDistGenerated(path string) bool {
   300  	switch path {
   301  	case "src/cmd/cgo/zdefaultcc.go",
   302  		"src/cmd/go/internal/cfg/zdefaultcc.go",
   303  		"src/cmd/go/internal/cfg/zosarch.go",
   304  		"src/cmd/internal/objabi/zbootstrap.go",
   305  		"src/go/build/zcgo.go",
   306  		"src/internal/buildcfg/zbootstrap.go",
   307  		"src/runtime/internal/sys/zversion.go",
   308  		"src/time/tzdata/zzipdata.go":
   309  		return true
   310  	}
   311  	return false
   312  }
   313  
   314  func isEditorBackup(path string) bool {
   315  	base := filepath.Base(path)
   316  	if strings.HasPrefix(base, ".") && strings.HasSuffix(base, ".swp") {
   317  		// vi
   318  		return true
   319  	}
   320  	if strings.HasSuffix(path, "~") || strings.HasSuffix(path, "#") ||
   321  		strings.HasPrefix(base, "#") || strings.HasPrefix(base, ".#") {
   322  		// emacs
   323  		return true
   324  	}
   325  	return false
   326  }
   327  
   328  // file is forward-slash separated
   329  func generateDeltaTgz(goroot string, files []string) (*bytes.Buffer, error) {
   330  	var buf bytes.Buffer
   331  	zw := gzip.NewWriter(&buf)
   332  	tw := tar.NewWriter(zw)
   333  	for _, file := range files {
   334  		// Special.
   335  		if file == "VERSION" && !localFileExists(filepath.Join(goroot, file)) {
   336  			// TODO(bradfitz): a dummy VERSION file's contents to make things
   337  			// happy. Notably it starts with "devel ". Do we care about it
   338  			// being accurate beyond that?
   339  			version := "devel gomote.XXXXX"
   340  			if err := tw.WriteHeader(&tar.Header{
   341  				Name: "VERSION",
   342  				Mode: 0644,
   343  				Size: int64(len(version)),
   344  			}); err != nil {
   345  				return nil, err
   346  			}
   347  			if _, err := io.WriteString(tw, version); err != nil {
   348  				return nil, err
   349  			}
   350  			continue
   351  		}
   352  		f, err := os.Open(filepath.Join(goroot, file))
   353  		if err != nil {
   354  			return nil, err
   355  		}
   356  		fi, err := f.Stat()
   357  		if err != nil {
   358  			f.Close()
   359  			return nil, err
   360  		}
   361  		header, err := tar.FileInfoHeader(fi, "")
   362  		if err != nil {
   363  			f.Close()
   364  			return nil, err
   365  		}
   366  		header.Name = file // forward slash
   367  		if err := tw.WriteHeader(header); err != nil {
   368  			f.Close()
   369  			return nil, err
   370  		}
   371  		if _, err := io.CopyN(tw, f, header.Size); err != nil {
   372  			f.Close()
   373  			return nil, fmt.Errorf("error copying contents of %s: %w", file, err)
   374  		}
   375  		f.Close()
   376  	}
   377  	if err := tw.Close(); err != nil {
   378  		return nil, err
   379  	}
   380  	if err := zw.Close(); err != nil {
   381  		return nil, err
   382  	}
   383  
   384  	return &buf, nil
   385  }
   386  
   387  func fileSHA1(path string) (string, error) {
   388  	f, err := os.Open(path)
   389  	if err != nil {
   390  		return "", err
   391  	}
   392  	defer f.Close()
   393  	s1 := sha1.New()
   394  	if _, err := io.Copy(s1, f); err != nil {
   395  		return "", err
   396  	}
   397  	return fmt.Sprintf("%x", s1.Sum(nil)), nil
   398  }
   399  
   400  func getGOROOT() (string, error) {
   401  	goroot := os.Getenv("GOROOT")
   402  	if goroot == "" {
   403  		slurp, err := exec.Command("go", "env", "GOROOT").Output()
   404  		if err != nil {
   405  			return "", fmt.Errorf("failed to get GOROOT from go env: %w", err)
   406  		}
   407  		goroot = strings.TrimSpace(string(slurp))
   408  		if goroot == "" {
   409  			return "", errors.New("Failed to get $GOROOT from environment or go env")
   410  		}
   411  	}
   412  	goroot = filepath.Clean(goroot)
   413  	return goroot, nil
   414  }
   415  
   416  func localFileExists(path string) bool {
   417  	_, err := os.Stat(path)
   418  	return !os.IsNotExist(err)
   419  }
   420  
   421  // gitIgnored checks whether any of the paths listed as keys in absToRel
   422  // are git ignored in goroot. It returns the list of ignored paths.
   423  func gitIgnored(goroot string, absToRel map[string]string) []string {
   424  	var stdin, stdout, stderr bytes.Buffer
   425  	for abs := range absToRel {
   426  		stdin.WriteString(abs)
   427  		stdin.WriteString("\x00")
   428  	}
   429  
   430  	// Invoke 'git check-ignore' and use it to query whether paths have been gitignored.
   431  	// If anything goes wrong at any point, fall back to assuming that nothing is gitignored.
   432  	cmd := exec.Command("git", "-C", goroot, "check-ignore", "--stdin", "-z")
   433  	cmd.Stdin = &stdin
   434  	cmd.Stdout = &stdout
   435  	cmd.Stderr = &stderr
   436  	if err := cmd.Run(); err != nil {
   437  		if e, ok := err.(*exec.ExitError); ok && e.ExitCode() == 1 {
   438  			// exit 1 means no files are ignored
   439  			err = nil
   440  		}
   441  		if err != nil {
   442  			log.Printf("exec git check-ignore: %v\n%s", err, stderr.Bytes())
   443  		}
   444  	}
   445  
   446  	var ignored []string
   447  	br := bufio.NewReader(&stdout)
   448  	for {
   449  		// Response is of the form "<source> <NUL>"
   450  		f, err := br.ReadBytes('\x00')
   451  		if err != nil {
   452  			if err != io.EOF {
   453  				log.Printf("git check-ignore: unexpected error reading output: %s", err)
   454  			}
   455  			break
   456  		}
   457  		ignored = append(ignored, string(f[:len(f)-len("\x00")]))
   458  	}
   459  	return ignored
   460  }