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  }