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 }