| // Copyright 2015 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 main |
| |
| import ( |
| "archive/tar" |
| "bufio" |
| "bytes" |
| "compress/gzip" |
| "context" |
| "crypto/sha1" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "sort" |
| "strings" |
| |
| "golang.org/x/build/buildlet" |
| "golang.org/x/build/internal/gomote/protos" |
| "golang.org/x/sync/errgroup" |
| ) |
| |
| func push(args []string) error { |
| fs := flag.NewFlagSet("push", flag.ContinueOnError) |
| var dryRun bool |
| fs.BoolVar(&dryRun, "dry-run", false, "print what would be done only") |
| fs.Usage = func() { |
| fmt.Fprintln(os.Stderr, "push usage: gomote push <instance>") |
| fs.PrintDefaults() |
| os.Exit(1) |
| } |
| fs.Parse(args) |
| |
| goroot, err := getGOROOT() |
| if err != nil { |
| return err |
| } |
| |
| var pushSet []string |
| if fs.NArg() == 1 { |
| pushSet = append(pushSet, fs.Arg(0)) |
| } else if activeGroup != nil { |
| for _, inst := range activeGroup.Instances { |
| pushSet = append(pushSet, inst) |
| } |
| } else { |
| fs.Usage() |
| } |
| |
| detailedProgress := len(pushSet) == 1 |
| eg, ctx := errgroup.WithContext(context.Background()) |
| for _, inst := range pushSet { |
| inst := inst |
| eg.Go(func() error { |
| fmt.Fprintf(os.Stderr, "# Pushing GOROOT %q to %q...\n", goroot, inst) |
| return doPush(ctx, inst, goroot, dryRun, detailedProgress) |
| }) |
| } |
| return eg.Wait() |
| } |
| |
| func doPush(ctx context.Context, name, goroot string, dryRun, detailedProgress bool) error { |
| logf := func(s string, a ...interface{}) { |
| if detailedProgress { |
| log.Printf(s, a...) |
| } |
| } |
| haveGo14 := false |
| remote := map[string]buildlet.DirEntry{} // keys like "src/make.bash" |
| |
| client := gomoteServerClient(ctx) |
| resp, err := client.ListDirectory(ctx, &protos.ListDirectoryRequest{ |
| GomoteId: name, |
| Directory: ".", |
| Recursive: true, |
| SkipFiles: []string{ |
| // Ignore binary output directories: |
| "go/pkg", "go/bin", |
| // We don't care about the digest of |
| // particular source files for Go 1.4. And |
| // exclude /pkg. This leaves go1.4/bin, which |
| // is enough to know whether we have Go 1.4 or |
| // not. |
| "go1.4/src", "go1.4/pkg", |
| // Ignore the cache and tmp directories, these slowly grow, and will |
| // eventually cause the listing to exceed the maximum gRPC message |
| // size. |
| "gocache", "goplscache", "tmp", |
| }, |
| Digest: true, |
| }) |
| if err != nil { |
| return fmt.Errorf("error listing buildlet's existing files: %w", err) |
| } |
| for _, entry := range resp.GetEntries() { |
| de := buildlet.DirEntry{Line: entry} |
| en := de.Name() |
| if strings.HasPrefix(en, "go1.4/") { |
| haveGo14 = true |
| continue |
| } |
| if strings.HasPrefix(en, "go/") && en != "go/" { |
| remote[en[len("go/"):]] = de |
| } |
| } |
| if !haveGo14 { |
| logf("installing go1.4") |
| if dryRun { |
| logf("(Dry-run) Would have pushed go1.4") |
| } else { |
| _, err := client.AddBootstrap(ctx, &protos.AddBootstrapRequest{ |
| GomoteId: name, |
| }) |
| if err != nil { |
| return fmt.Errorf("unable to add bootstrap version of Go to instance: %w", err) |
| } |
| } |
| } |
| |
| // Invoke 'git check-ignore' and use it to query whether paths have been gitignored. |
| // If anything goes wrong at any point, fall back to assuming that nothing is gitignored. |
| var isGitIgnored func(string) bool |
| gci := exec.Command("git", "-C", goroot, "check-ignore", "--stdin", "-n", "-v", "-z") |
| gci.Env = append(os.Environ(), "GIT_FLUSH=1") |
| gciIn, errIn := gci.StdinPipe() |
| defer gciIn.Close() // allow git process to exit |
| gciOut, errOut := gci.StdoutPipe() |
| gciErr := &bytes.Buffer{} |
| gci.Stderr = gciErr |
| errStart := gci.Start() |
| if errIn != nil || errOut != nil || errStart != nil { |
| isGitIgnored = func(string) bool { return false } |
| } else { |
| var failed bool |
| br := bufio.NewReader(gciOut) |
| isGitIgnored = func(path string) bool { |
| if failed { |
| return false |
| } |
| fmt.Fprintf(gciIn, "%s\x00", path) |
| // Response is of form "<source> <NULL> <linenum> <NULL> <pattern> <NULL> <pathname> <NULL>" |
| // Read all four and check that the path is correct. |
| // If so, the path is ignored iff the source (reason why ignored) is non-empty. |
| var resp [4][]byte |
| for i := range resp { |
| b, err := br.ReadBytes(0) |
| if err != nil { |
| gci.Wait() |
| logf("git check-ignore %q exited unexpectedly:\n%v", path, gciErr.String()) |
| failed = true |
| return false |
| } |
| resp[i] = b[:len(b)-1] // drop trailing NULL |
| } |
| // Sanity check |
| if string(resp[3]) != path { |
| panic("git check-ignore path did not roundtrip, got " + string(resp[3]) + " sent " + path) |
| } |
| return len(resp[0]) > 0 |
| } |
| } |
| |
| type fileInfo struct { |
| fi os.FileInfo |
| sha1 string // if regular file |
| } |
| local := map[string]fileInfo{} // keys like "src/make.bash" |
| |
| // Ensure that the goroot passed to filepath.Walk ends in a trailing slash, |
| // so that if GOROOT is a symlink we walk the underlying directory. |
| walkRoot := goroot |
| if walkRoot != "" && !os.IsPathSeparator(walkRoot[len(walkRoot)-1]) { |
| walkRoot += string(filepath.Separator) |
| } |
| if err := filepath.Walk(walkRoot, func(path string, fi os.FileInfo, err error) error { |
| if isEditorBackup(path) { |
| return nil |
| } |
| if err != nil { |
| return err |
| } |
| rel, err := filepath.Rel(goroot, path) |
| if err != nil { |
| return fmt.Errorf("error calculating relative path from %q to %q", goroot, path) |
| } |
| rel = filepath.ToSlash(rel) |
| if rel == "." { |
| return nil |
| } |
| if rel == ".git" { |
| if fi.IsDir() { |
| return filepath.SkipDir |
| } |
| return nil // .git is a file in `git worktree` checkouts. |
| } |
| if fi.IsDir() { |
| switch rel { |
| case "pkg", "bin": |
| return filepath.SkipDir |
| } |
| } |
| inf := fileInfo{fi: fi} |
| if isGitIgnored(path) { |
| if fi.IsDir() { |
| return filepath.SkipDir |
| } |
| return nil |
| } |
| if fi.Mode().IsRegular() { |
| inf.sha1, err = fileSHA1(path) |
| if err != nil { |
| return err |
| } |
| } |
| local[rel] = inf |
| return nil |
| }); err != nil { |
| return fmt.Errorf("error enumerating local GOROOT files: %w", err) |
| } |
| |
| var toDel []string |
| for rel := range remote { |
| if rel == "VERSION" { |
| // Don't delete this. It's harmless, and |
| // necessary. Clients can overwrite it if they |
| // want. But if there's no VERSION file there, |
| // make.bash/bat assumes there's a git repo in |
| // place, but there's not only not a git repo |
| // there with gomote, but there's no git tool |
| // available either. |
| continue |
| } |
| // Also don't delete the auto-generated files from cmd/dist. |
| // Otherwise gomote users can't gomote push + gomote run make.bash |
| // and then iteratively: |
| // -- hack locally |
| // -- gomote push |
| // -- gomote run go test -v ... |
| // Because the go test would fail remotely without |
| // these files if they were deleted by gomote push. |
| if isGoToolDistGenerated(rel) { |
| continue |
| } |
| if isGitIgnored(rel) { |
| // Don't delete remote gitignored files; this breaks built toolchains. |
| continue |
| } |
| rel = strings.TrimRight(rel, "/") |
| if rel == "" { |
| continue |
| } |
| if _, ok := local[rel]; !ok { |
| toDel = append(toDel, rel) |
| } |
| } |
| if len(toDel) > 0 { |
| withGo := make([]string, len(toDel)) // with the "go/" prefix |
| for i, v := range toDel { |
| withGo[i] = "go/" + v |
| } |
| sort.Strings(withGo) |
| if dryRun { |
| logf("(Dry-run) Would have deleted remote files: %q", withGo) |
| } else { |
| logf("Deleting remote files: %q", withGo) |
| if _, err := client.RemoveFiles(ctx, &protos.RemoveFilesRequest{ |
| GomoteId: name, |
| Paths: withGo, |
| }); err != nil { |
| return fmt.Errorf("failed to delete remote unwanted files: %w", err) |
| } |
| } |
| } |
| var toSend []string |
| notHave := 0 |
| const maxNotHavePrint = 5 |
| for rel, inf := range local { |
| if isGoToolDistGenerated(rel) || rel == "VERSION.cache" { |
| continue |
| } |
| if !inf.fi.Mode().IsRegular() { |
| if !inf.fi.IsDir() { |
| logf("Ignoring local non-regular, non-directory file %s: %v", rel, inf.fi.Mode()) |
| } |
| continue |
| } |
| rem, ok := remote[rel] |
| if !ok { |
| if notHave++; notHave <= maxNotHavePrint { |
| logf("Remote doesn't have %q", rel) |
| } |
| toSend = append(toSend, rel) |
| continue |
| } |
| if rem.Digest() != inf.sha1 { |
| logf("Remote's %s digest is %q; want %q", rel, rem.Digest(), inf.sha1) |
| toSend = append(toSend, rel) |
| } |
| } |
| if notHave > maxNotHavePrint { |
| logf("Remote doesn't have %d files (only showed %d).", notHave, maxNotHavePrint) |
| } |
| _, localHasVersion := local["VERSION"] |
| if _, remoteHasVersion := remote["VERSION"]; !remoteHasVersion && !localHasVersion { |
| logf("Remote lacks a VERSION file; sending a fake one") |
| toSend = append(toSend, "VERSION") |
| } |
| if len(toSend) > 0 { |
| sort.Strings(toSend) |
| tgz, err := generateDeltaTgz(goroot, toSend) |
| if err != nil { |
| return err |
| } |
| logf("Uploading %d new/changed files; %d byte .tar.gz", len(toSend), tgz.Len()) |
| if dryRun { |
| logf("(Dry-run mode; not doing anything.") |
| return nil |
| } |
| resp, err := client.UploadFile(ctx, &protos.UploadFileRequest{}) |
| if err != nil { |
| return fmt.Errorf("unable to request credentials for a file upload: %w", err) |
| } |
| if err := uploadToGCS(ctx, resp.GetFields(), tgz, resp.GetObjectName(), resp.GetUrl()); err != nil { |
| return fmt.Errorf("unable to upload file to GCS: %w", err) |
| } |
| if _, err := client.WriteTGZFromURL(ctx, &protos.WriteTGZFromURLRequest{ |
| GomoteId: name, |
| Url: fmt.Sprintf("%s%s", resp.GetUrl(), resp.GetObjectName()), |
| Directory: "go", |
| }); err != nil { |
| return fmt.Errorf("failed writing tarball to buildlet: %w", err) |
| } |
| } |
| return nil |
| } |
| |
| func isGoToolDistGenerated(path string) bool { |
| switch path { |
| case "src/cmd/cgo/zdefaultcc.go", |
| "src/cmd/go/internal/cfg/zdefaultcc.go", |
| "src/cmd/go/internal/cfg/zosarch.go", |
| "src/cmd/internal/objabi/zbootstrap.go", |
| "src/go/build/zcgo.go", |
| "src/internal/buildcfg/zbootstrap.go", |
| "src/runtime/internal/sys/zversion.go", |
| "src/time/tzdata/zzipdata.go": |
| return true |
| } |
| return false |
| } |
| |
| func isEditorBackup(path string) bool { |
| base := filepath.Base(path) |
| if strings.HasPrefix(base, ".") && strings.HasSuffix(base, ".swp") { |
| // vi |
| return true |
| } |
| if strings.HasSuffix(path, "~") || strings.HasSuffix(path, "#") || |
| strings.HasPrefix(base, "#") || strings.HasPrefix(base, ".#") { |
| // emacs |
| return true |
| } |
| return false |
| } |
| |
| // file is forward-slash separated |
| func generateDeltaTgz(goroot string, files []string) (*bytes.Buffer, error) { |
| var buf bytes.Buffer |
| zw := gzip.NewWriter(&buf) |
| tw := tar.NewWriter(zw) |
| for _, file := range files { |
| // Special. |
| if file == "VERSION" && !localFileExists(filepath.Join(goroot, file)) { |
| // TODO(bradfitz): a dummy VERSION file's contents to make things |
| // happy. Notably it starts with "devel ". Do we care about it |
| // being accurate beyond that? |
| version := "devel gomote.XXXXX" |
| if err := tw.WriteHeader(&tar.Header{ |
| Name: "VERSION", |
| Mode: 0644, |
| Size: int64(len(version)), |
| }); err != nil { |
| return nil, err |
| } |
| if _, err := io.WriteString(tw, version); err != nil { |
| return nil, err |
| } |
| continue |
| } |
| f, err := os.Open(filepath.Join(goroot, file)) |
| if err != nil { |
| return nil, err |
| } |
| fi, err := f.Stat() |
| if err != nil { |
| f.Close() |
| return nil, err |
| } |
| header, err := tar.FileInfoHeader(fi, "") |
| if err != nil { |
| f.Close() |
| return nil, err |
| } |
| header.Name = file // forward slash |
| if err := tw.WriteHeader(header); err != nil { |
| f.Close() |
| return nil, err |
| } |
| if _, err := io.CopyN(tw, f, header.Size); err != nil { |
| f.Close() |
| return nil, fmt.Errorf("error copying contents of %s: %w", file, err) |
| } |
| f.Close() |
| } |
| if err := tw.Close(); err != nil { |
| return nil, err |
| } |
| if err := zw.Close(); err != nil { |
| return nil, err |
| } |
| |
| return &buf, nil |
| } |
| |
| func fileSHA1(path string) (string, error) { |
| f, err := os.Open(path) |
| if err != nil { |
| return "", err |
| } |
| defer f.Close() |
| s1 := sha1.New() |
| if _, err := io.Copy(s1, f); err != nil { |
| return "", err |
| } |
| return fmt.Sprintf("%x", s1.Sum(nil)), nil |
| } |
| |
| func getGOROOT() (string, error) { |
| goroot := os.Getenv("GOROOT") |
| if goroot == "" { |
| slurp, err := exec.Command("go", "env", "GOROOT").Output() |
| if err != nil { |
| return "", fmt.Errorf("failed to get GOROOT from go env: %w", err) |
| } |
| goroot = strings.TrimSpace(string(slurp)) |
| if goroot == "" { |
| return "", errors.New("Failed to get $GOROOT from environment or go env") |
| } |
| } |
| goroot = filepath.Clean(goroot) |
| return goroot, nil |
| } |
| |
| func localFileExists(path string) bool { |
| _, err := os.Stat(path) |
| return !os.IsNotExist(err) |
| } |