go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/impl/remote/remote_v2.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 remote 16 17 import ( 18 "context" 19 "math" 20 "net/http" 21 "net/url" 22 "sort" 23 "sync" 24 25 "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 26 "golang.org/x/sync/errgroup" 27 "google.golang.org/genproto/protobuf/field_mask" 28 "google.golang.org/grpc" 29 "google.golang.org/grpc/codes" 30 "google.golang.org/grpc/credentials" 31 "google.golang.org/grpc/encoding/gzip" 32 33 "go.chromium.org/luci/common/errors" 34 "go.chromium.org/luci/common/retry/transient" 35 pb "go.chromium.org/luci/config_service/proto" 36 "go.chromium.org/luci/grpc/grpcmon" 37 "go.chromium.org/luci/grpc/grpcutil" 38 "go.chromium.org/luci/server/auth" 39 40 "go.chromium.org/luci/config" 41 ) 42 43 // retryPolicy is the default grpc retry policy for this Luci-config client. 44 const retryPolicy = `{ 45 "methodConfig": [{ 46 "name": [{ "service": "config.service.v2.Configs" }], 47 "timeout": "60s", 48 "retryPolicy": { 49 "maxAttempts": 5, 50 "initialBackoff": "1s", 51 "maxBackoff": "10s", 52 "backoffMultiplier": 1.5, 53 "retryableStatusCodes": ["UNAVAILABLE", "INTERNAL", "UNKNOWN"] 54 } 55 }] 56 }` 57 58 const ( 59 // defaultUserAgent is the default user-agent header value to use. 60 defaultUserAgent = "Config Go Client 1.0" 61 ) 62 63 type V2Options struct { 64 // Host is the hostname of a LUCI Config service. 65 Host string 66 67 // Creds is the credential to use when creating the grpc connection. 68 Creds credentials.PerRPCCredentials 69 70 // UserAgent is the optional additional User-Agent fragment which will be 71 // appended to gRPC calls 72 // 73 // If empty, defaultUserAgent is used. 74 UserAgent string 75 76 // DialOpts are the options to use to dial. 77 // 78 // If nil, DefaultDialOptions() are used 79 DialOpts []grpc.DialOption 80 } 81 82 // DefaultDialOptions returns default grpc dial options to connect to Luci-config v2. 83 func DefaultDialOptions() []grpc.DialOption { 84 return []grpc.DialOption{ 85 grpc.WithTransportCredentials(credentials.NewTLS(nil)), 86 grpc.WithStatsHandler(&grpcmon.ClientRPCStatsMonitor{}), 87 grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()), 88 grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()), 89 grpc.WithDefaultServiceConfig(retryPolicy), 90 // Luci-config V2 can return gzip-compressed msg. But the grpc client 91 // doesn't provide a way to check the pure compressed response size. It also 92 // checks size after decompression. It's hard to set a fixed size. And for 93 // very large size config, Luci-config already uses GCS to pass the file. 94 // So it's fine to not limit the received msg size. 95 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(math.MaxInt32)), 96 } 97 } 98 99 // NewV2 returns an implementation of the config Interface which talks to the 100 // real Luci-config service v2. 101 func NewV2(ctx context.Context, opts V2Options) (config.Interface, error) { 102 if opts.Host == "" { 103 return nil, errors.New("host is not specified") 104 } 105 106 dialOpts := opts.DialOpts 107 if dialOpts == nil { 108 dialOpts = DefaultDialOptions() 109 } 110 if opts.Creds != nil { 111 dialOpts = append(dialOpts, grpc.WithPerRPCCredentials(opts.Creds)) 112 } 113 if opts.UserAgent != "" { 114 dialOpts = append(dialOpts, grpc.WithUserAgent(opts.UserAgent)) 115 } else { 116 dialOpts = append(dialOpts, grpc.WithUserAgent(defaultUserAgent)) 117 } 118 119 conn, err := grpc.DialContext(ctx, opts.Host+":443", dialOpts...) 120 if err != nil { 121 return nil, errors.Annotate(err, "cannot dial to %s", opts.Host).Err() 122 } 123 124 t := http.DefaultTransport 125 if s := auth.GetState(ctx); s != nil { 126 t, err = auth.GetRPCTransport(ctx, auth.NoAuth) 127 if err != nil { 128 return nil, errors.Annotate(err, "failed to create a transport").Err() 129 } 130 } 131 132 return &remoteV2Impl{ 133 conn: conn, 134 grpcClient: pb.NewConfigsClient(conn), 135 httpClient: &http.Client{Transport: t}, 136 }, nil 137 } 138 139 var _ config.Interface = &remoteV2Impl{} 140 141 // remoteV2Impl implements config.Interface and will make gRPC calls to Config 142 // Service V2. 143 type remoteV2Impl struct { 144 conn *grpc.ClientConn 145 grpcClient pb.ConfigsClient 146 // A http client with no additional authentication. Only used for downloading from signed urls. 147 httpClient *http.Client 148 } 149 150 func (r *remoteV2Impl) GetConfig(ctx context.Context, configSet config.Set, path string, metaOnly bool) (*config.Config, error) { 151 if err := r.checkInitialized(); err != nil { 152 return nil, err 153 } 154 req := &pb.GetConfigRequest{ 155 ConfigSet: string(configSet), 156 Path: path, 157 } 158 if metaOnly { 159 req.Fields = &field_mask.FieldMask{ 160 Paths: []string{"config_set", "path", "content_sha256", "revision", "url"}, 161 } 162 } 163 164 res, err := r.grpcClient.GetConfig(ctx, req, grpc.UseCompressor(gzip.Name)) 165 if err != nil { 166 return nil, wrapGrpcErr(err) 167 } 168 169 cfg := toConfig(res) 170 if res.GetSignedUrl() != "" { 171 content, err := config.DownloadConfigFromSignedURL(ctx, r.httpClient, res.GetSignedUrl()) 172 if err != nil { 173 return nil, transient.Tag.Apply(err) 174 } 175 cfg.Content = string(content) 176 } 177 178 return cfg, nil 179 } 180 181 func (r *remoteV2Impl) GetConfigs(ctx context.Context, cfgSet config.Set, filter func(path string) bool, metaOnly bool) (map[string]config.Config, error) { 182 if err := r.checkInitialized(); err != nil { 183 return nil, err 184 } 185 186 // Fetch the list of files in the config set together with their hashes. 187 confSetPb, err := r.grpcClient.GetConfigSet(ctx, &pb.GetConfigSetRequest{ 188 ConfigSet: string(cfgSet), 189 Fields: &field_mask.FieldMask{ 190 Paths: []string{"configs"}, 191 }, 192 }) 193 if err != nil { 194 return nil, wrapGrpcErr(err) 195 } 196 197 // An edge case. This should be impossible in practice. 198 if len(confSetPb.Configs) == 0 { 199 return nil, nil 200 } 201 202 // Assert all returned files are from the same revision. They should be. 203 rev := confSetPb.Configs[0].Revision 204 for _, cfg := range confSetPb.Configs { 205 if cfg.Revision != rev { 206 return nil, errors.Reason("internal error: the reply contains files from revisions %q and %q", cfg.Revision, rev).Err() 207 } 208 } 209 210 // Filter the file list through the callback. 211 var filtered []*pb.Config 212 if filter != nil { 213 filtered = confSetPb.Configs[:0] 214 for _, cfg := range confSetPb.Configs { 215 if filter(cfg.Path) { 216 filtered = append(filtered, cfg) 217 } 218 } 219 } else { 220 filtered = confSetPb.Configs 221 } 222 223 // If the caller only cares about metadata, we are done. 224 if metaOnly { 225 out := make(map[string]config.Config, len(filtered)) 226 for _, cfg := range filtered { 227 cfg.Content = nil // in case the server decides to return something 228 out[cfg.Path] = *toConfig(cfg) 229 } 230 return out, nil 231 } 232 233 // Fetch all files in parallel using their SHA256 as the key. 234 out := make(map[string]config.Config, len(filtered)) 235 var m sync.Mutex 236 eg, ectx := errgroup.WithContext(ctx) 237 eg.SetLimit(8) 238 for _, cfg := range filtered { 239 cfg := cfg 240 eg.Go(func() error { 241 body, err := r.grpcClient.GetConfig(ectx, &pb.GetConfigRequest{ 242 ConfigSet: string(cfgSet), 243 ContentSha256: cfg.ContentSha256, 244 }, grpc.UseCompressor(gzip.Name)) 245 if err != nil { 246 err = wrapGrpcErr(err) 247 // Do not return ErrNoConfig if an individual file is missing. First of 248 // all, it should never happen. If it does happen for some reason, we 249 // must not return ErrNoConfig anyway, because it will be interpreted 250 // as if the config set is gone, which will be incorrect. 251 if err == config.ErrNoConfig { 252 return errors.Reason("internal error: config %q at SHA256 %q is unexpectedly gone", cfg.Path, cfg.ContentSha256).Err() 253 } 254 return errors.Annotate(err, "fetching %q at SHA256 %q", cfg.Path, cfg.ContentSha256).Err() 255 } 256 257 // Ignore all metadata from `body`. It may be pointing to some other 258 // file or revision that happened to have the exact same SHA256 as the one 259 // we are requesting. We only care about the content. 260 resolved := toConfig(cfg) 261 if url := body.GetSignedUrl(); url != "" { 262 content, err := config.DownloadConfigFromSignedURL(ectx, r.httpClient, url) 263 if err != nil { 264 return errors.Annotate(err, "fetching %q from signed URL", cfg.Path).Tag(transient.Tag).Err() 265 } 266 resolved.Content = string(content) 267 } else { 268 resolved.Content = string(body.GetRawContent()) 269 } 270 271 m.Lock() 272 out[resolved.Path] = *resolved 273 m.Unlock() 274 275 return nil 276 }) 277 } 278 279 if err := eg.Wait(); err != nil { 280 return nil, err 281 } 282 return out, nil 283 } 284 285 func (r *remoteV2Impl) GetProjectConfigs(ctx context.Context, path string, metaOnly bool) ([]config.Config, error) { 286 if err := r.checkInitialized(); err != nil { 287 return nil, err 288 } 289 req := &pb.GetProjectConfigsRequest{Path: path} 290 if metaOnly { 291 req.Fields = &field_mask.FieldMask{ 292 Paths: []string{"config_set", "path", "content_sha256", "revision", "url"}, 293 } 294 } 295 296 // This rpc response is usually larger than others. So instruct the Server to 297 // return a compressed response to allow data transfer faster. 298 res, err := r.grpcClient.GetProjectConfigs(ctx, req, grpc.UseCompressor(gzip.Name)) 299 if err != nil { 300 return nil, wrapGrpcErr(err) 301 } 302 303 eg, ectx := errgroup.WithContext(ctx) 304 eg.SetLimit(8) 305 configs := make([]config.Config, len(res.Configs)) 306 for i, cfg := range res.Configs { 307 configs[i] = *toConfig(cfg) 308 if cfg.GetSignedUrl() != "" { 309 i := i 310 signedURL := cfg.GetSignedUrl() 311 eg.Go(func() error { 312 content, err := config.DownloadConfigFromSignedURL(ectx, r.httpClient, signedURL) 313 if err != nil { 314 return errors.Annotate(err, "for file(%s) in config_set(%s)", configs[i].Path, configs[i].ConfigSet).Tag(transient.Tag).Err() 315 } 316 configs[i].Content = string(content) 317 return nil 318 }) 319 } 320 } 321 322 if err := eg.Wait(); err != nil { 323 return nil, err 324 } 325 return configs, nil 326 } 327 328 func (r *remoteV2Impl) GetProjects(ctx context.Context) ([]config.Project, error) { 329 if err := r.checkInitialized(); err != nil { 330 return nil, err 331 } 332 333 res, err := r.grpcClient.ListConfigSets(ctx, &pb.ListConfigSetsRequest{Domain: pb.ListConfigSetsRequest_PROJECT}) 334 if err != nil { 335 return nil, wrapGrpcErr(err) 336 } 337 338 projects := make([]config.Project, len(res.ConfigSets)) 339 for i, cs := range res.ConfigSets { 340 projectID := config.Set(cs.Name).Project() 341 parsedURL, err := url.Parse(cs.Url) 342 if err != nil { 343 return nil, errors.Annotate(err, "failed to parse repo url %s in project %s", cs.Url, projectID).Err() 344 } 345 projects[i] = config.Project{ 346 ID: projectID, 347 Name: projectID, 348 RepoURL: parsedURL, 349 RepoType: config.GitilesRepo, 350 } 351 } 352 353 return projects, nil 354 } 355 356 func (r *remoteV2Impl) ListFiles(ctx context.Context, configSet config.Set) ([]string, error) { 357 if err := r.checkInitialized(); err != nil { 358 return nil, err 359 } 360 361 res, err := r.grpcClient.GetConfigSet(ctx, &pb.GetConfigSetRequest{ 362 ConfigSet: string(configSet), 363 Fields: &field_mask.FieldMask{ 364 Paths: []string{"configs"}, 365 }, 366 }) 367 if err != nil { 368 return nil, wrapGrpcErr(err) 369 } 370 371 paths := make([]string, len(res.Configs)) 372 for i, cfg := range res.Configs { 373 paths[i] = cfg.Path 374 } 375 sort.Strings(paths) 376 return paths, nil 377 } 378 379 func (r *remoteV2Impl) Close() error { 380 if r == nil || r.conn == nil { 381 return nil 382 } 383 return r.conn.Close() 384 } 385 386 func (r *remoteV2Impl) checkInitialized() error { 387 if r == nil || r.grpcClient == nil || r.httpClient == nil { 388 return errors.New("The Luci-config client is not initialized") 389 } 390 return nil 391 } 392 393 func wrapGrpcErr(err error) error { 394 switch code := grpcutil.Code(err); { 395 case code == codes.NotFound: 396 return config.ErrNoConfig 397 case grpcutil.IsTransientCode(code): 398 return transient.Tag.Apply(err) 399 default: 400 return err 401 } 402 } 403 404 func toConfig(configPb *pb.Config) *config.Config { 405 return &config.Config{ 406 Meta: config.Meta{ 407 ConfigSet: config.Set(configPb.ConfigSet), 408 Path: configPb.Path, 409 ContentHash: configPb.ContentSha256, 410 Revision: configPb.Revision, 411 ViewURL: configPb.Url, 412 }, 413 Content: string(configPb.GetRawContent()), 414 } 415 }