blob: c10597c4a75c653d5b548c16f5d70132c1ced63d [file] [log] [blame]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package generator
import (
"errors"
"fmt"
"log"
"infra/cros/internal/gerrit"
"github.com/golang/protobuf/ptypes/wrappers"
"go.chromium.org/chromiumos/infra/proto/go/chromiumos"
"go.chromium.org/chromiumos/infra/proto/go/testplans"
bbproto "go.chromium.org/luci/buildbucket/proto"
)
type buildID struct {
buildTarget string
builderName string
}
// buildResult is a conglomeration of data about a build and how to test it.
type buildResult struct {
buildID buildID
build *bbproto.Build
perTargetTestReqs *testplans.PerTargetTestRequirements
}
type suitesForGroups struct {
// Keys are names of suites that must be included in the results after
// applying subtractive rules. Value will always be true.
onlyKeepSuites map[string]bool
// Keys are names of suites that must be included in the results as a result
// of additive rules. Value will always be true.
additionalSuites map[string]bool
}
// CreateTestPlan generates the test plan that must be run as part of a Chrome OS build.
func CreateTestPlan(
targetTestReqs *testplans.TargetTestRequirementsCfg,
sourceTreeCfg *testplans.SourceTreeTestCfg,
boardPriorityList *testplans.BoardPriorityList,
unfilteredBbBuilds []*bbproto.Build,
gerritChanges []*bbproto.GerritChange,
changeRevs *gerrit.ChangeRevData,
repoToBranchToSrcRoot map[string]map[string]string) (*testplans.GenerateTestPlanResponse, error) {
testPlan := &testplans.GenerateTestPlanResponse{}
// Match up the builds from the input to test requirements in the config.
targetBuildResults := make([]buildResult, 0)
buildResults := eligibleTestBuilds(unfilteredBbBuilds)
perTargetTestReq:
for _, pttr := range targetTestReqs.PerTargetTestRequirements {
tbr, err := selectBuildForRequirements(pttr, buildResults)
if err != nil {
return testPlan, err
}
if tbr == nil {
// Occurs when there are no affected builds for this TargetTestRequirement.
continue perTargetTestReq
}
targetBuildResults = append(targetBuildResults, *tbr)
}
// Get the source paths of files in the CL(s), and figure out any test pruning
// possibilities based on those files.
srcPaths, err := srcPaths(gerritChanges, changeRevs, repoToBranchToSrcRoot)
if err != nil {
return testPlan, err
}
pruneResult, err := extractPruneResult(sourceTreeCfg, srcPaths)
if err != nil {
return testPlan, err
}
// If oneof or only rules were found for the provided source paths, figure
// out the right suites to test for those rules.
onlyKeepSuites := make(map[string]bool)
if pruneResult.hasOnlyKeepSuiteRules() {
onlyKeepSuites, err = getOnlyKeepAllSuitesAndOneofSuites(targetBuildResults, pruneResult, boardPriorityList)
if err != nil {
return nil, err
}
}
additionalSuites := make(map[string]bool)
if pruneResult.hasAddAllOrOneTestRules() {
additionalSuites, err = getAddAllSuitesAndOneofSuites(targetBuildResults, pruneResult, boardPriorityList)
if err != nil {
return nil, err
}
}
sfg := suitesForGroups{
onlyKeepSuites: onlyKeepSuites,
additionalSuites: additionalSuites,
}
return createResponse(targetBuildResults, pruneResult, sfg)
}
func getOnlyKeepAllSuitesAndOneofSuites(targetBuildResults []buildResult, pruneResult *testPruneResult, boardPriorityList *testplans.BoardPriorityList) (map[string]bool, error) {
// Test group --> test suites, sorted in descending order of preference that the
// planner should use to pick from the group.
groupsToSortedSuites, err := groupAndSort(targetBuildResults, boardPriorityList)
if err != nil {
return nil, err
}
suitesForOneofAndOnly := make(map[string]bool)
if pruneResult.hasOnlyKeepSuiteRules() {
for onlyGroup := range pruneResult.onlyKeepAllSuitesInGroups {
sorted := groupsToSortedSuites[onlyGroup]
for _, s := range sorted {
suitesForOneofAndOnly[s.tsc.GetDisplayName()] = true
log.Printf("Using OnlyTest rule for testGroup %v, adding %v", onlyGroup, s.tsc.GetDisplayName())
}
}
for oneofGroup := range pruneResult.onlyKeepOneSuiteFromEachGroup {
sorted := groupsToSortedSuites[oneofGroup]
if len(sorted) > 0 {
suitesForOneofAndOnly[sorted[0].tsc.GetDisplayName()] = true
log.Printf("Using OneOfTest rule for testGroup %v, adding %v", oneofGroup, sorted[0].tsc.GetDisplayName())
}
}
}
log.Printf("OnlyKeepAllSuitesAndOneofSuites: %v", suitesForOneofAndOnly)
return suitesForOneofAndOnly, nil
}
func getAddAllSuitesAndOneofSuites(targetBuildResults []buildResult, pruneResult *testPruneResult, boardPriorityList *testplans.BoardPriorityList) (map[string]bool, error) {
// Test group --> test suites, sorted in descending order of preference that the
// planner should use to pick from the group.
groupsToSortedSuites, err := groupAndSort(targetBuildResults, boardPriorityList)
if err != nil {
return nil, err
}
suitesForAddAllAndOneof := make(map[string]bool)
if pruneResult.hasAddAllOrOneTestRules() {
for g := range pruneResult.addAllSuitesInGroups {
sorted := groupsToSortedSuites[g]
for _, s := range sorted {
suitesForAddAllAndOneof[s.tsc.GetDisplayName()] = true
log.Printf("Using AddAllSuitesInGroups rule for testGroup %v, adding %v", g, s.tsc.GetDisplayName())
}
}
for g := range pruneResult.addOneSuiteFromEachGroup {
sorted := groupsToSortedSuites[g]
if len(sorted) > 0 {
suitesForAddAllAndOneof[sorted[0].tsc.GetDisplayName()] = true
log.Printf("Using AddOneSuiteFromEachGroup rule for testGroup %v, adding %v", g, sorted[0].tsc.GetDisplayName())
}
}
}
log.Printf("AddAllSuitesAndOneofSuites: %v", suitesForAddAllAndOneof)
return suitesForAddAllAndOneof, nil
}
func eligibleTestBuilds(unfilteredBbBuilds []*bbproto.Build) map[buildID]*bbproto.Build {
buildIDToBuild := make(map[buildID]*bbproto.Build)
for _, bb := range unfilteredBbBuilds {
bt := getBuildTarget(bb)
if len(bt) == 0 {
log.Printf("filtering out build without a build target: %s", bb.GetBuilder().GetBuilder())
} else if isPointlessBuild(bb) {
log.Printf("filtering out because marked as pointless: %s", bb.GetBuilder().GetBuilder())
} else if !hasTestArtifacts(bb) {
log.Printf("filtering out with missing test artifacts: %s", bb.GetBuilder().GetBuilder())
} else {
buildIDToBuild[buildID{buildTarget: bt, builderName: bb.GetBuilder().GetBuilder()}] = bb
}
}
return buildIDToBuild
}
func isPointlessBuild(bb *bbproto.Build) bool {
pointlessBuild, ok := bb.GetOutput().GetProperties().GetFields()["pointless_build"]
return ok && pointlessBuild.GetBoolValue()
}
func hasTestArtifacts(b *bbproto.Build) bool {
art, ok := b.GetOutput().GetProperties().GetFields()["artifacts"]
if !ok {
return false
}
fba, ok := art.GetStructValue().GetFields()["files_by_artifact"]
if !ok {
return false
}
// The presence of any one of these artifacts is enough to tell us that this
// build should be considered for testing.
testArtifacts := []string{
"AUTOTEST_FILES",
"IMAGE_ZIP",
"PINNED_GUEST_IMAGES",
"TAST_FILES",
"TEST_UPDATE_PAYLOAD",
}
fileToArtifact := fba.GetStructValue().GetFields()
for _, ta := range testArtifacts {
if _, ok := fileToArtifact[ta]; ok {
return true
}
}
return false
}
// getBuildTarget returns the build target from the given build, or empty string if none is found.
func getBuildTarget(bb *bbproto.Build) string {
btStruct, ok := bb.Input.Properties.Fields["build_target"]
if !ok {
return ""
}
bt, ok := btStruct.GetStructValue().Fields["name"]
if !ok {
return ""
}
return bt.GetStringValue()
}
// createResponse creates the final GenerateTestPlanResponse.
func createResponse(targetBuildResults []buildResult, pruneResult *testPruneResult, sfg suitesForGroups) (*testplans.GenerateTestPlanResponse, error) {
resp := &testplans.GenerateTestPlanResponse{}
// loop over the merged (Buildbucket build, TargetTestRequirements).
for _, tbr := range targetBuildResults {
art, ok := tbr.build.Output.Properties.Fields["artifacts"]
if !ok {
return nil, fmt.Errorf("found no artifacts output property for builder %s", tbr.buildID.builderName)
}
gsBucket, ok := art.GetStructValue().Fields["gs_bucket"]
if !ok {
return nil, fmt.Errorf("found no artifacts.gs_bucket property for builder %s", tbr.buildID.builderName)
}
gsPath, ok := art.GetStructValue().Fields["gs_path"]
if !ok {
return nil, fmt.Errorf("found no artifacts.gs_path property for builder %s", tbr.buildID.builderName)
}
filesByArtifact, ok := art.GetStructValue().Fields["files_by_artifact"]
if !ok {
return nil, fmt.Errorf("found no artifacts.files_by_artifact property for builder %s", tbr.buildID.builderName)
}
bp := &testplans.BuildPayload{
ArtifactsGsBucket: gsBucket.GetStringValue(),
ArtifactsGsPath: gsPath.GetStringValue(),
FilesByArtifact: filesByArtifact.GetStructValue(),
}
pttr := tbr.perTargetTestReqs
bt := chromiumos.BuildTarget{Name: tbr.buildID.buildTarget}
tuc := &testplans.TestUnitCommon{BuildTarget: &bt, BuildPayload: bp, BuilderName: tbr.buildID.builderName}
criticalBuild := tbr.build.Critical != bbproto.Trinary_NO
if !criticalBuild {
// We formerly didn't test noncritical builders, but now we do.
// See https://crbug.com/1040602.
log.Printf("Builder %s is not critical, but we can still test it.", tbr.buildID.builderName)
}
if pttr.HwTestCfg != nil {
hwTestUnit := getHwTestUnit(tuc, pttr.HwTestCfg.HwTest, pruneResult, sfg, criticalBuild)
if hwTestUnit != nil {
resp.HwTestUnits = append(resp.HwTestUnits, hwTestUnit)
}
}
if pttr.DirectTastVmTestCfg != nil {
directTastVMTestUnit := getTastVMTestUnit(tuc, pttr.DirectTastVmTestCfg.TastVmTest, pruneResult, sfg, criticalBuild)
if directTastVMTestUnit != nil {
resp.DirectTastVmTestUnits = append(resp.DirectTastVmTestUnits, directTastVMTestUnit)
}
}
if pttr.VmTestCfg != nil {
vmTestUnit := getVMTestUnit(tuc, pttr.VmTestCfg.VmTest, pruneResult, sfg, criticalBuild)
if vmTestUnit != nil {
resp.VmTestUnits = append(resp.VmTestUnits, vmTestUnit)
}
}
}
return resp, nil
}
func getHwTestUnit(tuc *testplans.TestUnitCommon, tests []*testplans.HwTestCfg_HwTest, pruneResult *testPruneResult, sfg suitesForGroups, criticalBuild bool) *testplans.HwTestUnit {
if tests == nil {
return nil
}
tu := &testplans.HwTestUnit{
Common: tuc,
HwTestCfg: &testplans.HwTestCfg{},
}
testLoop:
for _, t := range tests {
if pruneResult.disableHWTests {
log.Printf("no HW testing needed for %v", t.Common.DisplayName)
continue testLoop
}
// Always test if there's an alsoTest rule.
mustAlsoTest := sfg.additionalSuites[t.GetCommon().GetDisplayName()]
if mustAlsoTest {
log.Printf("Including %v due to additive test rule", t.GetCommon().GetDisplayName())
} else {
inOnlyTestMode := len(sfg.onlyKeepSuites) > 0
if inOnlyTestMode {
// If there are only/oneof rules in effect, we keep the suite if that
// suite is in the `only` map, but not otherwise.
testNotNeeded := !sfg.onlyKeepSuites[t.Common.GetDisplayName()]
if testNotNeeded {
log.Printf("using OnlyTest rule to skip HW testing for %v", t.Common.DisplayName)
continue testLoop
}
} else {
// If we have no only/oneof rules in effect, we keep the suite unless
// there's a disableByDefault rule in effect.
if t.Common.DisableByDefault {
log.Printf("%v is disabled by default, and it was not triggered to be enabled", t.Common.DisplayName)
continue testLoop
}
}
}
log.Printf("adding testing for %v", t.Common.DisplayName)
t.Common = withCritical(t.Common, criticalBuild)
tu.HwTestCfg.HwTest = append(tu.HwTestCfg.HwTest, t)
}
if len(tu.HwTestCfg.HwTest) > 0 {
return tu
}
return nil
}
func getTastVMTestUnit(tuc *testplans.TestUnitCommon, tests []*testplans.TastVmTestCfg_TastVmTest, pruneResult *testPruneResult, sfg suitesForGroups, criticalBuild bool) *testplans.TastVmTestUnit {
if tests == nil {
return nil
}
tu := &testplans.TastVmTestUnit{
Common: tuc,
TastVmTestCfg: &testplans.TastVmTestCfg{},
}
testLoop:
for _, t := range tests {
if pruneResult.disableVMTests {
log.Printf("no Tast VM testing needed for %v", t.Common.DisplayName)
continue testLoop
}
// Always test if there's an alsoTest rule.
mustAlsoTest := sfg.additionalSuites[t.GetCommon().GetDisplayName()]
if mustAlsoTest {
log.Printf("Including %v due to additive test rule", t.GetCommon().GetDisplayName())
} else {
inOnlyTestMode := len(sfg.onlyKeepSuites) > 0
if inOnlyTestMode {
// If there are only/oneof rules in effect, we keep the suite if that
// suite is in the `only` map, but not otherwise.
testNotNeeded := !sfg.onlyKeepSuites[t.Common.GetDisplayName()]
if testNotNeeded {
log.Printf("using OnlyTest rule to skip HW testing for %v", t.Common.DisplayName)
continue testLoop
}
} else {
// If we have no only/oneof rules in effect, we keep the suite unless
// there's a disableByDefault rule in effect.
if t.Common.DisableByDefault {
log.Printf("%v is disabled by default, and it was not triggered to be enabled", t.Common.DisplayName)
continue testLoop
}
}
}
log.Printf("adding testing for %v", t.Common.DisplayName)
t.Common = withCritical(t.Common, criticalBuild)
tu.TastVmTestCfg.TastVmTest = append(tu.TastVmTestCfg.TastVmTest, t)
}
if len(tu.TastVmTestCfg.TastVmTest) > 0 {
return tu
}
return nil
}
func getVMTestUnit(tuc *testplans.TestUnitCommon, tests []*testplans.VmTestCfg_VmTest, pruneResult *testPruneResult, sfg suitesForGroups, criticalBuild bool) *testplans.VmTestUnit {
if tests == nil {
return nil
}
tu := &testplans.VmTestUnit{
Common: tuc,
VmTestCfg: &testplans.VmTestCfg{},
}
testLoop:
for _, t := range tests {
if pruneResult.disableVMTests {
log.Printf("no VM testing needed for %v", t.Common.DisplayName)
continue testLoop
}
// Always test if there's an alsoTest rule.
mustAlsoTest := sfg.additionalSuites[t.GetCommon().GetDisplayName()]
if mustAlsoTest {
log.Printf("Including %v due to additive test rule", t.GetCommon().GetDisplayName())
} else {
inOnlyTestMode := len(sfg.onlyKeepSuites) > 0
if inOnlyTestMode {
// If there are only/oneof rules in effect, we keep the suite if that
// suite is in the `only` map, but not otherwise.
testNotNeeded := !sfg.onlyKeepSuites[t.Common.GetDisplayName()]
if testNotNeeded {
log.Printf("using OnlyTest rule to skip HW testing for %v", t.Common.DisplayName)
continue testLoop
}
} else {
// If we have no only/oneof rules in effect, we keep the suite unless
// there's a disableByDefault rule in effect.
if t.Common.DisableByDefault {
log.Printf("%v is disabled by default, and it was not triggered to be enabled", t.Common.DisplayName)
continue testLoop
}
}
}
log.Printf("adding testing for %v", t.Common.DisplayName)
t.Common = withCritical(t.Common, criticalBuild)
tu.VmTestCfg.VmTest = append(tu.VmTestCfg.VmTest, t)
}
if len(tu.VmTestCfg.VmTest) > 0 {
return tu
}
return nil
}
func withCritical(tsc *testplans.TestSuiteCommon, buildCritical bool) *testplans.TestSuiteCommon {
if tsc == nil {
tsc = &testplans.TestSuiteCommon{}
}
suiteCritical := true
if tsc.Critical != nil {
suiteCritical = tsc.Critical.Value
}
// If either the build was noncritical or the suite is configured to be
// noncritical, then make the suite noncritical. As of now we don't even
// schedule suites for noncritical builders, but if we ever change that logic,
// this seems like the right way to set suite criticality.
tsc.Critical = &wrappers.BoolValue{Value: buildCritical && suiteCritical}
if !tsc.Critical.Value {
log.Printf("Marking %s as not critical", tsc.DisplayName)
}
return tsc
}
// selectBuildForRequirements finds a build that best matches the provided PerTargetTestRequirements.
// e.g. if the requirements want a build for a reef build target, this method will find a successful,
// non-early-terminated build.
func selectBuildForRequirements(
pttr *testplans.PerTargetTestRequirements,
buildIDToBuild map[buildID]*bbproto.Build) (*buildResult, error) {
log.Printf("Considering testing for TargetCritera %v", pttr.TargetCriteria)
if pttr.TargetCriteria.GetBuildTarget() == "" {
return nil, errors.New("found a PerTargetTestRequirement without a build target")
}
eligibleBuildIds := []buildID{{pttr.TargetCriteria.GetBuildTarget(), pttr.TargetCriteria.GetBuilderName()}}
bt, err := pickBuilderToTest(eligibleBuildIds, buildIDToBuild)
if err != nil {
// Expected when a necessary builder failed, and thus we cannot continue with testing.
return nil, err
}
if bt == nil {
// There are no builds for these test criteria, so this PerTargetTestRequirement is
// irrelevant. Continue on to the next one.
// This happens when no build was relevant due to an EarlyTerminationStatus.
return nil, nil
}
br := buildIDToBuild[*bt]
return &buildResult{
build: br,
buildID: buildID{buildTarget: getBuildTarget(br), builderName: br.GetBuilder().GetBuilder()},
perTargetTestReqs: pttr},
nil
}
// pickBuilderToTest returns up to one buildID that should be tested, out of the provided slice
// of buildIDs. The returned buildID, if present, is guaranteed to be one with a BuildResult.
func pickBuilderToTest(buildIDs []buildID, buildIDToBuild map[buildID]*bbproto.Build) (*buildID, error) {
// Relevant results are those builds that weren't terminated early.
// Early termination is a good thing. It just means that the build wasn't affected by the relevant commits.
relevantReports := make(map[buildID]*bbproto.Build)
for _, bt := range buildIDs {
br, found := buildIDToBuild[bt]
if !found {
log.Printf("No build found for buildID %s", bt)
continue
}
relevantReports[bt] = br
}
if len(relevantReports) == 0 {
// None of the builds were relevant, so none of these builds needs testing.
return nil, nil
}
for _, bt := range buildIDs {
// Find and return the first relevant, successful build.
result, found := relevantReports[bt]
if found && result.Status == bbproto.Status_SUCCESS {
return &bt, nil
}
}
log.Printf("can't test for builders %v because all builders failed\n", buildIDs)
return nil, nil
}
// srcPaths extracts the source paths from each of the provided Gerrit changes.
func srcPaths(
changes []*bbproto.GerritChange,
changeRevs *gerrit.ChangeRevData,
repoToBranchToSrcRoot map[string]map[string]string) ([]string, error) {
srcPaths := make([]string, 0)
changeLoop:
for _, commit := range changes {
chRev, err := changeRevs.GetChangeRev(commit.Host, commit.Change, int32(commit.Patchset))
if err != nil {
return srcPaths, err
}
for _, file := range chRev.Files {
branchMapping, found := repoToBranchToSrcRoot[chRev.Project]
if !found {
return srcPaths, fmt.Errorf("Found no branch mapping for project %s", chRev.Project)
}
srcRootMapping, found := branchMapping[chRev.Branch]
if !found {
log.Printf("Found no source mapping for project %s and branch %s", chRev.Project, chRev.Branch)
continue changeLoop
}
srcPaths = append(srcPaths, fmt.Sprintf("%s/%s", srcRootMapping, file))
}
}
return srcPaths, nil
}