go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/clients/gs.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package clients
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"io"
    22  	"net/http"
    23  	"time"
    24  
    25  	"cloud.google.com/go/storage"
    26  	"google.golang.org/api/googleapi"
    27  	"google.golang.org/api/option"
    28  
    29  	"go.chromium.org/luci/common/clock"
    30  	"go.chromium.org/luci/common/logging"
    31  	"go.chromium.org/luci/common/retry"
    32  	"go.chromium.org/luci/common/retry/transient"
    33  	"go.chromium.org/luci/server/auth"
    34  )
    35  
    36  var gsClientCtxKey = "holds the Google Cloud Storage client"
    37  
    38  // GsClient is an interface for interacting with Cloud Storage.
    39  type GsClient interface {
    40  	// UploadIfMissing upload data with attrs to the bucket-to-object path if it
    41  	// does not exist. Return true, if the upload operation is made this time.
    42  	UploadIfMissing(ctx context.Context, bucket, object string, data []byte, attrsModifyFn func(*storage.ObjectAttrs)) (bool, error)
    43  	// Read reads data from the give bucket-to-object path.
    44  	// If decompressive is true, it will read with decompressive transcoding:
    45  	// https://cloud.google.com/storage/docs/transcoding#decompressive_transcoding
    46  	Read(ctx context.Context, bucket, object string, decompressive bool) ([]byte, error)
    47  	// Touch updates the custom time of the object to the current timestamp.
    48  	//
    49  	// Returns storage.ErrObjectNotExist if object is not found.
    50  	Touch(ctx context.Context, bucket, object string) error
    51  	// SignedURL is used to generate a signed url for a given GCS object.
    52  	SignedURL(bucket, object string, opts *storage.SignedURLOptions) (string, error)
    53  	// Delete deletes the give object.
    54  	Delete(ctx context.Context, bucket, object string) error
    55  }
    56  
    57  // prodClient implements GsClient and used in Prod env only.
    58  type prodClient struct {
    59  	client *storage.Client
    60  }
    61  
    62  // NewGsProdClient create a prodClient.
    63  func NewGsProdClient(ctx context.Context) (GsClient, error) {
    64  	ts, err := auth.GetTokenSource(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
    65  	if err != nil {
    66  		return nil, fmt.Errorf("failed to get OAuth2 token source: %w", err)
    67  	}
    68  	client, err := storage.NewClient(ctx, option.WithTokenSource(ts))
    69  	if err != nil {
    70  		return nil, err
    71  	}
    72  	return &prodClient{
    73  		client: client,
    74  	}, nil
    75  }
    76  
    77  // WithGsClient returns a new context with the given GsClient.
    78  func WithGsClient(ctx context.Context, client GsClient) context.Context {
    79  	return context.WithValue(ctx, &gsClientCtxKey, client)
    80  }
    81  
    82  // GetGsClient returns the GsClient installed in the current context.
    83  func GetGsClient(ctx context.Context) GsClient {
    84  	return ctx.Value(&gsClientCtxKey).(GsClient)
    85  }
    86  
    87  // UploadIfMissing upload data with attrs to the bucket-to-object path if it
    88  // does not exist. Return true, if the upload operation is made this time.
    89  func (p *prodClient) UploadIfMissing(ctx context.Context, bucket, object string, data []byte, attrsModifyFn func(*storage.ObjectAttrs)) (bool, error) {
    90  	w := p.client.Bucket(bucket).Object(object).If(storage.Conditions{DoesNotExist: true}).NewWriter(ctx)
    91  	if attrsModifyFn != nil {
    92  		attrsModifyFn(&w.ObjectAttrs)
    93  	}
    94  
    95  	if _, err := w.Write(data); err != nil {
    96  		return false, err
    97  	}
    98  	if err := w.Close(); err != nil {
    99  		var apiErr *googleapi.Error
   100  		if errors.As(err, &apiErr) && apiErr.Code == http.StatusPreconditionFailed {
   101  			// The provided condition has already meet, no uploads are done.
   102  			return false, nil
   103  		}
   104  		return false, err
   105  	}
   106  	return true, nil
   107  }
   108  
   109  // Read reads data from the give bucket-to-object path.
   110  // If decompressive is true, it will read with decompressive transcoding:
   111  // https://cloud.google.com/storage/docs/transcoding#decompressive_transcoding
   112  func (p *prodClient) Read(ctx context.Context, bucket, object string, decompressive bool) ([]byte, error) {
   113  	r, err := p.client.Bucket(bucket).Object(object).ReadCompressed(!decompressive).NewReader(ctx)
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  	defer func() {
   118  		_ = r.Close()
   119  	}()
   120  	return io.ReadAll(r)
   121  }
   122  
   123  // Touch updates the custom time of the object to the current timestamp.
   124  //
   125  // Returns storage.ErrObjectNotExist if object is not found.
   126  func (p *prodClient) Touch(ctx context.Context, bucket, object string) error {
   127  	obj := p.client.Bucket(bucket).Object(object)
   128  	err := retry.Retry(ctx, transient.Only(retry.Default),
   129  		func() error {
   130  			attr, err := obj.Attrs(ctx)
   131  			switch {
   132  			case err != nil:
   133  				return err
   134  			case !attr.CustomTime.IsZero() && clock.Now(ctx).Sub(attr.CustomTime) < 10*time.Minute:
   135  				// If custom time is updated within last 10 minute, then skip updating.
   136  				return nil
   137  			}
   138  			// Conditionally update the storage metadata and retry on pre-condition
   139  			// failure. This is to mitigate the concurrent modification error occurs
   140  			// when the same requester sends multiple validation requests that contain
   141  			// the exact same config file. As a request, all the request processors
   142  			// will contend to update the metadata of the same object.
   143  			_, err = obj.
   144  				If(storage.Conditions{MetagenerationMatch: attr.Metageneration}).
   145  				Update(ctx, storage.ObjectAttrsToUpdate{
   146  					CustomTime: clock.Now(ctx).UTC(),
   147  				})
   148  			var apiErr *googleapi.Error
   149  			if errors.As(err, &apiErr) {
   150  				switch apiErr.Code {
   151  				case http.StatusConflict, http.StatusPreconditionFailed:
   152  					// Tag precondition fail and conflict as transient to trigger a retry.
   153  					return transient.Tag.Apply(err)
   154  				}
   155  			}
   156  			return err
   157  		}, func(err error, d time.Duration) {
   158  			logging.Warningf(ctx, "got err: %s when touching the object. Retrying in %s", err, d)
   159  		})
   160  	return err
   161  }
   162  
   163  // SignedURL is used to generate a signed url for a given GCS object.
   164  func (p *prodClient) SignedURL(bucket, object string, opts *storage.SignedURLOptions) (string, error) {
   165  	// Use https://pkg.go.dev/cloud.google.com/go/storage#BucketHandle.SignedURL
   166  	// to generate the signed URL.
   167  	url, err := p.client.Bucket(bucket).SignedURL(object, opts)
   168  	if err != nil {
   169  		return "", err
   170  	}
   171  	return url, nil
   172  }
   173  
   174  // Delete deletes the give object.
   175  func (p *prodClient) Delete(ctx context.Context, bucket, object string) error {
   176  	return p.client.Bucket(bucket).Object(object).Delete(ctx)
   177  }