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 }