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  }