blob: bdae5d02d6e43eb3f2351d0ee95c03bda911162b [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package rpc
import (
"context"
"encoding/base64"
"encoding/json"
"sort"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/auth/identity"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/caching/layered"
"go.chromium.org/luci/milo/internal/buildsource/buildbucket"
"go.chromium.org/luci/milo/internal/projectconfig"
"go.chromium.org/luci/milo/internal/utils"
milopb "go.chromium.org/luci/milo/proto/v1"
)
const (
// Keep the builders in cache for 10 mins to speed up repeated page loads and
// reduce stress on buildbucket side.
// But this also means newly added/removed builders would take 10 mins to
// propagate.
// Cache duration can be adjusted if needed.
builderCacheDuration = 10 * time.Minute
// Refresh the builders cache if the cache TTL falls below this threshold.
builderCacheRefreshThreshold = builderCacheDuration - time.Minute
)
var listBuildersPageSize = PageSizeLimiter{
Max: 10000,
Default: 100,
}
var buildbucketBuildersCache = layered.RegisterCache(layered.Parameters[[]*buildbucketpb.BuilderID]{
ProcessCacheCapacity: 64,
GlobalNamespace: "buildbucket-builders-v4",
Marshal: func(item []*buildbucketpb.BuilderID) ([]byte, error) {
return json.Marshal(item)
},
Unmarshal: func(blob []byte) ([]*buildbucketpb.BuilderID, error) {
res := make([]*buildbucketpb.BuilderID, 0)
err := json.Unmarshal(blob, &res)
return res, err
},
})
// ListBuilders implements milopb.MiloInternal service
func (s *MiloInternalService) ListBuilders(ctx context.Context, req *milopb.ListBuildersRequest) (_ *milopb.ListBuildersResponse, err error) {
// Validate request.
err = validateListBuildersRequest(req)
if err != nil {
return nil, appstatus.BadRequest(err)
}
// Validate and get page token.
pageToken, err := validateListBuildersPageToken(req)
if err != nil {
return nil, appstatus.BadRequest(err)
}
// Perform ACL check when the project is specified.
if req.Project != "" {
allowed, err := projectconfig.IsAllowed(ctx, req.Project)
if err != nil {
return nil, err
}
if !allowed {
if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity {
return nil, appstatus.Error(codes.Unauthenticated, "not logged in")
}
return nil, appstatus.Error(codes.PermissionDenied, "no access to the project")
}
}
pageSize := int(listBuildersPageSize.Adjust(req.PageSize))
if req.Group == "" {
return s.listProjectBuilders(ctx, req.Project, pageSize, pageToken)
}
return s.listGroupBuilders(ctx, req.Project, req.Group, pageSize, pageToken)
}
func (s *MiloInternalService) listProjectBuilders(ctx context.Context, project string, pageSize int, pageToken *milopb.ListBuildersPageToken) (_ *milopb.ListBuildersResponse, err error) {
res := &milopb.ListBuildersResponse{
Builders: make([]*buildbucketpb.BuilderItem, 0, pageSize),
}
// First, query buildbucket and return all builders defined in the project.
if pageToken == nil || pageToken.NextBuildbucketBuilderIndex != 0 {
builders, err := s.GetAllVisibleBuilders(ctx, project)
if err != nil {
return nil, err
}
pageStart := int(pageToken.GetNextBuildbucketBuilderIndex())
pageEnd := pageStart + pageSize
if pageEnd > len(builders) {
pageEnd = len(builders)
}
for _, builder := range builders[pageStart:pageEnd] {
res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
Id: builder,
})
}
// If there are more internal builders, populate `res.NextPageToken` and
// return.
if len(builders) > pageEnd {
nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
NextBuildbucketBuilderIndex: int32(pageEnd),
})
if err != nil {
return nil, err
}
res.NextPageToken = nextPageToken
}
}
if project == "" {
return res, nil
}
// Then, return external builders referenced in the project consoles.
remaining := pageSize - len(res.Builders)
if remaining > 0 {
project := &projectconfig.Project{ID: project}
if err := datastore.Get(ctx, project); err != nil {
return nil, err
}
externalBuilders := make([]*buildbucketpb.BuilderID, len(project.ExternalBuilderIDs))
for i, externalBuilderID := range project.ExternalBuilderIDs {
externalBuilders[i], err = utils.ParseBuilderID(externalBuilderID)
if err != nil {
return nil, err
}
}
externalBuilders, err = buildbucket.FilterVisibleBuilders(ctx, externalBuilders, "")
if err != nil {
return nil, err
}
pageStart := int(pageToken.GetNextMiloBuilderIndex())
pageEnd := pageStart + remaining
if pageEnd > len(externalBuilders) {
pageEnd = len(externalBuilders)
}
for _, builder := range externalBuilders[pageStart:pageEnd] {
res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
Id: builder,
})
}
if len(externalBuilders) > pageEnd {
nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
NextMiloBuilderIndex: int32(pageEnd),
})
if err != nil {
return nil, err
}
res.NextPageToken = nextPageToken
}
}
return res, nil
}
func (s *MiloInternalService) listGroupBuilders(ctx context.Context, project string, group string, pageSize int, pageToken *milopb.ListBuildersPageToken) (_ *milopb.ListBuildersResponse, err error) {
res := &milopb.ListBuildersResponse{}
projKey := datastore.MakeKey(ctx, "Project", project)
con := projectconfig.Console{Parent: projKey, ID: group}
switch err := datastore.Get(ctx, &con); err {
case nil:
case datastore.ErrNoSuchEntity:
return nil, appstatus.Error(codes.NotFound, "group not found")
default:
return nil, errors.Annotate(err, "error getting console %s in project %s", group, project).Err()
}
// Sort con.Builders. with Internal builders come before external builders.
internalBuilderIDPrefix := "buildbucket/luci." + project + "."
sort.Slice(con.Builders, func(i, j int) bool {
builder1InternalFlag := 1
builder2InternalFlag := 1
if strings.HasPrefix(con.Builders[i], internalBuilderIDPrefix) {
builder1InternalFlag = 0
}
if strings.HasPrefix(con.Builders[j], internalBuilderIDPrefix) {
builder2InternalFlag = 0
}
if builder1InternalFlag != builder2InternalFlag {
return builder1InternalFlag < builder2InternalFlag
}
return con.Builders[i] < con.Builders[j]
})
builders := make([]*buildbucketpb.BuilderID, len(con.Builders))
for i, bid := range con.Builders {
builders[i], err = utils.ParseLegacyBuilderID(bid)
if err != nil {
return nil, err
}
}
builders, err = buildbucket.FilterVisibleBuilders(ctx, builders, "")
if err != nil {
return nil, err
}
pageStart := int(pageToken.GetNextMiloBuilderIndex())
pageEnd := pageStart + pageSize
if pageEnd > len(builders) {
pageEnd = len(builders)
}
for _, builder := range builders[pageStart:pageEnd] {
res.Builders = append(res.Builders, &buildbucketpb.BuilderItem{
Id: builder,
})
}
if len(builders) > pageEnd {
nextPageToken, err := serializeListBuildersPageToken(&milopb.ListBuildersPageToken{
NextMiloBuilderIndex: int32(pageEnd),
})
if err != nil {
return nil, err
}
res.NextPageToken = nextPageToken
}
return res, nil
}
func validateListBuildersRequest(req *milopb.ListBuildersRequest) error {
switch {
case req.PageSize < 0:
return errors.Reason("page_size can not be negative").Err()
case req.Project == "" && req.Group != "":
return errors.Reason("project is required when group is specified").Err()
default:
return nil
}
}
func validateListBuildersPageToken(req *milopb.ListBuildersRequest) (*milopb.ListBuildersPageToken, error) {
if req.PageToken == "" {
return nil, nil
}
token, err := parseListBuildersPageToken(req.PageToken)
if err != nil {
return nil, errors.Annotate(err, "unable to parse page_token").Err()
}
// Should not have NextBuildbucketPageToken and NextMiloBuilderIndex at the
// same time.
if token.NextBuildbucketBuilderIndex != 0 && token.NextMiloBuilderIndex != 0 {
return nil, errors.Reason("invalid page_token").Err()
}
// NextBuildbucketPageToken should only be defined when listing all builders
// in the project.
if req.Group != "" && token.NextBuildbucketBuilderIndex != 0 {
return nil, errors.Reason("invalid page_token").Err()
}
return token, nil
}
func parseListBuildersPageToken(tokenStr string) (token *milopb.ListBuildersPageToken, err error) {
bytes, err := base64.RawStdEncoding.DecodeString(tokenStr)
if err != nil {
return nil, err
}
token = &milopb.ListBuildersPageToken{}
err = proto.Unmarshal(bytes, token)
return
}
func serializeListBuildersPageToken(token *milopb.ListBuildersPageToken) (string, error) {
bytes, err := proto.Marshal(token)
return base64.RawStdEncoding.EncodeToString(bytes), err
}
// GetAllVisibleBuilders returns all cached buildbucket builders. If the cache expired,
// refresh it with Milo's credential.
func (s *MiloInternalService) GetAllVisibleBuilders(c context.Context, project string, opt ...layered.Option) ([]*buildbucketpb.BuilderID, error) {
settings, err := s.GetSettings(c)
if err != nil {
return nil, err
}
host := settings.GetBuildbucket().GetHost()
if host == "" {
return nil, errors.New("buildbucket host is missing in config")
}
builders, err := buildbucketBuildersCache.GetOrCreate(c, host, func() (v []*buildbucketpb.BuilderID, exp time.Duration, err error) {
start := time.Now()
buildersClient, err := s.GetBuildersClient(c, host, auth.AsSelf)
if err != nil {
return nil, 0, err
}
// Get all the Builder IDs from buildbucket.
bids := make([]*buildbucketpb.BuilderID, 0)
req := &buildbucketpb.ListBuildersRequest{PageSize: 1000}
for {
r, err := buildersClient.ListBuilders(c, req)
if err != nil {
return nil, 0, err
}
for _, builder := range r.Builders {
bids = append(bids, builder.Id)
}
if r.NextPageToken == "" {
break
}
req.PageToken = r.NextPageToken
}
logging.Infof(c, "listing all builders from buildbucket took %v", time.Since(start))
return bids, builderCacheDuration, nil
}, opt...)
if err != nil {
return nil, err
}
return buildbucket.FilterVisibleBuilders(c, builders, project)
}
// UpdateBuilderCache updates the builders cache if the cache TTL falls below
// builderCacheRefreshThreshold.
func (s *MiloInternalService) UpdateBuilderCache(c context.Context) error {
_, err := s.GetAllVisibleBuilders(c, "", layered.WithMinTTL(builderCacheRefreshThreshold))
return err
}