go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/ingestion/join/build.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 join 16 17 import ( 18 "context" 19 "fmt" 20 "sort" 21 "strings" 22 23 structpb "github.com/golang/protobuf/ptypes/struct" 24 "google.golang.org/genproto/protobuf/field_mask" 25 "google.golang.org/grpc/codes" 26 "google.golang.org/grpc/status" 27 "google.golang.org/protobuf/types/known/timestamppb" 28 29 bbpb "go.chromium.org/luci/buildbucket/proto" 30 "go.chromium.org/luci/common/errors" 31 "go.chromium.org/luci/common/logging" 32 gerritpb "go.chromium.org/luci/common/proto/gerrit" 33 "go.chromium.org/luci/common/retry/transient" 34 "go.chromium.org/luci/common/tsmon/field" 35 "go.chromium.org/luci/common/tsmon/metric" 36 37 "go.chromium.org/luci/analysis/internal/buildbucket" 38 "go.chromium.org/luci/analysis/internal/gerrit" 39 "go.chromium.org/luci/analysis/internal/ingestion/control" 40 ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto" 41 "go.chromium.org/luci/analysis/internal/resultdb" 42 "go.chromium.org/luci/analysis/internal/testresults" 43 "go.chromium.org/luci/analysis/internal/testresults/gerritchangelists" 44 pb "go.chromium.org/luci/analysis/proto/v1" 45 ) 46 47 const ( 48 // userAgentTagKey is the key of the user agent tag. 49 userAgentTagKey = "user_agent" 50 // userAgentCQ is the value of the user agent tag, for builds started 51 // by LUCI CV. 52 userAgentCQ = "cq" 53 // The maximum number of CLs to keep for each ingested build. 54 // Avoids excessive storage consumption and calls to gerrit. 55 maximumCLs = 10 56 ) 57 58 var ( 59 buildProcessingOutcomeCounter = metric.NewCounter( 60 "analysis/ingestion/pubsub/buildbucket_build_processing_outcome", 61 "The number of buildbucket builds processed by LUCI Analysis,"+ 62 " by processing outcome (e.g. success, permission denied).", 63 nil, 64 // The LUCI Project. 65 field.String("project"), 66 // "success", "permission_denied". 67 field.String("status")) 68 69 ancestorCounter = metric.NewCounter( 70 "analysis/ingestion/ancestor_build_status", 71 "The status retrieving ancestor builds in ingestion tasks, by build project.", 72 nil, 73 // The LUCI Project. 74 field.String("project"), 75 // "no_bb_access_to_ancestor", 76 // "no_resultdb_invocation_on_ancestor", 77 // "ok". 78 field.String("ancestor_status")) 79 ) 80 81 // JoinBuild notifies ingestion that the given buildbucket build has finished. 82 // Ingestion tasks are created for buildbucket builds when all required data 83 // for a build (including any associated LUCI CV run) is available. 84 func JoinBuild(ctx context.Context, bbHost, project string, buildID int64) (processed bool, err error) { 85 buildReadMask := &field_mask.FieldMask{ 86 Paths: []string{"ancestor_ids", "builder", "create_time", "infra.resultdb", "input", "output", "status", "tags"}, 87 } 88 build, err := retrieveBuild(ctx, bbHost, project, buildID, buildReadMask) 89 code := status.Code(err) 90 if code == codes.NotFound { 91 // Build not found, handle gracefully. 92 logging.Warningf(ctx, "Buildbucket build %s/%d for project %s not found (or LUCI Analysis does not have access to read it).", 93 bbHost, buildID, project) 94 buildProcessingOutcomeCounter.Add(ctx, 1, project, "permission_denied") 95 return false, nil 96 } 97 if err != nil { 98 return false, transient.Tag.Apply(errors.Annotate(err, "retrieving buildbucket build").Err()) 99 } 100 101 if build.CreateTime.GetSeconds() <= 0 { 102 return false, errors.New("build did not have create time specified") 103 } 104 105 userAgents := extractTagValues(build.Tags, userAgentTagKey) 106 isPresubmit := len(userAgents) == 1 && userAgents[0] == userAgentCQ 107 108 id := control.BuildID(bbHost, buildID) 109 110 hasInvocation := false 111 invocationName := build.GetInfra().GetResultdb().GetInvocation() 112 rdbHostName := build.GetInfra().GetResultdb().GetHostname() 113 if rdbHostName != "" && invocationName != "" { 114 wantInvocationName := control.BuildInvocationName(buildID) 115 if invocationName != wantInvocationName { 116 // If a build does not have an invocation of this form, it will never 117 // be successfully joined by our implementation. It is better to 118 // fail now in an obvious manner than fail later silently. 119 return false, errors.Reason("build %v had unexpected ResultDB invocation (got %v, want %v)", id, invocationName, wantInvocationName).Err() 120 } 121 hasInvocation = true 122 } 123 124 isIncludedByAncestor := false 125 if len(build.AncestorIds) > 0 && hasInvocation { 126 // If the build has an ancestor build, see if its immediate 127 // ancestor is accessible by LUCI Analysis and has a ResultDB 128 // invocation (likely indicating it includes the test results 129 // from this build). 130 ancestorBuildID := build.AncestorIds[len(build.AncestorIds)-1] 131 var err error 132 isIncludedByAncestor, err = includedByAncestorBuild(ctx, buildID, ancestorBuildID, rdbHostName, project) 133 if err != nil { 134 return false, transient.Tag.Apply(err) 135 } 136 } 137 138 var buildStatus pb.BuildStatus 139 switch build.Status { 140 case bbpb.Status_CANCELED: 141 buildStatus = pb.BuildStatus_BUILD_STATUS_CANCELED 142 case bbpb.Status_SUCCESS: 143 buildStatus = pb.BuildStatus_BUILD_STATUS_SUCCESS 144 case bbpb.Status_FAILURE: 145 buildStatus = pb.BuildStatus_BUILD_STATUS_FAILURE 146 case bbpb.Status_INFRA_FAILURE: 147 buildStatus = pb.BuildStatus_BUILD_STATUS_INFRA_FAILURE 148 default: 149 return false, fmt.Errorf("build has unknown status: %v", build.Status) 150 } 151 152 var changelists []*pb.Changelist 153 if project == "chromeos" { 154 // This path is being retained only to support Chrome OS's 155 // use of LUCI Analysis Exoneration v1, in the presence 156 // of inconsistently set test results sources in ResultDB. 157 // Deprecate once ChromeOS fixes this up and switches 158 // to exoneration v2. 159 // 160 // Chromium's use of LUCI Analysis Exoneration v1 does not 161 // require this as it consistently sets test result sources 162 // via ResultDB. 163 // 164 // Exoneration v2 only functions with sources set via ResultDB. 165 gerritChanges := build.GetInput().GetGerritChanges() 166 changelists, err = prepareChangelists(ctx, project, gerritChanges) 167 if err != nil { 168 return false, errors.Annotate(err, "prepare changelists").Err() 169 } 170 } 171 172 commit := build.Output.GetGitilesCommit() 173 if commit == nil { 174 commit = build.Input.GetGitilesCommit() 175 } 176 177 result := &ctlpb.BuildResult{ 178 CreationTime: timestamppb.New(build.CreateTime.AsTime()), 179 Id: buildID, 180 Host: bbHost, 181 Project: project, 182 Bucket: build.Builder.Bucket, 183 Builder: build.Builder.Builder, 184 Status: buildStatus, 185 Changelists: changelists, 186 Commit: commit, 187 HasInvocation: hasInvocation, 188 ResultdbHost: build.GetInfra().GetResultdb().Hostname, 189 IsIncludedByAncestor: isIncludedByAncestor, 190 GardenerRotations: gardenerRotations(build.Input.GetProperties()), 191 } 192 if err := JoinBuildResult(ctx, id, project, isPresubmit, hasInvocation, result); err != nil { 193 return false, errors.Annotate(err, "joining build result").Err() 194 } 195 buildProcessingOutcomeCounter.Add(ctx, 1, project, "success") 196 return true, nil 197 } 198 199 func prepareChangelists(ctx context.Context, project string, gerritChanges []*bbpb.GerritChange) ([]*pb.Changelist, error) { 200 // Capture the tested changelists in sorted order. This ensures that for 201 // the same combination of CLs tested, the arrays are identical. 202 gerritChanges = sortChangelists(gerritChanges) 203 204 // Truncate the list of changelists to avoid storing an excessive number. 205 // Apply truncation after sorting to ensure a stable set of changelists. 206 if len(gerritChanges) > maximumCLs { 207 gerritChanges = gerritChanges[:maximumCLs] 208 } 209 210 // Lookup the owner kind of each changelist. 211 lookupRequest := make(map[gerritchangelists.Key]gerritchangelists.LookupRequest) 212 for _, change := range gerritChanges { 213 if err := testresults.ValidateGerritHostname(change.Host); err != nil { 214 return nil, err 215 } 216 key := gerritchangelists.Key{ 217 Project: project, 218 Host: change.Host, 219 Change: change.Change, 220 } 221 lookupRequest[key] = gerritchangelists.LookupRequest{ 222 GerritProject: change.Project, 223 } 224 } 225 226 ownerKinds, err := gerritchangelists.FetchOwnerKinds(ctx, lookupRequest) 227 if err != nil { 228 return nil, errors.Annotate(err, "retrieving gerrit owner kinds").Err() 229 } 230 231 result := make([]*pb.Changelist, 0, len(gerritChanges)) 232 for _, change := range gerritChanges { 233 key := gerritchangelists.Key{ 234 Project: project, 235 Host: change.Host, 236 Change: change.Change, 237 } 238 239 result = append(result, &pb.Changelist{ 240 Host: change.Host, 241 Change: change.Change, 242 Patchset: int32(change.Patchset), 243 OwnerKind: ownerKinds[key], 244 }) 245 } 246 return result, nil 247 } 248 249 func includedByAncestorBuild(ctx context.Context, buildID, ancestorBuildID int64, rdbHost string, project string) (bool, error) { 250 ancestorInvName := control.BuildInvocationName(ancestorBuildID) 251 252 // The ancestor build may not be in the same project as the build we are 253 // considering ingesting. We cannot use project-scoped credentials, 254 // and instead must use privileged access granted to us. We should 255 // be careful not to leak information about this invocation to the 256 // project we are ingesting (except for the inclusion of the child 257 // in it as that is unavoidable for the purposes of implementing 258 // only-once ingestion). 259 rc, err := resultdb.NewPrivilegedClient(ctx, rdbHost) 260 if err != nil { 261 return false, transient.Tag.Apply(err) 262 } 263 ancestorInv, err := rc.GetInvocation(ctx, ancestorInvName) 264 code := status.Code(err) 265 if code == codes.NotFound || code == codes.PermissionDenied { 266 logging.Warningf(ctx, "Ancestor build ResultDB Invocation %s/%d for project %s not found (or LUCI Analysis does not have access to read it).", 267 rdbHost, ancestorBuildID, project) 268 // Invocation on the ancestor build not found or permission denied. 269 // Continue ingestion of this build. 270 ancestorCounter.Add(ctx, 1, project, "resultdb_invocation_on_ancestor_not_found") 271 return false, nil 272 } 273 if err != nil { 274 return false, transient.Tag.Apply(errors.Annotate(err, "fetch ancestor build ResultDB invocation").Err()) 275 } 276 277 containsThisBuild := false 278 279 buildInvocation := control.BuildInvocationName(buildID) 280 for _, inv := range ancestorInv.IncludedInvocations { 281 if inv == buildInvocation { 282 containsThisBuild = true 283 } 284 } 285 286 if !containsThisBuild { 287 // The ancestor build's invocation does not contain the ResultDB 288 // invocation of this build. Continue ingestion of this build. 289 ancestorCounter.Add(ctx, 1, project, "resultdb_invocation_on_ancestor_does_not_contain") 290 return false, nil 291 } 292 293 // The ancestor build also has a ResultDB invocation, and it 294 // contains this invocation. We will ingest the ancestor build 295 // only to avoid ingesting the same test results multiple times. 296 ancestorCounter.Add(ctx, 1, project, "ok") 297 return true, nil 298 } 299 300 func extractTagValues(tags []*bbpb.StringPair, key string) []string { 301 var values []string 302 for _, tag := range tags { 303 if tag.Key == key { 304 values = append(values, tag.Value) 305 } 306 } 307 return values 308 } 309 310 func retrieveBuild(ctx context.Context, bbHost, project string, id int64, readMask *field_mask.FieldMask) (*bbpb.Build, error) { 311 bc, err := buildbucket.NewClient(ctx, bbHost, project) 312 if err != nil { 313 return nil, err 314 } 315 request := &bbpb.GetBuildRequest{ 316 Id: id, 317 Mask: &bbpb.BuildMask{ 318 Fields: readMask, 319 }, 320 } 321 b, err := bc.GetBuild(ctx, request) 322 switch { 323 case err != nil: 324 return nil, err 325 } 326 return b, nil 327 } 328 329 func retrieveChangelistOwnerKind(ctx context.Context, luciProject string, change *bbpb.GerritChange) (pb.ChangelistOwnerKind, error) { 330 if !strings.HasSuffix(change.Host, "-review.googlesource.com") { 331 // Do not try and retrieve CL information from a gerrit host other 332 // than those hosted on .googlesource.com. The CL hostname 333 // could come from an untrusted source, and we don't want to leak 334 // our authentication tokens to arbitrary hosts on the internet. 335 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil 336 } 337 338 client, err := gerrit.NewClient(ctx, change.Host, luciProject) 339 if err != nil { 340 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, err 341 } 342 req := &gerritpb.GetChangeRequest{ 343 Number: change.Change, 344 Options: []gerritpb.QueryOption{ 345 gerritpb.QueryOption_DETAILED_ACCOUNTS, 346 }, 347 Project: change.Project, 348 } 349 fullChange, err := client.GetChange(ctx, req) 350 code := status.Code(err) 351 if code == codes.NotFound { 352 logging.Warningf(ctx, "Patchset %s/%v for project %s not found.", 353 change.Host, change.Change, luciProject) 354 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil 355 } 356 if code == codes.PermissionDenied { 357 logging.Warningf(ctx, "LUCI Analysis does not have permission to read patchset %s/%v for project %s.", 358 change.Host, change.Change, luciProject) 359 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil 360 } 361 if err != nil { 362 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, transient.Tag.Apply(err) 363 } 364 ownerEmail := fullChange.Owner.GetEmail() 365 if automationAccountRE.MatchString(ownerEmail) { 366 return pb.ChangelistOwnerKind_AUTOMATION, nil 367 } else if ownerEmail != "" { 368 return pb.ChangelistOwnerKind_HUMAN, nil 369 } 370 return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil 371 } 372 373 // gardenerRotations extracts the gardener rotations monitoring 374 // a buildbucket build. This is obtained from the sheriff_rotations 375 // build input property. 376 func gardenerRotations(buildInputProperties *structpb.Struct) []string { 377 if buildInputProperties.GetFields() == nil { 378 return nil 379 } 380 field := buildInputProperties.Fields["sheriff_rotations"] 381 if field == nil { 382 return nil 383 } 384 listValue := field.GetListValue() 385 if listValue == nil { 386 return nil 387 } 388 var rotations []string 389 for _, value := range listValue.Values { 390 rotation := value.GetStringValue() 391 if rotation != "" { 392 rotations = append(rotations, rotation) 393 } 394 // Ignore sheriff_rotation entries which are not strings. 395 // This should not happen anyway. 396 } 397 return rotations 398 } 399 400 // sortChangelists sorts a slice of changelists to be in ascending 401 // lexicographical order by (host, change, patchset). 402 func sortChangelists(cls []*bbpb.GerritChange) []*bbpb.GerritChange { 403 // Copy the CLs list to avoid modifying the passed arguments. 404 originalCLs := cls 405 cls = make([]*bbpb.GerritChange, len(originalCLs)) 406 copy(cls, originalCLs) 407 408 sort.Slice(cls, func(i, j int) bool { 409 // Returns true iff cls[i] is less than cls[j]. 410 if cls[i].Host < cls[j].Host { 411 return true 412 } 413 if cls[i].Host == cls[j].Host && cls[i].Change < cls[j].Change { 414 return true 415 } 416 if cls[i].Host == cls[j].Host && cls[i].Change == cls[j].Change && cls[i].Patchset < cls[j].Patchset { 417 return true 418 } 419 return false 420 }) 421 return cls 422 }