blob: 42390236de775aa45823ad957df69a59cff97751 [file] [log] [blame]
// Copyright 2022 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 join
import (
"context"
"fmt"
"sort"
"strings"
structpb "github.com/golang/protobuf/ptypes/struct"
"google.golang.org/genproto/protobuf/field_mask"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
bbpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/common/tsmon/field"
"go.chromium.org/luci/common/tsmon/metric"
"go.chromium.org/luci/analysis/internal/buildbucket"
"go.chromium.org/luci/analysis/internal/gerrit"
"go.chromium.org/luci/analysis/internal/gerritchangelists"
"go.chromium.org/luci/analysis/internal/ingestion/control"
ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
"go.chromium.org/luci/analysis/internal/resultdb"
"go.chromium.org/luci/analysis/internal/testresults"
pb "go.chromium.org/luci/analysis/proto/v1"
)
const (
// userAgentTagKey is the key of the user agent tag.
userAgentTagKey = "user_agent"
// userAgentCQ is the value of the user agent tag, for builds started
// by LUCI CV.
userAgentCQ = "cq"
// The maximum number of CLs to keep for each ingested build.
// Avoids excessive storage consumption and calls to gerrit.
maximumCLs = 10
)
var (
buildProcessingOutcomeCounter = metric.NewCounter(
"analysis/ingestion/pubsub/buildbucket_build_processing_outcome",
"The number of buildbucket builds processed by LUCI Analysis,"+
" by processing outcome (e.g. success, permission denied).",
nil,
// The LUCI Project.
field.String("project"),
// "success", "permission_denied".
field.String("status"))
ancestorCounter = metric.NewCounter(
"analysis/ingestion/ancestor_build_status",
"The status retrieving ancestor builds in ingestion tasks, by build project.",
nil,
// The LUCI Project.
field.String("project"),
// "no_bb_access_to_ancestor",
// "no_resultdb_invocation_on_ancestor",
// "ok".
field.String("ancestor_status"))
)
// JoinBuild notifies ingestion that the given buildbucket build has finished.
// Ingestion tasks are created for buildbucket builds when all required data
// for a build (including any associated LUCI CV run) is available.
func JoinBuild(ctx context.Context, bbHost, project string, buildID int64) (processed bool, err error) {
buildReadMask := &field_mask.FieldMask{
Paths: []string{"ancestor_ids", "builder", "create_time", "infra.resultdb", "input", "output", "status", "tags"},
}
build, err := retrieveBuild(ctx, bbHost, project, buildID, buildReadMask)
code := status.Code(err)
if code == codes.NotFound {
// Build not found, handle gracefully.
logging.Warningf(ctx, "Buildbucket build %s/%d for project %s not found (or LUCI Analysis does not have access to read it).",
bbHost, buildID, project)
buildProcessingOutcomeCounter.Add(ctx, 1, project, "permission_denied")
return false, nil
}
if err != nil {
return false, transient.Tag.Apply(errors.Annotate(err, "retrieving buildbucket build").Err())
}
if build.CreateTime.GetSeconds() <= 0 {
return false, errors.New("build did not have create time specified")
}
userAgents := extractTagValues(build.Tags, userAgentTagKey)
isPresubmit := len(userAgents) == 1 && userAgents[0] == userAgentCQ
id := control.BuildID(bbHost, buildID)
hasInvocation := false
invocationName := build.GetInfra().GetResultdb().GetInvocation()
rdbHostName := build.GetInfra().GetResultdb().GetHostname()
if rdbHostName != "" && invocationName != "" {
wantInvocationName := control.BuildInvocationName(buildID)
if invocationName != wantInvocationName {
// If a build does not have an invocation of this form, it will never
// be successfully joined by our implementation. It is better to
// fail now in an obvious manner than fail later silently.
return false, errors.Reason("build %v had unexpected ResultDB invocation (got %v, want %v)", id, invocationName, wantInvocationName).Err()
}
hasInvocation = true
}
isIncludedByAncestor := false
if len(build.AncestorIds) > 0 && hasInvocation {
// If the build has an ancestor build, see if its immediate
// ancestor is accessible by LUCI Analysis and has a ResultDB
// invocation (likely indicating it includes the test results
// from this build).
ancestorBuildID := build.AncestorIds[len(build.AncestorIds)-1]
var err error
isIncludedByAncestor, err = includedByAncestorBuild(ctx, buildID, ancestorBuildID, rdbHostName, project)
if err != nil {
return false, transient.Tag.Apply(err)
}
}
var buildStatus pb.BuildStatus
switch build.Status {
case bbpb.Status_CANCELED:
buildStatus = pb.BuildStatus_BUILD_STATUS_CANCELED
case bbpb.Status_SUCCESS:
buildStatus = pb.BuildStatus_BUILD_STATUS_SUCCESS
case bbpb.Status_FAILURE:
buildStatus = pb.BuildStatus_BUILD_STATUS_FAILURE
case bbpb.Status_INFRA_FAILURE:
buildStatus = pb.BuildStatus_BUILD_STATUS_INFRA_FAILURE
default:
return false, fmt.Errorf("build has unknown status: %v", build.Status)
}
var changelists []*pb.Changelist
if project == "chromeos" {
// This path is being retained only to support Chrome OS's
// use of LUCI Analysis Exoneration v1, in the presence
// of inconsistently set test results sources in ResultDB.
// Deprecate once ChromeOS fixes this up and switches
// to exoneration v2.
//
// Chromium's use of LUCI Analysis Exoneration v1 does not
// require this as it consistently sets test result sources
// via ResultDB.
//
// Exoneration v2 only functions with sources set via ResultDB.
gerritChanges := build.GetInput().GetGerritChanges()
changelists, err = prepareChangelists(ctx, project, gerritChanges)
if err != nil {
return false, errors.Annotate(err, "prepare changelists").Err()
}
}
commit := build.Output.GetGitilesCommit()
if commit == nil {
commit = build.Input.GetGitilesCommit()
}
result := &ctlpb.BuildResult{
CreationTime: timestamppb.New(build.CreateTime.AsTime()),
Id: buildID,
Host: bbHost,
Project: project,
Bucket: build.Builder.Bucket,
Builder: build.Builder.Builder,
Status: buildStatus,
Changelists: changelists,
Commit: commit,
HasInvocation: hasInvocation,
ResultdbHost: build.GetInfra().GetResultdb().Hostname,
IsIncludedByAncestor: isIncludedByAncestor,
GardenerRotations: gardenerRotations(build.Input.GetProperties()),
}
if err := JoinBuildResult(ctx, id, project, isPresubmit, hasInvocation, result); err != nil {
return false, errors.Annotate(err, "joining build result").Err()
}
buildProcessingOutcomeCounter.Add(ctx, 1, project, "success")
return true, nil
}
func prepareChangelists(ctx context.Context, project string, gerritChanges []*bbpb.GerritChange) ([]*pb.Changelist, error) {
// Capture the tested changelists in sorted order. This ensures that for
// the same combination of CLs tested, the arrays are identical.
gerritChanges = sortChangelists(gerritChanges)
// Truncate the list of changelists to avoid storing an excessive number.
// Apply truncation after sorting to ensure a stable set of changelists.
if len(gerritChanges) > maximumCLs {
gerritChanges = gerritChanges[:maximumCLs]
}
// Lookup the owner kind of each changelist.
lookupRequest := make(map[gerritchangelists.Key]gerritchangelists.LookupRequest)
for _, change := range gerritChanges {
if err := testresults.ValidateGerritHostname(change.Host); err != nil {
return nil, err
}
key := gerritchangelists.Key{
Project: project,
Host: change.Host,
Change: change.Change,
}
lookupRequest[key] = gerritchangelists.LookupRequest{
GerritProject: change.Project,
}
}
ownerKinds, err := gerritchangelists.FetchOwnerKinds(ctx, lookupRequest)
if err != nil {
return nil, errors.Annotate(err, "retrieving gerrit owner kinds").Err()
}
result := make([]*pb.Changelist, 0, len(gerritChanges))
for _, change := range gerritChanges {
key := gerritchangelists.Key{
Project: project,
Host: change.Host,
Change: change.Change,
}
result = append(result, &pb.Changelist{
Host: change.Host,
Change: change.Change,
Patchset: int32(change.Patchset),
OwnerKind: ownerKinds[key],
})
}
return result, nil
}
func includedByAncestorBuild(ctx context.Context, buildID, ancestorBuildID int64, rdbHost string, project string) (bool, error) {
ancestorInvName := control.BuildInvocationName(ancestorBuildID)
// The ancestor build may not be in the same project as the build we are
// considering ingesting. We cannot use project-scoped credentials,
// and instead must use privileged access granted to us. We should
// be careful not to leak information about this invocation to the
// project we are ingesting (except for the inclusion of the child
// in it as that is unavoidable for the purposes of implementing
// only-once ingestion).
rc, err := resultdb.NewPrivilegedClient(ctx, rdbHost)
if err != nil {
return false, transient.Tag.Apply(err)
}
ancestorInv, err := rc.GetInvocation(ctx, ancestorInvName)
code := status.Code(err)
if code == codes.NotFound || code == codes.PermissionDenied {
logging.Warningf(ctx, "Ancestor build ResultDB Invocation %s/%d for project %s not found (or LUCI Analysis does not have access to read it).",
rdbHost, ancestorBuildID, project)
// Invocation on the ancestor build not found or permission denied.
// Continue ingestion of this build.
ancestorCounter.Add(ctx, 1, project, "resultdb_invocation_on_ancestor_not_found")
return false, nil
}
if err != nil {
return false, transient.Tag.Apply(errors.Annotate(err, "fetch ancestor build ResultDB invocation").Err())
}
containsThisBuild := false
buildInvocation := control.BuildInvocationName(buildID)
for _, inv := range ancestorInv.IncludedInvocations {
if inv == buildInvocation {
containsThisBuild = true
}
}
if !containsThisBuild {
// The ancestor build's invocation does not contain the ResultDB
// invocation of this build. Continue ingestion of this build.
ancestorCounter.Add(ctx, 1, project, "resultdb_invocation_on_ancestor_does_not_contain")
return false, nil
}
// The ancestor build also has a ResultDB invocation, and it
// contains this invocation. We will ingest the ancestor build
// only to avoid ingesting the same test results multiple times.
ancestorCounter.Add(ctx, 1, project, "ok")
return true, nil
}
func extractTagValues(tags []*bbpb.StringPair, key string) []string {
var values []string
for _, tag := range tags {
if tag.Key == key {
values = append(values, tag.Value)
}
}
return values
}
func retrieveBuild(ctx context.Context, bbHost, project string, id int64, readMask *field_mask.FieldMask) (*bbpb.Build, error) {
bc, err := buildbucket.NewClient(ctx, bbHost, project)
if err != nil {
return nil, err
}
request := &bbpb.GetBuildRequest{
Id: id,
Mask: &bbpb.BuildMask{
Fields: readMask,
},
}
b, err := bc.GetBuild(ctx, request)
switch {
case err != nil:
return nil, err
}
return b, nil
}
func retrieveChangelistOwnerKind(ctx context.Context, luciProject string, change *bbpb.GerritChange) (pb.ChangelistOwnerKind, error) {
if !strings.HasSuffix(change.Host, "-review.googlesource.com") {
// Do not try and retrieve CL information from a gerrit host other
// than those hosted on .googlesource.com. The CL hostname
// could come from an untrusted source, and we don't want to leak
// our authentication tokens to arbitrary hosts on the internet.
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil
}
client, err := gerrit.NewClient(ctx, change.Host, luciProject)
if err != nil {
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, err
}
req := &gerritpb.GetChangeRequest{
Number: change.Change,
Options: []gerritpb.QueryOption{
gerritpb.QueryOption_DETAILED_ACCOUNTS,
},
Project: change.Project,
}
fullChange, err := client.GetChange(ctx, req)
code := status.Code(err)
if code == codes.NotFound {
logging.Warningf(ctx, "Patchset %s/%v for project %s not found.",
change.Host, change.Change, luciProject)
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil
}
if code == codes.PermissionDenied {
logging.Warningf(ctx, "LUCI Analysis does not have permission to read patchset %s/%v for project %s.",
change.Host, change.Change, luciProject)
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil
}
if err != nil {
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, transient.Tag.Apply(err)
}
ownerEmail := fullChange.Owner.GetEmail()
if automationAccountRE.MatchString(ownerEmail) {
return pb.ChangelistOwnerKind_AUTOMATION, nil
} else if ownerEmail != "" {
return pb.ChangelistOwnerKind_HUMAN, nil
}
return pb.ChangelistOwnerKind_CHANGELIST_OWNER_UNSPECIFIED, nil
}
// gardenerRotations extracts the gardener rotations monitoring
// a buildbucket build. This is obtained from the sheriff_rotations
// build input property.
func gardenerRotations(buildInputProperties *structpb.Struct) []string {
if buildInputProperties.GetFields() == nil {
return nil
}
field := buildInputProperties.Fields["sheriff_rotations"]
if field == nil {
return nil
}
listValue := field.GetListValue()
if listValue == nil {
return nil
}
var rotations []string
for _, value := range listValue.Values {
rotation := value.GetStringValue()
if rotation != "" {
rotations = append(rotations, rotation)
}
// Ignore sheriff_rotation entries which are not strings.
// This should not happen anyway.
}
return rotations
}
// sortChangelists sorts a slice of changelists to be in ascending
// lexicographical order by (host, change, patchset).
func sortChangelists(cls []*bbpb.GerritChange) []*bbpb.GerritChange {
// Copy the CLs list to avoid modifying the passed arguments.
originalCLs := cls
cls = make([]*bbpb.GerritChange, len(originalCLs))
copy(cls, originalCLs)
sort.Slice(cls, func(i, j int) bool {
// Returns true iff cls[i] is less than cls[j].
if cls[i].Host < cls[j].Host {
return true
}
if cls[i].Host == cls[j].Host && cls[i].Change < cls[j].Change {
return true
}
if cls[i].Host == cls[j].Host && cls[i].Change == cls[j].Change && cls[i].Patchset < cls[j].Patchset {
return true
}
return false
})
return cls
}