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  }