go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/buildbucket/fake/client.go (about) 1 // Copyright 2022 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 bbfake 16 17 import ( 18 "context" 19 "fmt" 20 "sort" 21 "strconv" 22 "strings" 23 24 "google.golang.org/grpc" 25 "google.golang.org/grpc/codes" 26 "google.golang.org/grpc/status" 27 28 "google.golang.org/protobuf/encoding/protojson" 29 "google.golang.org/protobuf/proto" 30 "google.golang.org/protobuf/reflect/protoreflect" 31 "google.golang.org/protobuf/types/known/structpb" 32 "google.golang.org/protobuf/types/known/timestamppb" 33 34 "go.chromium.org/luci/auth/identity" 35 "go.chromium.org/luci/buildbucket/appengine/model" 36 bbpb "go.chromium.org/luci/buildbucket/proto" 37 bbutil "go.chromium.org/luci/buildbucket/protoutil" 38 "go.chromium.org/luci/common/clock" 39 "go.chromium.org/luci/common/data/stringset" 40 41 "go.chromium.org/luci/cv/internal/buildbucket" 42 ) 43 44 type clientFactory struct { 45 fake *Fake 46 } 47 48 // MakeClient implements buildbucket.ClientFactory. 49 func (factory clientFactory) MakeClient(ctx context.Context, host, luciProject string) (buildbucket.Client, error) { 50 return &Client{ 51 fa: factory.fake.ensureApp(host), 52 luciProject: luciProject, 53 }, nil 54 } 55 56 // Client connects a Buildbucket Fake and scope to a certain LUCI Project + 57 // Buildbucket host. 58 type Client struct { 59 fa *fakeApp 60 luciProject string 61 } 62 63 // GetBuild implements buildbucket.Client. 64 func (c *Client) GetBuild(ctx context.Context, in *bbpb.GetBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) { 65 switch { 66 case in.GetBuilder() != nil || in.GetBuildNumber() != 0: 67 return nil, status.Errorf(codes.Unimplemented, "GetBuild by builder+number is not supported") 68 case in.GetId() == 0: 69 return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0") 70 } 71 72 switch build := c.fa.getBuild(in.GetId()); { 73 case build == nil: 74 fallthrough 75 case !c.canAccessBuild(build): 76 projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject)) 77 return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to view it", projIdentity) 78 default: 79 if err := applyMask(build, in.GetMask()); err != nil { 80 return nil, err 81 } 82 return build, nil 83 } 84 } 85 86 var supportedPredicates = stringset.NewFromSlice( 87 "gerrit_changes", 88 "include_experimental", 89 ) 90 91 const defaultSearchPageSize = 5 92 93 // SearchBuilds implements buildbucket.Client. 94 // 95 // Support paging and the following predicates: 96 // - gerrit_changes 97 // - include_experimental 98 // 99 // Use `defaultSearchPageSize` if page size is not specified in the input. 100 func (c *Client) SearchBuilds(ctx context.Context, in *bbpb.SearchBuildsRequest, opts ...grpc.CallOption) (*bbpb.SearchBuildsResponse, error) { 101 if in.GetPredicate() != nil { 102 var notSupportedPredicates []string 103 in.GetPredicate().ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { 104 if v.IsValid() && !supportedPredicates.Has(string(fd.Name())) { 105 notSupportedPredicates = append(notSupportedPredicates, string(fd.Name())) 106 } 107 return true 108 }) 109 if len(notSupportedPredicates) > 0 { 110 return nil, status.Errorf(codes.InvalidArgument, "predicates [%s] are not supported", strings.Join(notSupportedPredicates, ", ")) 111 } 112 } 113 var lastReturnedBuildID int64 114 if token := in.GetPageToken(); token != "" { 115 var err error 116 lastReturnedBuildID, err = strconv.ParseInt(token, 10, 64) 117 if err != nil { 118 return nil, status.Errorf(codes.InvalidArgument, "invalid token %q, expecting a build ID", token) 119 } 120 } 121 var candidates []*bbpb.Build 122 c.fa.iterBuildStore(func(build *bbpb.Build) { 123 candidates = append(candidates, build) 124 }) 125 sort.Slice(candidates, func(i, j int) bool { 126 return candidates[i].Id < candidates[j].Id 127 }) 128 pageSize := in.GetPageSize() 129 if pageSize == 0 { 130 pageSize = defaultSearchPageSize 131 } 132 resBuilds := make([]*bbpb.Build, 0, pageSize) 133 for _, b := range candidates { 134 if c.shouldIncludeBuild(b, in.GetPredicate(), lastReturnedBuildID) { 135 if err := applyMask(b, in.GetMask()); err != nil { 136 return nil, err 137 } 138 resBuilds = append(resBuilds, b) 139 if len(resBuilds) == int(pageSize) { 140 return &bbpb.SearchBuildsResponse{ 141 Builds: resBuilds, 142 NextPageToken: strconv.FormatInt(b.Id, 10), 143 }, nil 144 } 145 } 146 } 147 return &bbpb.SearchBuildsResponse{Builds: resBuilds}, nil 148 } 149 150 func (c *Client) shouldIncludeBuild(b *bbpb.Build, pred *bbpb.BuildPredicate, lastReturnedBuildID int64) bool { 151 switch { 152 case b.GetId() <= lastReturnedBuildID: 153 return false 154 case !c.canAccessBuild(b): 155 return false 156 case !pred.GetIncludeExperimental() && b.GetInput().GetExperimental(): 157 return false 158 case len(pred.GetGerritChanges()) > 0: 159 gcs := stringset.New(len(b.GetInput().GetGerritChanges())) 160 for _, gc := range b.GetInput().GetGerritChanges() { 161 gcs.Add(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset())) 162 } 163 for _, gc := range pred.GetGerritChanges() { 164 if !gcs.Has(fmt.Sprintf("%s/%s/%d/%d", gc.GetHost(), gc.GetProject(), gc.GetChange(), gc.GetPatchset())) { 165 return false 166 } 167 } 168 } 169 return true 170 } 171 172 // CancelBuild implements buildbucket.Client. 173 func (c *Client) CancelBuild(ctx context.Context, in *bbpb.CancelBuildRequest, opts ...grpc.CallOption) (*bbpb.Build, error) { 174 if in.GetId() == 0 { 175 return nil, status.Errorf(codes.InvalidArgument, "requested build id is 0") 176 } 177 var noAccess bool 178 var updatedBuild *bbpb.Build 179 if build := c.fa.getBuild(in.GetId()); build == nil { 180 noAccess = true 181 } else { 182 updatedBuild = c.fa.updateBuild(ctx, in.GetId(), func(build *bbpb.Build) { 183 switch { 184 case !c.canAccessBuild(build): 185 noAccess = true 186 case bbutil.IsEnded(build.GetStatus()): 187 // noop on ended build 188 default: 189 build.Status = bbpb.Status_CANCELED 190 now := timestamppb.New(clock.Now(ctx).UTC()) 191 if build.GetStartTime() == nil { 192 build.StartTime = now 193 } 194 build.EndTime = now 195 build.UpdateTime = now 196 build.SummaryMarkdown = in.GetSummaryMarkdown() 197 } 198 }) 199 } 200 201 if noAccess { 202 projIdentity := identity.Identity(fmt.Sprintf("%s:%s", identity.Project, c.luciProject)) 203 return nil, status.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to modify it", projIdentity) 204 } 205 206 if err := applyMask(updatedBuild, in.GetMask()); err != nil { 207 return nil, err 208 } 209 return updatedBuild, nil 210 } 211 212 var supportedScheduleArguments = stringset.NewFromSlice( 213 "request_id", 214 "builder", 215 "properties", 216 "gerrit_changes", 217 "tags", 218 "experiments", 219 "mask", 220 ) 221 222 // ScheduleBuild schedules a new build for the provided builder. 223 // 224 // The builder should be present in buildbucket fake. It can be added via 225 // AddBuilder function. 226 func (c *Client) ScheduleBuild(ctx context.Context, in *bbpb.ScheduleBuildRequest) (*bbpb.Build, error) { 227 var notSupportedArguments []string 228 in.ProtoReflect().Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { 229 if v.IsValid() && !supportedScheduleArguments.Has(string(fd.Name())) { 230 notSupportedArguments = append(notSupportedArguments, string(fd.Name())) 231 } 232 return true 233 }) 234 if len(notSupportedArguments) > 0 { 235 return nil, status.Errorf(codes.InvalidArgument, "schedule arguments [%s] are not supported", strings.Join(notSupportedArguments, ", ")) 236 } 237 238 if build := c.fa.findDupRequest(ctx, in.GetRequestId()); build != nil { 239 if err := applyMask(build, in.GetMask()); err != nil { 240 return nil, err 241 } 242 return build, nil 243 } 244 245 builderID := in.GetBuilder() 246 if builderID == nil { 247 return nil, status.Errorf(codes.InvalidArgument, "requested builder is empty") 248 } 249 builderCfg := c.fa.loadBuilderCfg(builderID) 250 if builderCfg == nil { 251 return nil, status.Errorf(codes.NotFound, "builder %s not found", bbutil.FormatBuilderID(builderID)) 252 } 253 inputProps, err := mkInputProps(builderCfg, in.GetProperties()) 254 if err != nil { 255 return nil, err 256 } 257 now := timestamppb.New(clock.Now(ctx)) 258 build := &bbpb.Build{ 259 Builder: builderID, 260 Status: bbpb.Status_SCHEDULED, 261 CreateTime: now, 262 UpdateTime: now, 263 Input: &bbpb.Build_Input{ 264 Properties: inputProps, 265 GerritChanges: in.GetGerritChanges(), 266 }, 267 Infra: &bbpb.BuildInfra{ 268 Buildbucket: &bbpb.BuildInfra_Buildbucket{ 269 RequestedProperties: in.GetProperties(), 270 Hostname: c.fa.hostname, 271 }, 272 }, 273 Tags: in.GetTags(), 274 } 275 276 if len(in.GetExperiments()) > 0 { 277 experiments := make(sort.StringSlice, 0, len(in.GetExperiments())) 278 for exp, enabled := range in.GetExperiments() { 279 if enabled { 280 experiments = append(experiments, exp) 281 } 282 } 283 experiments.Sort() 284 if len(experiments) > 0 { 285 build.Input.Experiments = experiments 286 } 287 } 288 289 c.fa.insertBuild(ctx, build, in.GetRequestId()) 290 if err := applyMask(build, in.GetMask()); err != nil { 291 return nil, err 292 } 293 return build, nil 294 } 295 296 func mkInputProps(builderCfg *bbpb.BuilderConfig, requestedProps *structpb.Struct) (*structpb.Struct, error) { 297 var ret *structpb.Struct 298 if builderProps := builderCfg.GetProperties(); builderProps != "" { 299 ret = &structpb.Struct{} 300 if err := protojson.Unmarshal([]byte(builderProps), ret); err != nil { 301 return nil, status.Errorf(codes.Internal, "failed to unmarshal properties: %s", builderProps) 302 } 303 } 304 if requestedProps != nil { 305 if ret == nil { 306 return requestedProps, nil 307 } 308 proto.Merge(ret, requestedProps) 309 } 310 return ret, nil 311 } 312 313 // Batch implements buildbucket.Client. 314 // 315 // Supports: 316 // - CancelBuild 317 // - GetBuild 318 // - ScheduleBuild 319 func (c *Client) Batch(ctx context.Context, in *bbpb.BatchRequest, opts ...grpc.CallOption) (*bbpb.BatchResponse, error) { 320 responses := make([]*bbpb.BatchResponse_Response, len(in.GetRequests())) 321 for i, req := range in.GetRequests() { 322 res := &bbpb.BatchResponse_Response{} 323 switch req.GetRequest().(type) { 324 case *bbpb.BatchRequest_Request_CancelBuild: 325 if b, err := c.CancelBuild(ctx, req.GetCancelBuild()); err != nil { 326 res.Response = &bbpb.BatchResponse_Response_Error{ 327 Error: status.Convert(err).Proto(), 328 } 329 } else { 330 res.Response = &bbpb.BatchResponse_Response_CancelBuild{ 331 CancelBuild: b, 332 } 333 } 334 case *bbpb.BatchRequest_Request_GetBuild: 335 if b, err := c.GetBuild(ctx, req.GetGetBuild()); err != nil { 336 res.Response = &bbpb.BatchResponse_Response_Error{ 337 Error: status.Convert(err).Proto(), 338 } 339 } else { 340 res.Response = &bbpb.BatchResponse_Response_GetBuild{ 341 GetBuild: b, 342 } 343 } 344 case *bbpb.BatchRequest_Request_ScheduleBuild: 345 if b, err := c.ScheduleBuild(ctx, req.GetScheduleBuild()); err != nil { 346 res.Response = &bbpb.BatchResponse_Response_Error{ 347 Error: status.Convert(err).Proto(), 348 } 349 } else { 350 res.Response = &bbpb.BatchResponse_Response_ScheduleBuild{ 351 ScheduleBuild: b, 352 } 353 } 354 default: 355 return nil, status.Errorf(codes.Unimplemented, "batch request type: %T is not supported", req.GetRequest()) 356 } 357 responses[i] = res 358 } 359 return &bbpb.BatchResponse{ 360 Responses: responses, 361 }, nil 362 } 363 364 func (c *Client) canAccessBuild(build *bbpb.Build) bool { 365 // TODO(yiwzhang): implement proper ACL 366 return c.luciProject == build.GetBuilder().GetProject() 367 } 368 369 func applyMask(build *bbpb.Build, bm *bbpb.BuildMask) error { 370 mask, err := model.NewBuildMask("", nil, bm) 371 if err != nil { 372 return status.Errorf(codes.Internal, "error while constructing BuildMask: %s", err) 373 } 374 if err := mask.Trim(build); err != nil { 375 return status.Errorf(codes.Internal, "error while applying field mask: %s", err) 376 } 377 return nil 378 }