blob: fdd6d03a7459f2eb756690d4b66dd0b5ca4a4996 [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 handler
import (
"fmt"
"testing"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/common/clock"
gerritpb "go.chromium.org/luci/common/proto/gerrit"
"go.chromium.org/luci/gae/service/datastore"
cfgpb "go.chromium.org/luci/cv/api/config/v2"
"go.chromium.org/luci/cv/internal/changelist"
"go.chromium.org/luci/cv/internal/common"
"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
"go.chromium.org/luci/cv/internal/cvtesting"
gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake"
"go.chromium.org/luci/cv/internal/gerrit/trigger"
"go.chromium.org/luci/cv/internal/run"
"go.chromium.org/luci/cv/internal/run/eventpb"
"go.chromium.org/luci/cv/internal/run/impl/state"
. "github.com/smartystreets/goconvey/convey"
. "go.chromium.org/luci/common/testing/assertions"
)
func TestOnCLsUpdated(t *testing.T) {
Convey("OnCLsUpdated", t, func() {
ct := cvtesting.Test{}
ctx, cancel := ct.SetUp()
defer cancel()
const (
lProject = "chromium"
gHost = "x-review.example.com"
committers = "committer-group"
dryRunners = "dry-runner-group"
)
cfg := &cfgpb.Config{
ConfigGroups: []*cfgpb.ConfigGroup{
{
Name: "main",
Verifiers: &cfgpb.Verifiers{
GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{
CommitterList: []string{committers},
DryRunAccessList: []string{dryRunners},
},
},
},
},
}
prjcfgtest.Create(ctx, lProject, cfg)
h, _ := makeTestHandler(&ct)
// initial state
triggerTime := clock.Now(ctx).UTC()
rs := &state.RunState{
Run: run.Run{
ID: common.MakeRunID(lProject, ct.Clock.Now(), 1, []byte("deadbeef")),
StartTime: triggerTime.Add(1 * time.Minute),
Status: run.Status_RUNNING,
ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0],
CLs: common.CLIDs{1, 2},
Mode: run.DryRun,
},
}
updateCL := func(clID common.CLID, ci *gerritpb.ChangeInfo, ap *changelist.ApplicableConfig, acc *changelist.Access) changelist.CL {
cl := changelist.CL{
ID: clID,
ExternalID: changelist.MustGobID(gHost, ci.GetNumber()),
Snapshot: &changelist.Snapshot{
LuciProject: lProject,
Patchset: ci.GetRevisions()[ci.GetCurrentRevision()].GetNumber(),
Kind: &changelist.Snapshot_Gerrit{
Gerrit: &changelist.Gerrit{
Host: gHost,
Info: ci,
},
},
},
ApplicableConfig: ap,
Access: acc,
}
So(datastore.Put(ctx, &cl), ShouldBeNil)
return cl
}
aplConfigOK := &changelist.ApplicableConfig{Projects: []*changelist.ApplicableConfig_Project{
{Name: lProject, ConfigGroupIds: prjcfgtest.MustExist(ctx, lProject).ConfigGroupNames},
}}
accessOK := (*changelist.Access)(nil)
const gChange1 = 1
const gChange2 = 2
const gPatchSet1 = 5
const gPatchSet2 = 7
ci1 := gf.CI(
gChange1, gf.PS(gPatchSet1),
gf.Owner("foo"),
gf.CQ(+2, triggerTime, gf.U("foo")),
gf.Approve(),
)
ci2 := gf.CI(
gChange2, gf.PS(gPatchSet2),
gf.Owner("foo"),
gf.CQ(+2, triggerTime, gf.U("foo")),
gf.Approve(),
)
ct.AddMember("foo", committers)
cl1 := updateCL(1, ci1, aplConfigOK, accessOK)
triggers1 := trigger.Find(&trigger.FindInput{ChangeInfo: ci1, ConfigGroup: cfg.GetConfigGroups()[0]})
So(triggers1.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{
Time: timestamppb.New(triggerTime),
Mode: string(run.FullRun),
Email: "foo@example.com",
GerritAccountId: 1,
})
cl2 := updateCL(2, ci2, aplConfigOK, accessOK)
triggers2 := trigger.Find(&trigger.FindInput{ChangeInfo: ci2, ConfigGroup: cfg.GetConfigGroups()[0]})
So(triggers2.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{
Time: timestamppb.New(triggerTime),
Mode: string(run.FullRun),
Email: "foo@example.com",
GerritAccountId: 1,
})
runCLs := []*run.RunCL{
{
ID: 1,
Run: datastore.MakeKey(ctx, common.RunKind, string(rs.ID)),
Detail: cl1.Snapshot,
Trigger: triggers1.GetCqVoteTrigger(),
},
{
ID: 2,
Run: datastore.MakeKey(ctx, common.RunKind, string(rs.ID)),
Detail: cl2.Snapshot,
Trigger: triggers2.GetCqVoteTrigger(),
},
}
So(runCLs[0].Trigger, ShouldNotBeNil) // ensure trigger find is working fine.
So(runCLs[1].Trigger, ShouldNotBeNil) // ensure trigger find is working fine.
So(datastore.Put(ctx, runCLs), ShouldBeNil)
ensureNoop := func() {
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1, 2})
So(err, ShouldBeNil)
So(res.State, ShouldResemble, rs)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
}
Convey("Noop", func() {
statuses := []run.Status{
run.Status_SUCCEEDED,
run.Status_FAILED,
run.Status_CANCELLED,
}
for _, status := range statuses {
Convey(fmt.Sprintf("When Run is %s", status), func() {
rs.Status = status
ensureNoop()
})
}
Convey("When new CL Version", func() {
Convey("is a message update", func() {
newCI1 := proto.Clone(ci1).(*gerritpb.ChangeInfo)
gf.Messages(&gerritpb.ChangeMessageInfo{
Message: "This is a message",
})(newCI1)
updateCL(1, newCI1, aplConfigOK, accessOK)
ensureNoop()
})
Convey("is triggered by different user at the exact same time", func() {
updateCL(1, gf.CI(
gChange1, gf.PS(gPatchSet1),
gf.CQ(+2, triggerTime, gf.U("bar")),
gf.Approve(),
), aplConfigOK, accessOK)
ensureNoop()
})
})
})
Convey("Preserve events for SUBMITTING Run", func() {
rs.Status = run.Status_SUBMITTING
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1})
So(err, ShouldBeNil)
So(res.State, ShouldResemble, rs)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeTrue)
})
Convey("Preserve events for if trigger cancellation is ongoing", func() {
rs.OngoingLongOps = &run.OngoingLongOps{
Ops: map[string]*run.OngoingLongOps_Op{
"op_id": {
Work: &run.OngoingLongOps_Op_CancelTriggers{
CancelTriggers: &run.OngoingLongOps_Op_TriggersCancellation{
Requests: []*run.OngoingLongOps_Op_TriggersCancellation_Request{
{
Clid: 1,
Message: "no perimission to Run",
},
},
},
},
},
},
}
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1})
So(err, ShouldBeNil)
So(res.State, ShouldResemble, rs)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeTrue)
})
runAndVerifyCancelled := func(reason string) {
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1})
So(err, ShouldBeNil)
So(res.State.Status, ShouldEqual, run.Status_CANCELLED)
So(res.State.CancellationReasons, ShouldResemble, []string{reason})
So(res.SideEffectFn, ShouldNotBeNil)
So(res.PreserveEvents, ShouldBeFalse)
}
Convey("Cancels Run on new Patchset", func() {
updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1+1), gf.CQ(+2, triggerTime, gf.U("foo"))), aplConfigOK, accessOK)
runAndVerifyCancelled("the patchset of https://x-review.example.com/c/1 has changed from 5 to 6")
})
Convey("Cancels Run on moved Ref", func() {
updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Ref("refs/heads/new")), aplConfigOK, accessOK)
runAndVerifyCancelled("the ref of https://x-review.example.com/c/1 has moved from refs/heads/main to refs/heads/new")
})
Convey("Cancels Run on removed trigger", func() {
newCI1 := gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(0, triggerTime.Add(1*time.Minute), gf.U("foo")))
So(trigger.Find(&trigger.FindInput{ChangeInfo: newCI1, ConfigGroup: cfg.GetConfigGroups()[0]}), ShouldBeNil)
updateCL(1, newCI1, aplConfigOK, accessOK)
runAndVerifyCancelled("the FULL_RUN trigger on https://x-review.example.com/c/1 has been removed")
})
Convey("Cancels Run on changed mode", func() {
updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+1, triggerTime.Add(1*time.Minute), gf.U("foo"))), aplConfigOK, accessOK)
runAndVerifyCancelled("the triggering vote on https://x-review.example.com/c/1 has requested a different run mode: DRY_RUN")
})
Convey("Cancels Run on change of triggering time", func() {
updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime.Add(2*time.Minute), gf.U("foo"))), aplConfigOK, accessOK)
runAndVerifyCancelled(fmt.Sprintf("the timestamp of the triggering vote on https://x-review.example.com/c/1 has changed from %s to %s", triggerTime, triggerTime.Add(2*time.Minute)))
})
Convey("Change of access level to the CL", func() {
Convey("cancel if another project started watching the same CL", func() {
ac := proto.Clone(aplConfigOK).(*changelist.ApplicableConfig)
ac.Projects = append(ac.Projects, &changelist.ApplicableConfig_Project{
Name: "other-project", ConfigGroupIds: []string{"other-group"},
})
updateCL(1, ci1, ac, accessOK)
runAndVerifyCancelled(fmt.Sprintf("no longer have access to https://x-review.example.com/c/1: watched not only by LUCI Project %q", lProject))
})
Convey("wait if code review access was just lost, potentially due to eventual consistency", func() {
noAccessAt := ct.Clock.Now().Add(42 * time.Second)
acc := &changelist.Access{ByProject: map[string]*changelist.Access_Project{
// Set NoAccessTime to the future, providing some grace period to
// recover.
lProject: {NoAccessTime: timestamppb.New(noAccessAt)},
}}
updateCL(1, ci1, aplConfigOK, acc)
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1})
So(err, ShouldBeNil)
So(res.State, ShouldResemble, rs)
So(res.SideEffectFn, ShouldBeNil)
// Event must be preserved, s.t. the same CL is re-visited later.
So(res.PreserveEvents, ShouldBeTrue)
// And Run Manager must have a task to re-check itself at around
// NoAccessTime.
So(ct.TQ.Tasks().Payloads(), ShouldHaveLength, 1)
So(ct.TQ.Tasks().Payloads()[0].(*eventpb.ManageRunTask).GetRunId(), ShouldResemble, string(rs.ID))
So(ct.TQ.Tasks()[0].ETA, ShouldHappenOnOrBetween, noAccessAt, noAccessAt.Add(time.Second))
})
Convey("cancel if code review access was lost a while ago", func() {
acc := &changelist.Access{ByProject: map[string]*changelist.Access_Project{
lProject: {NoAccessTime: timestamppb.New(ct.Clock.Now())},
}}
updateCL(1, ci1, aplConfigOK, acc)
runAndVerifyCancelled("no longer have access to https://x-review.example.com/c/1: code review site denied access")
})
Convey("wait if access level is unknown", func() {
cl1.Snapshot = nil
cl1.EVersion++
So(datastore.Put(ctx, &cl1), ShouldBeNil)
ensureNoop()
})
})
verifyHasCancelTriggerLongOpScheduled := func(res *Result, expect map[common.CLID]string) {
// The status should be still RUNNING,
// because it has not been cancelled yet.
// It's scheduled to be cancelled.
So(res.State.Status, ShouldEqual, run.Status_RUNNING)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
cancelOp := longOp.GetCancelTriggers()
So(cancelOp.Requests, ShouldHaveLength, len(expect))
for _, req := range cancelOp.Requests {
clid := common.CLID(req.Clid)
So(expect, ShouldContainKey, clid)
So(req.Message, ShouldContainSubstring, expect[clid])
delete(expect, clid)
}
So(expect, ShouldBeEmpty)
So(cancelOp.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED)
}
Convey("Schedules a CancelTrigger long op if the approval was revoked", func() {
Convey("Single CL", func() {
updateCL(1, gf.CI(
gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(),
), aplConfigOK, accessOK)
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1})
So(err, ShouldBeNil)
verifyHasCancelTriggerLongOpScheduled(res, map[common.CLID]string{
1: "CV cannot start a Run because this CL is missing approval.",
2: "CV cannot start a Run due to errors in the following CL(s).",
})
})
Convey("Both CLs", func() {
updateCL(1, gf.CI(
gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(),
), aplConfigOK, accessOK)
updateCL(2, gf.CI(
gChange2, gf.PS(gPatchSet2), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(),
), aplConfigOK, accessOK)
res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1, 2})
So(err, ShouldBeNil)
verifyHasCancelTriggerLongOpScheduled(res, map[common.CLID]string{
1: "CV cannot start a Run because this CL is missing approval.",
2: "CV cannot start a Run because this CL is missing approval.",
})
})
})
})
}