blob: 4ad2aacf8125fbb164ff9209e3bd3f5fccf9f174 [file] [log] [blame]
// Copyright 2021 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 triager
import (
"fmt"
"strings"
"testing"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/common/clock/testclock"
cfgpb "go.chromium.org/luci/cv/api/config/v2"
"go.chromium.org/luci/cv/internal/changelist"
"go.chromium.org/luci/cv/internal/configs/prjcfg"
"go.chromium.org/luci/cv/internal/cvtesting"
"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
"go.chromium.org/luci/cv/internal/run"
. "github.com/smartystreets/goconvey/convey"
. "go.chromium.org/luci/common/testing/assertions"
)
func shouldResembleTriagedCL(actual interface{}, expected ...interface{}) string {
if len(expected) != 1 {
return fmt.Sprintf("expected 1 value, got %d", len(expected))
}
exp := expected[0] // this may be nil
a, ok := actual.(*clInfo)
if !ok {
return fmt.Sprintf("Wrong actual type %T, must be %T", actual, a)
}
if err := ShouldHaveSameTypeAs(actual, exp); err != "" {
return err
}
b := exp.(*clInfo)
switch {
case a == b:
return ""
case a == nil:
return "actual is nil, but non-nil was expected"
case b == nil:
return "actual is not-nil, but nil was expected"
}
buf := strings.Builder{}
for _, err := range []string{
ShouldResemble(a.cqReady, b.cqReady),
ShouldResemble(a.nprReady, b.nprReady),
ShouldResemble(a.runIndexes, b.runIndexes),
cvtesting.SafeShouldResemble(a.deps, b.deps),
ShouldResembleProto(a.pcl, b.pcl),
ShouldResembleProto(a.purgingCL, b.purgingCL),
ShouldResembleProto(a.purgeReasons, b.purgeReasons),
} {
if err != "" {
buf.WriteRune(' ')
buf.WriteString(err)
}
}
return strings.TrimSpace(buf.String())
}
func TestCLsTriage(t *testing.T) {
t.Parallel()
Convey("Component's PCL deps triage", t, func() {
// Truncate start time point s.t. easy to see diff in test failures.
epoch := testclock.TestRecentTimeUTC.Truncate(10000 * time.Second)
dryRun := func(t time.Time) *run.Trigger {
return &run.Trigger{Mode: string(run.DryRun), Time: timestamppb.New(t)}
}
fullRun := func(t time.Time) *run.Trigger {
return &run.Trigger{Mode: string(run.FullRun), Time: timestamppb.New(t)}
}
newPatchsetTrigger := func(t time.Time) *run.Trigger {
return &run.Trigger{Mode: string(run.NewPatchsetRun), Time: timestamppb.New(t)}
}
sup := &simplePMState{
pb: &prjpb.PState{},
cgs: []*prjcfg.ConfigGroup{
{ID: "hash/singular", Content: &cfgpb.ConfigGroup{}},
{ID: "hash/combinable", Content: &cfgpb.ConfigGroup{CombineCls: &cfgpb.CombineCLs{}}},
{ID: "hash/another", Content: &cfgpb.ConfigGroup{}},
{ID: "hash/npr", Content: &cfgpb.ConfigGroup{
Verifiers: &cfgpb.Verifiers{
Tryjob: &cfgpb.Verifiers_Tryjob{
Builders: []*cfgpb.Verifiers_Tryjob_Builder{
{Name: "nprBuilder", ModeAllowlist: []string{string(run.NewPatchsetRun)}},
},
},
},
}},
},
}
pm := pmState{sup}
const singIdx, combIdx, anotherIdx, nprIdx = 0, 1, 2, 3
do := func(c *prjpb.Component) map[int64]*clInfo {
sup.pb.Components = []*prjpb.Component{c} // include it in backup
backup := prjpb.PState{}
proto.Merge(&backup, sup.pb)
cls := triageCLs(c, pm)
So(sup.pb, ShouldResembleProto, &backup) // must not be modified
return cls
}
Convey("Typical 1 CL component without deps", func() {
sup.pb.Pcls = []*prjpb.PCL{{
Clid: 1,
ConfigGroupIndexes: []int32{singIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: nil,
}}
Convey("Ready without runs", func() {
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: nil,
triagedCL: triagedCL{
purgeReasons: nil,
cqReady: true,
deps: &triagedDeps{},
},
}
So(cls[1], shouldResembleTriagedCL, expected)
Convey("ready may also be in 1+ Runs", func() {
cls := do(&prjpb.Component{
Clids: []int64{1},
Pruns: []*prjpb.PRun{{Id: "r1", Clids: []int64{1}, Mode: string(run.DryRun)}},
})
So(cls, ShouldHaveLength, 1)
expected.runIndexes = []int32{0}
So(cls[1], shouldResembleTriagedCL, expected)
})
})
Convey("CL already with Errors is not ready", func() {
sup.pb.Pcls[0].PurgeReasons = []*prjpb.PurgeReason{
{
ClError: &changelist.CLError{
Kind: &changelist.CLError_OwnerLacksEmail{OwnerLacksEmail: true},
},
ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
},
{
ClError: &changelist.CLError{
Kind: &changelist.CLError_UnsupportedMode{UnsupportedMode: "CUSTOM_RUN"},
},
ApplyTo: &prjpb.PurgeReason_AllActiveTriggers{AllActiveTriggers: true},
},
}
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: nil,
triagedCL: triagedCL{
purgeReasons: sup.pb.Pcls[0].GetPurgeReasons(),
},
}
So(cls[1], shouldResembleTriagedCL, expected)
})
Convey("Already purged is never ready", func() {
sup.pb.PurgingCls = []*prjpb.PurgingCL{{
Clid: 1,
ApplyTo: &prjpb.PurgingCL_AllActiveTriggers{AllActiveTriggers: true},
}}
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: pm.PurgingCL(1),
triagedCL: triagedCL{
purgeReasons: nil,
cqReady: false,
deps: &triagedDeps{},
},
}
So(cls[1], shouldResembleTriagedCL, expected)
Convey("not even if inside 1+ Runs", func() {
cls := do(&prjpb.Component{
Clids: []int64{1},
Pruns: []*prjpb.PRun{{Id: "r1", Clids: []int64{1}, Mode: string(run.DryRun)}},
})
So(cls, ShouldHaveLength, 1)
expected.runIndexes = []int32{0}
So(cls[1], shouldResembleTriagedCL, expected)
})
})
Convey("CL matching several config groups is never ready", func() {
sup.PCL(1).ConfigGroupIndexes = []int32{singIdx, anotherIdx}
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: nil,
triagedCL: triagedCL{
purgeReasons: []*prjpb.PurgeReason{{
ClError: &changelist.CLError{
Kind: &changelist.CLError_WatchedByManyConfigGroups_{
WatchedByManyConfigGroups: &changelist.CLError_WatchedByManyConfigGroups{
ConfigGroups: []string{"singular", "another"},
},
},
},
ApplyTo: &prjpb.PurgeReason_Triggers{Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)}},
}},
cqReady: false,
deps: nil, // not checked.
},
}
So(cls[1], shouldResembleTriagedCL, expected)
Convey("not even if inside 1+ Runs, but Run protects from purging", func() {
cls := do(&prjpb.Component{
Clids: []int64{1},
Pruns: []*prjpb.PRun{{Id: "r1", Clids: []int64{1}, Mode: string(run.DryRun)}},
})
So(cls, ShouldHaveLength, 1)
expected.runIndexes = []int32{0}
expected.purgeReasons = nil
So(cls[1], shouldResembleTriagedCL, expected)
})
})
})
Convey("Typical 1 CL component with new patchset run enabled", func() {
sup.pb.Pcls = []*prjpb.PCL{{
Clid: 1,
ConfigGroupIndexes: []int32{nprIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{
CqVoteTrigger: dryRun(epoch),
NewPatchsetRunTrigger: newPatchsetTrigger(epoch),
},
Submitted: false,
Deps: nil,
}}
Convey("new patchset upload on CL with CQ vote run being purged", func() {
sup.pb.PurgingCls = append(sup.pb.PurgingCls, &prjpb.PurgingCL{
Clid: 1,
ApplyTo: &prjpb.PurgingCL_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: dryRun(epoch),
},
},
})
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: &prjpb.PurgingCL{
Clid: 1,
ApplyTo: &prjpb.PurgingCL_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: dryRun(epoch),
},
},
},
triagedCL: triagedCL{
purgeReasons: nil,
cqReady: false,
nprReady: true,
deps: &triagedDeps{},
},
}
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
So(cls[1], shouldResembleTriagedCL, expected)
})
Convey("new patch upload on CL with NPR being purged", func() {
sup.pb.PurgingCls = append(sup.pb.PurgingCls, &prjpb.PurgingCL{
Clid: 1,
ApplyTo: &prjpb.PurgingCL_Triggers{
Triggers: &run.Triggers{
NewPatchsetRunTrigger: newPatchsetTrigger(epoch),
},
},
})
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: nil,
purgingCL: &prjpb.PurgingCL{
Clid: 1,
ApplyTo: &prjpb.PurgingCL_Triggers{
Triggers: &run.Triggers{
NewPatchsetRunTrigger: newPatchsetTrigger(epoch),
},
},
},
triagedCL: triagedCL{
purgeReasons: nil,
cqReady: true,
nprReady: false,
deps: &triagedDeps{},
},
}
cls := do(&prjpb.Component{Clids: []int64{1}})
So(cls, ShouldHaveLength, 1)
So(cls[1], shouldResembleTriagedCL, expected)
})
})
Convey("Triage with ongoing New Pachset Run", func() {
sup.pb.Pcls = []*prjpb.PCL{{
Clid: 1,
ConfigGroupIndexes: []int32{nprIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{
NewPatchsetRunTrigger: newPatchsetTrigger(epoch),
},
Submitted: false,
Deps: nil,
}}
expected := &clInfo{
pcl: pm.MustPCL(1),
runIndexes: []int32{0},
triagedCL: triagedCL{
purgeReasons: nil,
nprReady: true,
},
}
cls := do(&prjpb.Component{
Clids: []int64{1},
Pruns: []*prjpb.PRun{{Id: "r1", Clids: []int64{1}, Mode: string(run.NewPatchsetRun)}},
})
So(cls, ShouldHaveLength, 1)
So(cls[1], shouldResembleTriagedCL, expected)
})
Convey("Single CL Runs: typical CL stack", func() {
// CL 3 depends on 2, which in turn depends 1.
// Start configuration is each one is Dry-run triggered.
sup.pb.Pcls = []*prjpb.PCL{
{
Clid: 1,
ConfigGroupIndexes: []int32{singIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: nil,
},
{
Clid: 2,
ConfigGroupIndexes: []int32{singIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_HARD}},
},
{
Clid: 3,
ConfigGroupIndexes: []int32{singIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_SOFT}, {Clid: 2, Kind: changelist.DepKind_HARD}},
},
}
Convey("Dry run everywhere is OK", func() {
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
So(cls, ShouldHaveLength, 3)
for _, info := range cls {
So(info.cqReady, ShouldBeTrue)
So(info.deps.OK(), ShouldBeTrue)
So(info.lastCQVoteTriggered(), ShouldResemble, epoch)
}
})
Convey("Full run at the bottom (CL1) and dry run elsewhere is also OK", func() {
sup.PCL(1).Triggers = &run.Triggers{CqVoteTrigger: fullRun(epoch)}
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
So(cls, ShouldHaveLength, 3)
for _, info := range cls {
So(info.cqReady, ShouldBeTrue)
So(info.deps.OK(), ShouldBeTrue)
}
})
Convey("Full Run on #3 is purged if its deps aren't submitted, but NPR is not affected", func() {
sup.PCL(1).Triggers = &run.Triggers{CqVoteTrigger: dryRun(epoch), NewPatchsetRunTrigger: newPatchsetTrigger(epoch)}
sup.PCL(2).Triggers = &run.Triggers{CqVoteTrigger: dryRun(epoch), NewPatchsetRunTrigger: newPatchsetTrigger(epoch)}
sup.PCL(3).Triggers = &run.Triggers{CqVoteTrigger: fullRun(epoch), NewPatchsetRunTrigger: newPatchsetTrigger(epoch)}
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
So(cls[1].cqReady, ShouldBeTrue)
So(cls[2].cqReady, ShouldBeTrue)
So(cls[1].nprReady, ShouldBeTrue)
So(cls[2].nprReady, ShouldBeTrue)
So(cls[3].nprReady, ShouldBeTrue)
So(cls[3], shouldResembleTriagedCL, &clInfo{
pcl: sup.PCL(3),
triagedCL: triagedCL{
cqReady: false,
nprReady: true,
purgeReasons: []*prjpb.PurgeReason{{
ClError: &changelist.CLError{
Kind: &changelist.CLError_InvalidDeps_{
InvalidDeps: &changelist.CLError_InvalidDeps{
SingleFullDeps: sup.PCL(3).GetDeps(),
},
},
},
ApplyTo: &prjpb.PurgeReason_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: fullRun(epoch),
},
},
}},
deps: &triagedDeps{
lastCQVoteTriggered: epoch,
invalidDeps: &changelist.CLError_InvalidDeps{
SingleFullDeps: sup.PCL(3).GetDeps(),
},
},
},
})
})
Convey("CL1 submitted but still with Run, CL2 CQ+1 is OK, CL3 CQ+2 is purged", func() {
sup.PCL(1).Triggers = nil
sup.PCL(1).Submitted = true
// PCL(2) is still not submitted.
sup.PCL(3).Triggers = &run.Triggers{CqVoteTrigger: fullRun(epoch)}
cls := do(&prjpb.Component{
Clids: []int64{1, 2, 3},
Pruns: []*prjpb.PRun{{Id: "r1", Clids: []int64{1}, Mode: string(run.FullRun)}},
})
So(cls[2].cqReady, ShouldBeTrue)
So(cls[2].deps, cvtesting.SafeShouldResemble, &triagedDeps{
submitted: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_HARD}},
})
So(cls[3], shouldResembleTriagedCL, &clInfo{
pcl: sup.PCL(3),
triagedCL: triagedCL{
cqReady: false,
purgeReasons: []*prjpb.PurgeReason{{
ClError: &changelist.CLError{
Kind: &changelist.CLError_InvalidDeps_{
InvalidDeps: &changelist.CLError_InvalidDeps{
SingleFullDeps: []*changelist.Dep{{Clid: 2, Kind: changelist.DepKind_HARD}},
},
},
},
ApplyTo: &prjpb.PurgeReason_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: fullRun(epoch),
},
},
}},
deps: &triagedDeps{
lastCQVoteTriggered: epoch.UTC(),
submitted: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_SOFT}},
invalidDeps: &changelist.CLError_InvalidDeps{
SingleFullDeps: []*changelist.Dep{{Clid: 2, Kind: changelist.DepKind_HARD}},
},
},
},
})
})
})
Convey("Multiple CL Runs: 1<->2 and 3 depending on both", func() {
// CL 3 depends on 1 and 2, while 1 and 2 depend on each other (e.g. via
// CQ-Depend). Start configuration is each one is Dry-run triggered.
sup.pb.Pcls = []*prjpb.PCL{
{
Clid: 1,
ConfigGroupIndexes: []int32{combIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: []*changelist.Dep{{Clid: 2, Kind: changelist.DepKind_SOFT}},
},
{
Clid: 2,
ConfigGroupIndexes: []int32{combIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_SOFT}},
},
{
Clid: 3,
ConfigGroupIndexes: []int32{combIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Submitted: false,
Deps: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_SOFT}, {Clid: 2, Kind: changelist.DepKind_SOFT}},
},
}
Convey("Happy case: all are ready", func() {
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
So(cls, ShouldHaveLength, 3)
for _, info := range cls {
So(info.cqReady, ShouldBeTrue)
So(info.deps.OK(), ShouldBeTrue)
}
})
Convey("Full Run on #1 and #2 can co-exist, but Dry run on #3 is purged", func() {
// This scenario documents current CQDaemon behavior. This isn't desired
// long term though.
sup.PCL(1).Triggers = &run.Triggers{CqVoteTrigger: fullRun(epoch)}
sup.PCL(2).Triggers = &run.Triggers{CqVoteTrigger: fullRun(epoch)}
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
So(cls[1].cqReady, ShouldBeTrue)
So(cls[2].cqReady, ShouldBeTrue)
So(cls[3], shouldResembleTriagedCL, &clInfo{
pcl: sup.PCL(3),
triagedCL: triagedCL{
cqReady: false,
purgeReasons: []*prjpb.PurgeReason{{
ClError: &changelist.CLError{
Kind: &changelist.CLError_InvalidDeps_{
InvalidDeps: &changelist.CLError_InvalidDeps{
CombinableMismatchedMode: sup.PCL(3).GetDeps(),
},
},
},
ApplyTo: &prjpb.PurgeReason_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: dryRun(epoch),
},
},
}},
deps: &triagedDeps{
lastCQVoteTriggered: epoch,
invalidDeps: &changelist.CLError_InvalidDeps{
CombinableMismatchedMode: sup.PCL(3).GetDeps(),
},
},
},
})
})
Convey("Dependencies in diff config groups are not allowed", func() {
sup.PCL(1).ConfigGroupIndexes = []int32{combIdx} // depends on 2
sup.PCL(2).ConfigGroupIndexes = []int32{anotherIdx} // depends on 1
sup.PCL(3).ConfigGroupIndexes = []int32{combIdx} // depends on 1(OK) and 2.
cls := do(&prjpb.Component{Clids: []int64{1, 2, 3}})
for _, info := range cls {
So(info.cqReady, ShouldBeFalse)
So(info.purgeReasons, ShouldResembleProto, []*prjpb.PurgeReason{{
ClError: &changelist.CLError{
Kind: &changelist.CLError_InvalidDeps_{
InvalidDeps: info.triagedCL.deps.invalidDeps,
},
},
ApplyTo: &prjpb.PurgeReason_Triggers{
Triggers: &run.Triggers{
CqVoteTrigger: dryRun(epoch),
},
},
}})
}
Convey("unless dependency is already submitted", func() {
sup.PCL(2).Triggers = nil
sup.PCL(2).Submitted = true
cls := do(&prjpb.Component{Clids: []int64{1, 3}})
for _, info := range cls {
So(info.cqReady, ShouldBeTrue)
So(info.purgeReasons, ShouldBeNil)
So(info.deps.submitted, ShouldResembleProto, []*changelist.Dep{{Clid: 2, Kind: changelist.DepKind_SOFT}})
}
})
})
})
Convey("Ready CLs can have not yet loaded dependencies", func() {
sup.pb.Pcls = []*prjpb.PCL{
{
Clid: 1,
Status: prjpb.PCL_UNKNOWN,
},
{
Clid: 2,
ConfigGroupIndexes: []int32{combIdx},
Status: prjpb.PCL_OK,
Triggers: &run.Triggers{CqVoteTrigger: dryRun(epoch)},
Deps: []*changelist.Dep{{Clid: 1, Kind: changelist.DepKind_SOFT}},
},
}
cls := do(&prjpb.Component{Clids: []int64{2}})
So(cls[2], shouldResembleTriagedCL, &clInfo{
pcl: sup.PCL(2),
triagedCL: triagedCL{
cqReady: true,
deps: &triagedDeps{notYetLoaded: sup.PCL(2).GetDeps()},
},
})
})
})
}