go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/gcs-util/up.go (about) 1 // Copyright 2022 The Fuchsia Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package main 6 7 import ( 8 "archive/tar" 9 "bytes" 10 "compress/gzip" 11 "context" 12 "crypto/ed25519" 13 "crypto/md5" 14 "encoding/json" 15 "errors" 16 "fmt" 17 "hash" 18 "io" 19 "io/fs" 20 "os" 21 "os/signal" 22 "path" 23 "path/filepath" 24 "strconv" 25 "strings" 26 "sync" 27 "syscall" 28 "time" 29 30 "cloud.google.com/go/storage" 31 "github.com/maruel/subcommands" 32 "go.chromium.org/luci/auth" 33 "go.chromium.org/luci/common/logging" 34 "go.chromium.org/luci/common/logging/gologger" 35 "go.chromium.org/luci/common/retry/transient" 36 "google.golang.org/api/googleapi" 37 38 "go.fuchsia.dev/infra/cmd/gcs-util/lib" 39 "go.fuchsia.dev/infra/cmd/gcs-util/types" 40 "google.golang.org/api/option" 41 ) 42 43 func cmdUp(authOpts auth.Options) *subcommands.Command { 44 return &subcommands.Command{ 45 UsageLine: "up -bucket <bucket> -namespace <namespace> -manifest-path <manifest-path>", 46 ShortDesc: "Upload files from an input manifest to Google Cloud Storage.", 47 LongDesc: "Upload files from an input manifest to Google Cloud Storage.", 48 CommandRun: func() subcommands.CommandRun { 49 c := &upCmd{} 50 c.Init(authOpts) 51 return c 52 }, 53 } 54 } 55 56 func isTransientError(err error) bool { 57 var apiErr *googleapi.Error 58 return transient.Tag.In(err) || (errors.As(err, &apiErr) && apiErr.Code >= 500) 59 } 60 61 type upCmd struct { 62 commonFlags 63 64 bucket string 65 namespace string 66 privateKeyPath string 67 manifestPath string 68 j int 69 logLevel logging.Level 70 } 71 72 func (c *upCmd) Init(defaultAuthOpts auth.Options) { 73 c.commonFlags.Init(defaultAuthOpts) 74 c.Flags.StringVar(&c.bucket, "bucket", "", "Bucket to upload to.") 75 c.Flags.StringVar(&c.namespace, "namespace", "", "Namespace of non-deduplicated uploads relative to the root of the bucket.") 76 c.Flags.StringVar(&c.privateKeyPath, "pkey", "", "The path to an ED25519 private key encoded in the PKCS8 PEM format.\n"+ 77 "This can, for example, be generated by \"openssl genpkey -algorithm ed25519\".\n"+ 78 "If set, all images and build APIs will be signed and uploaded with their signatures\n"+ 79 "in GCS metadata, and the corresponding public key will be uploaded as well.") 80 c.Flags.StringVar(&c.manifestPath, "manifest-path", "", "Upload manifest.") 81 c.Flags.IntVar(&c.j, "j", 32, "Maximum number of concurrent uploading processes.") 82 c.Flags.Var(&c.logLevel, "log-level", "Logging level. Can be debug, info, warning, or error.") 83 } 84 85 func (c *upCmd) parseArgs() error { 86 if err := c.commonFlags.Parse(); err != nil { 87 return err 88 } 89 if c.bucket == "" { 90 return errors.New("-bucket is required") 91 } 92 if c.namespace == "" { 93 return errors.New("-namespace is required") 94 } 95 if c.manifestPath == "" { 96 return errors.New("-manifest-path is required") 97 } 98 return nil 99 } 100 101 func (c *upCmd) Run(a subcommands.Application, _ []string, _ subcommands.Env) int { 102 if err := c.parseArgs(); err != nil { 103 fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err) 104 return 1 105 } 106 107 if err := c.main(); err != nil { 108 fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err) 109 if isTransientError(err) || errors.Is(err, context.DeadlineExceeded) { 110 return exitTransientError 111 } 112 return 1 113 } 114 return 0 115 } 116 117 func (c *upCmd) main() error { 118 ctx := context.Background() 119 ctx, cancel := signal.NotifyContext(ctx, syscall.SIGTERM) 120 defer cancel() 121 ctx = logging.SetLevel(ctx, c.logLevel) 122 ctx = gologger.StdConfig.Use(ctx) 123 124 jsonInput, err := os.ReadFile(c.manifestPath) 125 if err != nil { 126 return err 127 } 128 var manifest []types.Upload 129 if err := json.Unmarshal(jsonInput, &manifest); err != nil { 130 return err 131 } 132 133 // Flatten any directories in the manifest to files. 134 files := []types.Upload{} 135 for _, upload := range manifest { 136 if len(upload.Source) == 0 { 137 files = append(files, upload) 138 continue 139 } 140 fileInfo, err := os.Stat(upload.Source) 141 if err != nil { 142 return err 143 } 144 if !fileInfo.IsDir() { 145 files = append(files, upload) 146 } else { 147 contents, err := dirToFiles(ctx, upload) 148 if err != nil { 149 return err 150 } 151 files = append(files, contents...) 152 } 153 } 154 155 pkey, err := lib.PrivateKey(c.privateKeyPath) 156 if err != nil { 157 return fmt.Errorf("failed to get private key: %w", err) 158 } 159 if pkey != nil { 160 publicKey, err := lib.PublicKeyUpload(pkey.Public().(ed25519.PublicKey)) 161 if err != nil { 162 return err 163 } 164 files = append(files, *publicKey) 165 files, err = lib.Sign(files, pkey) 166 if err != nil { 167 return err 168 } 169 } 170 171 authenticator := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.parsedAuthOpts) 172 tokenSource, err := authenticator.TokenSource() 173 if err != nil { 174 if err == auth.ErrLoginRequired { 175 fmt.Fprintf(os.Stderr, "You need to login first by running:\n") 176 fmt.Fprintf(os.Stderr, " luci-auth login -scopes %q\n", strings.Join(c.parsedAuthOpts.Scopes, " ")) 177 } 178 return err 179 } 180 181 sink, err := newCloudSink(ctx, c.bucket, option.WithTokenSource(tokenSource)) 182 if err != nil { 183 return err 184 } 185 defer sink.client.Close() 186 return uploadFiles(ctx, files, sink, c.j, c.namespace) 187 } 188 189 // DataSink is an abstract data sink, providing a mockable interface to 190 // cloudSink, the GCS-backed implementation below. 191 type dataSink interface { 192 193 // ObjectExistsAt returns whether an object of that name exists within the sink. 194 objectExistsAt(ctx context.Context, name string) (bool, *storage.ObjectAttrs, error) 195 196 // Write writes the content of a reader to a sink object at the given name. 197 // If an object at that name does not exists, it will be created; else it 198 // will be overwritten. 199 write(ctx context.Context, upload *types.Upload) error 200 } 201 202 // CloudSink is a GCS-backed data sink. 203 type cloudSink struct { 204 client *storage.Client 205 bucket *storage.BucketHandle 206 } 207 208 func newCloudSink(ctx context.Context, bucket string, opts ...option.ClientOption) (*cloudSink, error) { 209 client, err := storage.NewClient(ctx, opts...) 210 if err != nil { 211 return nil, err 212 } 213 return &cloudSink{ 214 client: client, 215 bucket: client.Bucket(bucket), 216 }, nil 217 } 218 219 func (s *cloudSink) objectExistsAt(ctx context.Context, name string) (bool, *storage.ObjectAttrs, error) { 220 attrs, err := lib.ObjectAttrs(ctx, s.bucket.Object(name)) 221 if err != nil { 222 if errors.Is(err, storage.ErrObjectNotExist) { 223 return false, nil, nil 224 } 225 return false, nil, err 226 } 227 // Check if MD5 is not set, mark this as a miss, then write() function will 228 // handle the race. 229 return len(attrs.MD5) != 0, attrs, nil 230 } 231 232 // hasher is a io.Writer that calculates the MD5. 233 type hasher struct { 234 h hash.Hash 235 w io.Writer 236 } 237 238 func (h *hasher) Write(p []byte) (int, error) { 239 n, err := h.w.Write(p) 240 _, _ = h.h.Write(p[:n]) 241 return n, err 242 } 243 244 func (s *cloudSink) write(ctx context.Context, upload *types.Upload) error { 245 var reader io.Reader 246 if upload.Source != "" { 247 f, err := os.Open(upload.Source) 248 if err != nil { 249 return err 250 } 251 defer f.Close() 252 reader = f 253 } else { 254 reader = bytes.NewBuffer(upload.Contents) 255 } 256 257 obj := s.bucket.Object(upload.Destination) 258 // Set timeouts to fail fast on unresponsive connections. 259 tctx, cancel := context.WithTimeout(ctx, perFileUploadTimeout) 260 defer cancel() 261 sw := obj.If(storage.Conditions{DoesNotExist: true}).NewWriter(tctx) 262 sw.ChunkSize = chunkSize 263 sw.ContentType = "application/octet-stream" 264 if upload.Compress { 265 sw.ContentEncoding = "gzip" 266 } 267 if upload.Metadata != nil { 268 sw.Metadata = upload.Metadata 269 } 270 // The CustomTime needs to be set to work with the lifecycle condition 271 // set on the GCS bucket. 272 sw.CustomTime = time.Now() 273 274 // We optionally compress on the fly, and calculate the MD5 on the 275 // compressed data. 276 // Writes happen asynchronously, and so a nil may be returned while the write 277 // goes on to fail. It is recommended in 278 // https://godoc.org/cloud.google.com/go/storage#Writer.Write 279 // to return the value of Close() to detect the success of the write. 280 // Note that a gzip compressor would need to be closed before the storage 281 // writer that it wraps is. 282 h := &hasher{md5.New(), sw} 283 var writeErr, tarErr, zipErr error 284 if upload.Compress { 285 gzw := gzip.NewWriter(h) 286 if upload.TarHeader != nil { 287 tw := tar.NewWriter(gzw) 288 writeErr = tw.WriteHeader(upload.TarHeader) 289 if writeErr == nil { 290 _, writeErr = io.Copy(tw, reader) 291 } 292 tarErr = tw.Close() 293 } else { 294 _, writeErr = io.Copy(gzw, reader) 295 } 296 zipErr = gzw.Close() 297 } else { 298 _, writeErr = io.Copy(h, reader) 299 } 300 closeErr := sw.Close() 301 302 // Keep the first error we encountered - and vet it for 'permissable' GCS 303 // error states. 304 // Note: consider an errorsmisc.FirstNonNil() helper if see this logic again. 305 err := writeErr 306 if err == nil { 307 err = tarErr 308 } 309 if err == nil { 310 err = zipErr 311 } 312 if err == nil { 313 err = closeErr 314 } 315 if err = checkGCSErr(ctx, err, upload.Destination); err != nil { 316 return err 317 } 318 319 // Now confirm that the MD5 matches upstream, just in case. If the file was 320 // uploaded by another client (a race condition), loop until the MD5 is set. 321 // This guarantees that the file is properly uploaded before this function 322 // quits. 323 d := h.h.Sum(nil) 324 t := time.Second 325 const max = 30 * time.Second 326 for { 327 attrs, err := lib.ObjectAttrs(ctx, obj) 328 if err != nil { 329 return fmt.Errorf("failed to confirm MD5 for %s due to: %w", upload.Destination, err) 330 } 331 if len(attrs.MD5) == 0 { 332 time.Sleep(t) 333 if t += t / 2; t > max { 334 t = max 335 } 336 logging.Debugf(ctx, "waiting for MD5 for %s", upload.Destination) 337 continue 338 } 339 if !bytes.Equal(attrs.MD5, d) { 340 return md5MismatchError{fmt.Errorf("MD5 mismatch for %s; local: %x, remote: %x", upload.Destination, d, attrs.MD5)} 341 } 342 break 343 } 344 return nil 345 } 346 347 // TODO(fxbug.dev/78017): Delete this type once fixed. 348 type md5MismatchError struct { 349 error 350 } 351 352 // checkGCSErr validates the error for a GCS upload. 353 // 354 // If the precondition of the object not existing is not met on write (i.e., 355 // at the time of the write the object is there), then the server will 356 // respond with a 412. (See 357 // https://cloud.google.com/storage/docs/json_api/v1/status-codes and 358 // https://tools.ietf.org/html/rfc7232#section-4.2.) 359 // We do not report this as an error, however, as the associated object might 360 // have been created by another job after we checked its non-existence - and we 361 // wish to be resilient in the event of such a race. 362 func checkGCSErr(ctx context.Context, err error, name string) error { 363 if err == nil || err == io.EOF { 364 return nil 365 } 366 if strings.Contains(err.Error(), "Error 412") { 367 logging.Debugf(ctx, "object %q: encountered recoverable race condition during upload, already exists remotely", name) 368 return nil 369 } 370 return err 371 } 372 373 // dirToFiles returns a list of the top-level files in the dir if dir.Recursive 374 // is false, else it returns all files in the dir. 375 func dirToFiles(ctx context.Context, dir types.Upload) ([]types.Upload, error) { 376 var files []types.Upload 377 var err error 378 var paths []string 379 if dir.Recursive { 380 err = filepath.Walk(dir.Source, func(path string, info os.FileInfo, err error) error { 381 if err != nil { 382 return err 383 } 384 if !info.IsDir() { 385 relPath, err := filepath.Rel(dir.Source, path) 386 if err != nil { 387 return err 388 } 389 paths = append(paths, relPath) 390 } 391 return nil 392 }) 393 } else { 394 var entries []fs.DirEntry 395 entries, err = os.ReadDir(dir.Source) 396 if err == nil { 397 for _, fi := range entries { 398 if fi.IsDir() { 399 continue 400 } 401 paths = append(paths, fi.Name()) 402 } 403 } 404 } 405 if err != nil { 406 return nil, err 407 } 408 for _, path := range paths { 409 files = append(files, types.Upload{ 410 Source: filepath.Join(dir.Source, path), 411 Destination: filepath.Join(dir.Destination, path), 412 Compress: dir.Compress, 413 Deduplicate: dir.Deduplicate, 414 Signed: dir.Signed, 415 }) 416 } 417 return files, nil 418 } 419 420 func uploadFiles(ctx context.Context, files []types.Upload, dest dataSink, j int, namespace string) error { 421 if j <= 0 { 422 return fmt.Errorf("concurrency factor j must be a positive number") 423 } 424 425 uploads := make(chan types.Upload, j) 426 errs := make(chan error, j) 427 428 queueUploads := func() { 429 defer close(uploads) 430 for _, f := range files { 431 if len(f.Source) != 0 { 432 fileInfo, err := os.Stat(f.Source) 433 if err != nil { 434 errs <- err 435 return 436 } 437 mtime := strconv.FormatInt(fileInfo.ModTime().Unix(), 10) 438 if f.Metadata == nil { 439 f.Metadata = map[string]string{} 440 } 441 f.Metadata[googleReservedFileMtime] = mtime 442 } 443 uploads <- f 444 } 445 } 446 447 objsToRefreshTTL := make(chan string) 448 var wg sync.WaitGroup 449 wg.Add(j) 450 upload := func() { 451 defer wg.Done() 452 for upload := range uploads { 453 // Files which are not deduplicated are uploaded to the dedicated 454 // -namespace. The manifest may already set the namespace, and in 455 // that case, don't try to prepend it. 456 if !upload.Deduplicate && !strings.HasPrefix(upload.Destination, namespace) { 457 upload.Destination = path.Join(namespace, upload.Destination) 458 } 459 exists, attrs, err := dest.objectExistsAt(ctx, upload.Destination) 460 if err != nil { 461 errs <- err 462 return 463 } 464 if exists { 465 logging.Debugf(ctx, "object %q: already exists remotely", upload.Destination) 466 if !upload.Deduplicate { 467 errs <- fmt.Errorf("object %q: collided", upload.Destination) 468 return 469 } 470 // Add objects to update timestamps for that are older than 471 // daysSinceCustomTime. 472 if attrs != nil && time.Now().AddDate(0, 0, -daysSinceCustomTime).After(attrs.CustomTime) { 473 objsToRefreshTTL <- upload.Destination 474 } 475 continue 476 } 477 478 if err := uploadFile(ctx, upload, dest); err != nil { 479 // If deduplicated file already exists remotely but the local 480 // and remote md5 hashes don't match, upload our version to a 481 // namespaced path so it can be compared with the 482 // already-existent version to help with debugging. 483 // TODO(fxbug.dev/78017): Delete this logic once fixed. 484 var md5Err md5MismatchError 485 if errors.As(err, &md5Err) { 486 upload.Destination = path.Join(namespace, "md5-mismatches", upload.Destination) 487 if err := uploadFile(ctx, upload, dest); err != nil { 488 logging.Warningf(ctx, "failed to upload md5-mismatch file %q for debugging: %s", upload.Destination, err) 489 } 490 } 491 errs <- err 492 return 493 } 494 } 495 } 496 497 go queueUploads() 498 for range j { 499 go upload() 500 } 501 go func() { 502 wg.Wait() 503 close(errs) 504 close(objsToRefreshTTL) 505 }() 506 var objs []string 507 for o := range objsToRefreshTTL { 508 objs = append(objs, o) 509 } 510 511 if err := <-errs; err != nil { 512 return err 513 } 514 // Upload a file listing all the deduplicated files that already existed in 515 // the upload destination. A post-processor will use this file to update the 516 // CustomTime of the objects and extend their TTL. 517 if len(objs) > 0 { 518 objsToRefreshTTLUpload := types.Upload{ 519 Contents: []byte(strings.Join(objs, "\n")), 520 Destination: path.Join(namespace, objsToRefreshTTLTxt), 521 } 522 return uploadFile(ctx, objsToRefreshTTLUpload, dest) 523 } 524 return nil 525 } 526 527 func uploadFile(ctx context.Context, upload types.Upload, dest dataSink) error { 528 logging.Debugf(ctx, "object %q: attempting creation", upload.Destination) 529 if err := lib.Retry(ctx, func() error { 530 err := dest.write(ctx, &upload) 531 if err != nil { 532 logging.Warningf(ctx, "error uploading %q: %s", upload.Destination, err) 533 } 534 return err 535 }); err != nil { 536 return fmt.Errorf("%s: %w", upload.Destination, err) 537 } 538 logging.Infof(ctx, "object %q: created", upload.Destination) 539 return nil 540 }