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 }