go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/rpc/get_project_configs.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 rpc
    16  
    17  import (
    18  	"context"
    19  	"net/http"
    20  	"slices"
    21  
    22  	"google.golang.org/grpc/codes"
    23  	"google.golang.org/grpc/status"
    24  
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/common/gcloud/gs"
    27  	"go.chromium.org/luci/common/logging"
    28  	"go.chromium.org/luci/common/proto/mask"
    29  	"go.chromium.org/luci/config"
    30  	"go.chromium.org/luci/gae/service/datastore"
    31  	"go.chromium.org/luci/server/auth"
    32  
    33  	"go.chromium.org/luci/config_service/internal/acl"
    34  	"go.chromium.org/luci/config_service/internal/clients"
    35  	"go.chromium.org/luci/config_service/internal/common"
    36  	"go.chromium.org/luci/config_service/internal/model"
    37  	pb "go.chromium.org/luci/config_service/proto"
    38  )
    39  
    40  // maxProjConfigsResSize limits the sum of small configs size GetProjectConfigs
    41  // rpc can return. In the current situation, it most likely won't happen. Treat
    42  // this as a defensive coding, which can:
    43  //  1. Prevent accidentally fetching hundreds of small config which causes the
    44  //     Config Server OOM.
    45  //  2. In the future, when this rpc expand to support regex files fetching, the
    46  //     response is more likely large. It should limit the response size and use
    47  //     some other ways to return the entire results (e.g pagination.)
    48  //
    49  // Note: Make it a var instead of a const to facilitate the unit test.
    50  var maxProjConfigsResSize = 200 * 1024 * 1024
    51  
    52  // GetProjectConfigs gets the specified project configs from all projects.
    53  // Implement pb.ConfigsServer.
    54  func (c Configs) GetProjectConfigs(ctx context.Context, req *pb.GetProjectConfigsRequest) (*pb.GetProjectConfigsResponse, error) {
    55  	if err := validatePath(req.GetPath()); err != nil {
    56  		return nil, status.Errorf(codes.InvalidArgument, "invalid path - %q: %s", req.GetPath(), err)
    57  	}
    58  	m, err := toConfigMask(req.Fields)
    59  	if err != nil {
    60  		return nil, status.Errorf(codes.InvalidArgument, "invalid fields mask: %s", err)
    61  	}
    62  
    63  	var files []*model.File
    64  	// Query all config sets which start with "projects/". Use
    65  	// Lt("__key__", string(config.ProjectDomain)+"0"),  because '0' is the next
    66  	// char of "/" in ASCII table.
    67  	// The query only fetches keys with `latest_revision.id` field.
    68  	query := datastore.NewQuery(model.ConfigSetKind).
    69  		Project("latest_revision.id").
    70  		Gt("__key__", datastore.MakeKey(ctx, model.ConfigSetKind, string(config.ProjectDomain)+"/")).
    71  		Lt("__key__", datastore.MakeKey(ctx, model.ConfigSetKind, string(config.ProjectDomain)+"0"))
    72  	err = datastore.Run(ctx, query, func(cs *model.ConfigSet) error {
    73  		switch hasPerm, err := acl.CanReadConfigSet(ctx, cs.ID); {
    74  		case err != nil:
    75  			logging.Errorf(ctx, "cannot check %q read access for %q: %s", cs.ID, auth.CurrentIdentity(ctx), err)
    76  			return err
    77  		case hasPerm:
    78  			files = append(files, &model.File{
    79  				Path:     req.Path,
    80  				Revision: datastore.MakeKey(ctx, model.ConfigSetKind, string(cs.ID), model.RevisionKind, cs.LatestRevision.ID),
    81  			})
    82  		}
    83  		return nil
    84  	})
    85  	if err != nil {
    86  		logging.Errorf(ctx, "error when querying project config sets: %s", err)
    87  		return nil, status.Errorf(codes.Internal, "error while fetching project configs")
    88  	}
    89  
    90  	// Get latest revision files from Datastore.
    91  	if err := errors.Filter(datastore.Get(ctx, files), datastore.ErrNoSuchEntity); err != nil {
    92  		logging.Errorf(ctx, "failed to fetch config files from Datastore: %s", err)
    93  		return nil, status.Errorf(codes.Internal, "error while fetching project configs")
    94  	}
    95  	var configs []*pb.Config
    96  	totalSize := 0
    97  	// Sort the files from smallest to largest so that the response will include
    98  	// as many smaller configs as possible.
    99  	slices.SortFunc(files, func(a, b *model.File) int {
   100  		return int(a.Size - b.Size)
   101  	})
   102  	for _, f := range files {
   103  		cfgPb := &pb.Config{
   104  			ConfigSet:     f.Revision.Root().StringID(),
   105  			Path:          f.Path,
   106  			ContentSha256: f.ContentSHA256,
   107  			Size:          f.Size,
   108  			Revision:      f.Revision.StringID(),
   109  			Url:           common.GitilesURL(f.Location.GetGitilesLocation()),
   110  		}
   111  		totalSize += int(f.Size)
   112  		switch {
   113  		case len(f.Content) == 0 && f.GcsURI == "":
   114  			// This file is not found.
   115  			continue
   116  		case len(f.Content) != 0 && f.Size < int64(maxRawContentSize) && totalSize < maxProjConfigsResSize && m.MustIncludes("raw_content") != mask.Exclude:
   117  			rawContent, err := f.GetRawContent(ctx)
   118  			if err != nil {
   119  				logging.Errorf(ctx, "failed to get the raw content of the config for %s-%s: %s", cfgPb.ConfigSet, cfgPb.Path, err)
   120  				return nil, status.Errorf(codes.Internal, "error while getting raw content")
   121  			}
   122  			cfgPb.Content = &pb.Config_RawContent{RawContent: rawContent}
   123  		case f.GcsURI != "" && m.MustIncludes("signed_url") != mask.Exclude:
   124  			urls, err := common.CreateSignedURLs(ctx, clients.GetGsClient(ctx), []gs.Path{f.GcsURI}, http.MethodGet, nil)
   125  			if err != nil {
   126  				logging.Errorf(ctx, "cannot generate a signed url for GCS object %s: %s", f.GcsURI, err)
   127  				return nil, status.Errorf(codes.Internal, "error while generating the config signed url")
   128  			}
   129  			cfgPb.Content = &pb.Config_SignedUrl{SignedUrl: urls[0]}
   130  		}
   131  
   132  		// Trim config proto by the Fields mask.
   133  		if err := m.Trim(cfgPb); err != nil {
   134  			logging.Errorf(ctx, "cannot trim the config proto in config set %q: %s", cfgPb.ConfigSet, err)
   135  			return nil, status.Errorf(codes.Internal, "error while constructing the response")
   136  		}
   137  		configs = append(configs, cfgPb)
   138  	}
   139  	return &pb.GetProjectConfigsResponse{Configs: configs}, nil
   140  }