go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/rpc/list_builders.go (about)

     1  // Copyright 2021 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  	"encoding/base64"
    20  	"encoding/json"
    21  	"sort"
    22  	"strings"
    23  	"time"
    24  
    25  	"go.chromium.org/luci/auth/identity"
    26  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    27  	"go.chromium.org/luci/common/errors"
    28  	"go.chromium.org/luci/common/logging"
    29  	"go.chromium.org/luci/gae/service/datastore"
    30  	"go.chromium.org/luci/grpc/appstatus"
    31  	"go.chromium.org/luci/milo/internal/buildsource/buildbucket"
    32  	"go.chromium.org/luci/milo/internal/projectconfig"
    33  	"go.chromium.org/luci/milo/internal/utils"
    34  	milopb "go.chromium.org/luci/milo/proto/v1"
    35  	"go.chromium.org/luci/server/auth"
    36  	"go.chromium.org/luci/server/caching/layered"
    37  	"google.golang.org/grpc/codes"
    38  	"google.golang.org/protobuf/proto"
    39  )
    40  
    41  const (
    42  	// Keep the builders in cache for 10 mins to speed up repeated page loads and
    43  	// reduce stress on buildbucket side.
    44  	// But this also means newly added/removed builders would take 10 mins to
    45  	// propagate.
    46  	// Cache duration can be adjusted if needed.
    47  	builderCacheDuration = 10 * time.Minute
    48  
    49  	// Refresh the builders cache if the cache TTL falls below this threshold.
    50  	builderCacheRefreshThreshold = builderCacheDuration - time.Minute
    51  )
    52  
    53  var listBuildersPageSize = PageSizeLimiter{
    54  	Max:     10000,
    55  	Default: 100,
    56  }
    57  
    58  var buildbucketBuildersCache = layered.RegisterCache(layered.Parameters[[]*buildbucketpb.BuilderID]{
    59  	ProcessCacheCapacity: 64,
    60  	GlobalNamespace:      "buildbucket-builders-v4",
    61  	Marshal: func(item []*buildbucketpb.BuilderID) ([]byte, error) {
    62  		return json.Marshal(item)
    63  	},
    64  	Unmarshal: func(blob []byte) ([]*buildbucketpb.BuilderID, error) {
    65  		res := make([]*buildbucketpb.BuilderID, 0)
    66  		err := json.Unmarshal(blob, &res)
    67  		return res, err
    68  	},
    69  })
    70  
    71  // ListBuilders implements milopb.MiloInternal service
    72  func (s *MiloInternalService) ListBuilders(ctx context.Context, req *milopb.ListBuildersRequest) (_ *milopb.ListBuildersResponse, err error) {
    73  	// Validate request.
    74  	err = validateListBuildersRequest(req)
    75  	if err != nil {
    76  		return nil, appstatus.BadRequest(err)
    77  	}
    78  
    79  	// Validate and get page token.
    80  	pageToken, err := validateListBuildersPageToken(req)
    81  	if err != nil {
    82  		return nil, appstatus.BadRequest(err)
    83  	}
    84  
    85  	// Perform ACL check when the project is specified.
    86  	if req.Project != "" {
    87  		allowed, err := projectconfig.IsAllowed(ctx, req.Project)
    88  		if err != nil {
    89  			return nil, err
    90  		}
    91  		if !allowed {
    92  			if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity {
    93  				return nil, appstatus.Error(codes.Unauthenticated, "not logged in")
    94  			}
    95  			return nil, appstatus.Error(codes.PermissionDenied, "no access to the project")
    96  		}
    97  	}
    98  
    99  	pageSize := int(listBuildersPageSize.Adjust(req.PageSize))
   100  
   101  	if req.Group == "" {
   102  		return s.listProjectBuilders(ctx, req.Project, pageSize, pageToken)
   103  	}
   104  	return s.listGroupBuilders(ctx, req.Project, req.Group, pageSize, pageToken)
   105  }
   106  
   107  func (s *MiloInternalService) listProjectBuilders(ctx context.Context, project string, pageSize int, pageToken *milopb.ListBuildersPageToken) (_ *milopb.ListBuildersResponse, err error) {
   108  	res := &milopb.ListBuildersResponse{
   109  		Builders: make([]*buildbucketpb.BuilderItem, 0, pageSize),
   110  	}
   111  
   112  	// First, query buildbucket and return all builders defined in the project.
   113  	if pageToken == nil || pageToken.NextBuildbucketBuilderIndex != 0 {
   114  		builders, err := s.GetAllVisibleBuilders(ctx, project)
   115  		if err != nil {
   116  			return nil, err
   117  		}
   118  
   119  		pageStart := int(pageToken.GetNextBuildbucketBuilderIndex())
   120  		pageEnd := pageStart + pageSize
   121  		if pageEnd > len(builders) {
   122  			pageEnd = len(builders)
   123  		}
   124  
   125  		for _, builder := range builders[pageStart:pageEnd] {
   126  			res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
   127  				Id: builder,
   128  			})
   129  		}
   130  
   131  		// If there are more internal builders, populate `res.NextPageToken` and
   132  		// return.
   133  		if len(builders) > pageEnd {
   134  			nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
   135  				NextBuildbucketBuilderIndex: int32(pageEnd),
   136  			})
   137  			if err != nil {
   138  				return nil, err
   139  			}
   140  			res.NextPageToken = nextPageToken
   141  		}
   142  	}
   143  
   144  	if project == "" {
   145  		return res, nil
   146  	}
   147  
   148  	// Then, return external builders referenced in the project consoles.
   149  	remaining := pageSize - len(res.Builders)
   150  	if remaining > 0 {
   151  		project := &projectconfig.Project{ID: project}
   152  		if err := datastore.Get(ctx, project); err != nil {
   153  			return nil, err
   154  		}
   155  
   156  		externalBuilders := make([]*buildbucketpb.BuilderID, len(project.ExternalBuilderIDs))
   157  		for i, externalBuilderID := range project.ExternalBuilderIDs {
   158  			externalBuilders[i], err = utils.ParseBuilderID(externalBuilderID)
   159  			if err != nil {
   160  				return nil, err
   161  			}
   162  		}
   163  
   164  		externalBuilders, err = buildbucket.FilterVisibleBuilders(ctx, externalBuilders, "")
   165  		if err != nil {
   166  			return nil, err
   167  		}
   168  
   169  		pageStart := int(pageToken.GetNextMiloBuilderIndex())
   170  		pageEnd := pageStart + remaining
   171  		if pageEnd > len(externalBuilders) {
   172  			pageEnd = len(externalBuilders)
   173  		}
   174  
   175  		for _, builder := range externalBuilders[pageStart:pageEnd] {
   176  			res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
   177  				Id: builder,
   178  			})
   179  		}
   180  
   181  		if len(externalBuilders) > pageEnd {
   182  			nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
   183  				NextMiloBuilderIndex: int32(pageEnd),
   184  			})
   185  			if err != nil {
   186  				return nil, err
   187  			}
   188  
   189  			res.NextPageToken = nextPageToken
   190  		}
   191  	}
   192  
   193  	return res, nil
   194  }
   195  
   196  func (s *MiloInternalService) listGroupBuilders(ctx context.Context, project string, group string, pageSize int, pageToken *milopb.ListBuildersPageToken) (_ *milopb.ListBuildersResponse, err error) {
   197  	res := &milopb.ListBuildersResponse{}
   198  
   199  	projKey := datastore.MakeKey(ctx, "Project", project)
   200  	con := projectconfig.Console{Parent: projKey, ID: group}
   201  	switch err := datastore.Get(ctx, &con); err {
   202  	case nil:
   203  	case datastore.ErrNoSuchEntity:
   204  		return nil, appstatus.Error(codes.NotFound, "group not found")
   205  	default:
   206  		return nil, errors.Annotate(err, "error getting console %s in project %s", group, project).Err()
   207  	}
   208  
   209  	// Sort con.Builders. with Internal builders come before external builders.
   210  	internalBuilderIDPrefix := "buildbucket/luci." + project + "."
   211  	sort.Slice(con.Builders, func(i, j int) bool {
   212  		builder1InternalFlag := 1
   213  		builder2InternalFlag := 1
   214  		if strings.HasPrefix(con.Builders[i], internalBuilderIDPrefix) {
   215  			builder1InternalFlag = 0
   216  		}
   217  		if strings.HasPrefix(con.Builders[j], internalBuilderIDPrefix) {
   218  			builder2InternalFlag = 0
   219  		}
   220  
   221  		if builder1InternalFlag != builder2InternalFlag {
   222  			return builder1InternalFlag < builder2InternalFlag
   223  		}
   224  
   225  		return con.Builders[i] < con.Builders[j]
   226  	})
   227  
   228  	builders := make([]*buildbucketpb.BuilderID, len(con.Builders))
   229  	for i, bid := range con.Builders {
   230  		builders[i], err = utils.ParseLegacyBuilderID(bid)
   231  		if err != nil {
   232  			return nil, err
   233  		}
   234  	}
   235  
   236  	builders, err = buildbucket.FilterVisibleBuilders(ctx, builders, "")
   237  	if err != nil {
   238  		return nil, err
   239  	}
   240  	pageStart := int(pageToken.GetNextMiloBuilderIndex())
   241  	pageEnd := pageStart + pageSize
   242  	if pageEnd > len(builders) {
   243  		pageEnd = len(builders)
   244  	}
   245  
   246  	for _, builder := range builders[pageStart:pageEnd] {
   247  		res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
   248  			Id: builder,
   249  		})
   250  	}
   251  
   252  	if len(builders) > pageEnd {
   253  		nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
   254  			NextMiloBuilderIndex: int32(pageEnd),
   255  		})
   256  		if err != nil {
   257  			return nil, err
   258  		}
   259  		res.NextPageToken = nextPageToken
   260  	}
   261  
   262  	return res, nil
   263  }
   264  
   265  func validateListBuildersRequest(req *milopb.ListBuildersRequest) error {
   266  	switch {
   267  	case req.PageSize < 0:
   268  		return errors.Reason("page_size can not be negative").Err()
   269  	case req.Project == "" && req.Group != "":
   270  		return errors.Reason("project is required when group is specified").Err()
   271  	default:
   272  		return nil
   273  	}
   274  }
   275  
   276  func validateListBuildersPageToken(req *milopb.ListBuildersRequest) (*milopb.ListBuildersPageToken, error) {
   277  	if req.PageToken == "" {
   278  		return nil, nil
   279  	}
   280  
   281  	token, err := parseListBuildersPageToken(req.PageToken)
   282  	if err != nil {
   283  		return nil, errors.Annotate(err, "unable to parse page_token").Err()
   284  	}
   285  
   286  	// Should not have NextBuildbucketPageToken and NextMiloBuilderIndex at the
   287  	// same time.
   288  	if token.NextBuildbucketBuilderIndex != 0 && token.NextMiloBuilderIndex != 0 {
   289  		return nil, errors.Reason("invalid page_token").Err()
   290  	}
   291  
   292  	// NextBuildbucketPageToken should only be defined when listing all builders
   293  	// in the project.
   294  	if req.Group != "" && token.NextBuildbucketBuilderIndex != 0 {
   295  		return nil, errors.Reason("invalid page_token").Err()
   296  	}
   297  
   298  	return token, nil
   299  }
   300  
   301  func parseListBuildersPageToken(tokenStr string) (token *milopb.ListBuildersPageToken, err error) {
   302  	bytes, err := base64.RawStdEncoding.DecodeString(tokenStr)
   303  	if err != nil {
   304  		return nil, err
   305  	}
   306  	token = &milopb.ListBuildersPageToken{}
   307  	err = proto.Unmarshal(bytes, token)
   308  	return
   309  }
   310  
   311  func serializeListBuildersPageToken(token *milopb.ListBuildersPageToken) (string, error) {
   312  	bytes, err := proto.Marshal(token)
   313  	return base64.RawStdEncoding.EncodeToString(bytes), err
   314  }
   315  
   316  // GetAllVisibleBuilders returns all cached buildbucket builders. If the cache expired,
   317  // refresh it with Milo's credential.
   318  func (s *MiloInternalService) GetAllVisibleBuilders(c context.Context, project string, opt ...layered.Option) ([]*buildbucketpb.BuilderID, error) {
   319  	settings, err := s.GetSettings(c)
   320  	if err != nil {
   321  		return nil, err
   322  	}
   323  	host := settings.GetBuildbucket().GetHost()
   324  	if host == "" {
   325  		return nil, errors.New("buildbucket host is missing in config")
   326  	}
   327  
   328  	builders, err := buildbucketBuildersCache.GetOrCreate(c, host, func() (v []*buildbucketpb.BuilderID, exp time.Duration, err error) {
   329  		start := time.Now()
   330  
   331  		buildersClient, err := s.GetBuildersClient(c, host, auth.AsSelf)
   332  		if err != nil {
   333  			return nil, 0, err
   334  		}
   335  
   336  		// Get all the Builder IDs from buildbucket.
   337  		bids := make([]*buildbucketpb.BuilderID, 0)
   338  		req := &buildbucketpb.ListBuildersRequest{PageSize: 1000}
   339  		for {
   340  			r, err := buildersClient.ListBuilders(c, req)
   341  			if err != nil {
   342  				return nil, 0, err
   343  			}
   344  
   345  			for _, builder := range r.Builders {
   346  				bids = append(bids, builder.Id)
   347  			}
   348  
   349  			if r.NextPageToken == "" {
   350  				break
   351  			}
   352  			req.PageToken = r.NextPageToken
   353  		}
   354  
   355  		logging.Infof(c, "listing all builders from buildbucket took %v", time.Since(start))
   356  
   357  		return bids, builderCacheDuration, nil
   358  	}, opt...)
   359  	if err != nil {
   360  		return nil, err
   361  	}
   362  
   363  	return buildbucket.FilterVisibleBuilders(c, builders, project)
   364  }
   365  
   366  // UpdateBuilderCache updates the builders cache if the cache TTL falls below
   367  // builderCacheRefreshThreshold.
   368  func (s *MiloInternalService) UpdateBuilderCache(c context.Context) error {
   369  	_, err := s.GetAllVisibleBuilders(c, "", layered.WithMinTTL(builderCacheRefreshThreshold))
   370  	return err
   371  }