blob: 12ed9b878734fc4e07f0bb474d3f469ea185b892 [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 (
"context"
"fmt"
"testing"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/gae/service/datastore"
cfgpb "go.chromium.org/luci/cv/api/config/v2"
migrationpb "go.chromium.org/luci/cv/api/migration"
"go.chromium.org/luci/cv/internal/changelist"
"go.chromium.org/luci/cv/internal/common"
"go.chromium.org/luci/cv/internal/common/tree"
"go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest"
"go.chromium.org/luci/cv/internal/configs/srvcfg"
"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/impl/state"
"go.chromium.org/luci/cv/internal/run/runtest"
"go.chromium.org/luci/cv/internal/tryjob"
. "github.com/smartystreets/goconvey/convey"
. "go.chromium.org/luci/common/testing/assertions"
)
func TestPoke(t *testing.T) {
t.Parallel()
Convey("Poke", t, func() {
ct := cvtesting.Test{}
ctx, cancel := ct.SetUp()
defer cancel()
const (
lProject = "infra"
gHost = "x-review.example.com"
dryRunners = "dry-runner-group"
gChange = 1
gPatchSet = 5
)
cfg := &cfgpb.Config{
ConfigGroups: []*cfgpb.ConfigGroup{
{
Name: "main",
Verifiers: &cfgpb.Verifiers{
TreeStatus: &cfgpb.Verifiers_TreeStatus{
Url: "tree.example.com",
},
GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{
DryRunAccessList: []string{dryRunners},
},
},
},
},
}
prjcfgtest.Create(ctx, lProject, cfg)
So(srvcfg.SetTestMigrationConfig(ctx, &migrationpb.Settings{
ApiHosts: []*migrationpb.Settings_ApiHost{
{
Host: ct.Env.LogicalHostname,
Prod: true,
ProjectRegexp: []string{".*"},
},
},
UseCvTryjobExecutor: &migrationpb.Settings_UseCVTryjobExecutor{
ProjectRegexp: []string{lProject},
},
}), ShouldBeNil)
h, deps := makeTestHandler(&ct)
rid := common.MakeRunID(lProject, ct.Clock.Now(), gChange, []byte("deadbeef"))
rs := &state.RunState{
Run: run.Run{
ID: rid,
CreateTime: ct.Clock.Now().UTC().Add(-2 * time.Minute),
StartTime: ct.Clock.Now().UTC().Add(-1 * time.Minute),
CLs: common.CLIDs{gChange},
ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0],
UseCVTryjobExecutor: true,
Mode: run.DryRun,
},
}
ci := gf.CI(
gChange, gf.PS(gPatchSet),
gf.Owner("foo"),
gf.CQ(+1, clock.Now(ctx).UTC(), gf.U("foo")),
)
ct.AddMember("foo", dryRunners)
cl := &changelist.CL{
ID: gChange,
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,
},
},
},
}
triggers := trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: cfg.GetConfigGroups()[0]})
So(triggers.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{
Time: timestamppb.New(clock.Now(ctx).UTC()),
Mode: string(run.DryRun),
Email: "foo@example.com",
GerritAccountId: 1,
})
rcl := &run.RunCL{
ID: gChange,
Run: datastore.MakeKey(ctx, common.RunKind, string(rid)),
Detail: cl.Snapshot,
Trigger: triggers.GetCqVoteTrigger(),
}
So(datastore.Put(ctx, cl, rcl), ShouldBeNil)
now := ct.Clock.Now()
ctx = context.WithValue(ctx, &fakeTaskIDKey, "task-foo")
verifyNoOp := func() {
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.State, cvtesting.SafeShouldResemble, rs)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(deps.clUpdater.refreshedCLs, ShouldBeEmpty)
}
Convey("Tree checks", func() {
Convey("Check Tree if condition matches", func() {
rs.Status = run.Status_WAITING_FOR_SUBMISSION
rs.Submission = &run.Submission{
TreeOpen: false,
LastTreeCheckTime: timestamppb.New(now.Add(-1 * time.Minute)),
}
Convey("Open", func() {
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldNotBeNil)
// proceed to submission right away
So(res.State.Status, ShouldEqual, run.Status_SUBMITTING)
So(res.State.Submission, ShouldResembleProto, &run.Submission{
Deadline: timestamppb.New(now.Add(submissionDuration)),
Cls: []int64{gChange},
TaskId: "task-foo",
TreeOpen: true,
LastTreeCheckTime: timestamppb.New(now),
})
})
Convey("Close", func() {
ct.TreeFake.ModifyState(ctx, tree.Closed)
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION)
// record the result and check again after 1 minute.
So(res.State.Submission, ShouldResembleProto, &run.Submission{
TreeOpen: false,
LastTreeCheckTime: timestamppb.New(now),
})
runtest.AssertReceivedPoke(ctx, rid, now.Add(1*time.Minute))
})
Convey("Failed", func() {
ct.TreeFake.ModifyState(ctx, tree.StateUnknown)
ct.TreeFake.InjectErr(fmt.Errorf("error retrieving tree status"))
Convey("Not too long", func() {
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION)
})
Convey("Too long", func() {
rs.Submission.TreeErrorSince = timestamppb.New(now.Add(-11 * time.Minute))
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.State, ShouldNotEqual, rs)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.NewLongOpIDs, ShouldHaveLength, 1)
ct := res.State.OngoingLongOps.Ops[res.State.NewLongOpIDs[0]].GetCancelTriggers()
So(ct.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED)
So(ct.Requests, ShouldHaveLength, 1)
So(ct.Requests[0].Message, ShouldContainSubstring, "Could not submit this CL because the tree status app at tree.example.com repeatedly returned failures")
So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION)
})
})
})
Convey("No-op if condition doesn't match", func() {
Convey("Not in WAITING_FOR_SUBMISSION status", func() {
rs.Status = run.Status_RUNNING
verifyNoOp()
})
Convey("Tree is open in the previous check", func() {
rs.Status = run.Status_WAITING_FOR_SUBMISSION
rs.Submission = &run.Submission{
TreeOpen: true,
LastTreeCheckTime: timestamppb.New(now.Add(-2 * time.Minute)),
}
verifyNoOp()
})
Convey("Last Tree check is too recent", func() {
rs.Status = run.Status_WAITING_FOR_SUBMISSION
rs.Submission = &run.Submission{
TreeOpen: false,
LastTreeCheckTime: timestamppb.New(now.Add(-1 * time.Second)),
}
verifyNoOp()
})
})
})
Convey("CLs Refresh", func() {
Convey("No-op if finalized", func() {
rs.Status = run.Status_CANCELLED
verifyNoOp()
})
Convey("No-op if recently created", func() {
rs.CreateTime = ct.Clock.Now()
rs.LatestCLsRefresh = time.Time{}
verifyNoOp()
})
Convey("No-op if recently refreshed", func() {
rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval / 2)
verifyNoOp()
})
Convey("Schedule refresh", func() {
verifyScheduled := func() {
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State, ShouldNotEqual, rs)
So(res.State.LatestCLsRefresh, ShouldResemble, datastore.RoundTime(ct.Clock.Now().UTC()))
So(deps.clUpdater.refreshedCLs.Contains(1), ShouldBeTrue)
}
Convey("For the first time", func() {
rs.CreateTime = ct.Clock.Now().Add(-clRefreshInterval - time.Second)
rs.LatestCLsRefresh = time.Time{}
verifyScheduled()
})
Convey("For the second (and later) time", func() {
rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval - time.Second)
verifyScheduled()
})
})
Convey("Run fails if no longer eligible", func() {
rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval - time.Second)
ct.ResetMockedAuthDB(ctx)
// verify that it did not schedule refresh but CancelTrigger.
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.Status, ShouldEqual, rs.Status)
So(deps.clUpdater.refreshedCLs, ShouldBeEmpty)
longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]]
cancelOp := longOp.GetCancelTriggers()
So(cancelOp.Requests, ShouldHaveLength, 1)
So(cancelOp.Requests[0], ShouldResembleProto,
&run.OngoingLongOps_Op_TriggersCancellation_Request{
Clid: int64(gChange),
Message: "CV cannot start a Run for `foo@example.com` because the user is not a dry-runner.",
Notify: []run.OngoingLongOps_Op_TriggersCancellation_Whom{
run.OngoingLongOps_Op_TriggersCancellation_OWNER,
run.OngoingLongOps_Op_TriggersCancellation_CQ_VOTERS,
},
AddToAttention: []run.OngoingLongOps_Op_TriggersCancellation_Whom{
run.OngoingLongOps_Op_TriggersCancellation_OWNER,
run.OngoingLongOps_Op_TriggersCancellation_CQ_VOTERS,
},
AddToAttentionReason: "CQ/CV Run failed",
},
)
So(cancelOp.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED)
})
})
Convey("Tryjobs Refresh", func() {
reqmt := &tryjob.Requirement{
Definitions: []*tryjob.Definition{
{
Backend: &tryjob.Definition_Buildbucket_{
Buildbucket: &tryjob.Definition_Buildbucket{
Builder: &buildbucketpb.BuilderID{
Project: "test_proj",
Bucket: "test_bucket",
Builder: "test_builder",
},
},
},
},
},
}
rs.Tryjobs = &run.Tryjobs{
Requirement: reqmt,
State: &tryjob.ExecutionState{
Requirement: reqmt,
Executions: []*tryjob.ExecutionState_Execution{
{
Attempts: []*tryjob.ExecutionState_Execution_Attempt{
{
TryjobId: 1,
ExternalId: string(tryjob.MustBuildbucketID("bb.example.com", 456)),
Status: tryjob.Status_ENDED,
},
{
TryjobId: 2,
ExternalId: string(tryjob.MustBuildbucketID("bb.example.com", 123)),
Status: tryjob.Status_TRIGGERED,
},
},
},
},
},
}
Convey("No-op if finalized", func() {
rs.Status = run.Status_CANCELLED
verifyNoOp()
})
Convey("No-op if recently created", func() {
rs.CreateTime = ct.Clock.Now()
rs.LatestTryjobsRefresh = time.Time{}
verifyNoOp()
})
Convey("No-op if recently refreshed", func() {
rs.LatestTryjobsRefresh = ct.Clock.Now().Add(-tryjobRefreshInterval / 2)
verifyNoOp()
})
Convey("Schedule refresh", func() {
verifyScheduled := func() {
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State, ShouldNotEqual, rs)
So(res.State.LatestTryjobsRefresh, ShouldEqual, datastore.RoundTime(ct.Clock.Now().UTC()))
So(deps.tjNotifier.updateScheduled, ShouldResemble, common.TryjobIDs{2})
}
Convey("For the first time", func() {
rs.CreateTime = ct.Clock.Now().Add(-tryjobRefreshInterval - time.Second)
rs.LatestTryjobsRefresh = time.Time{}
verifyScheduled()
})
Convey("For the second (and later) time", func() {
rs.LatestTryjobsRefresh = ct.Clock.Now().Add(-tryjobRefreshInterval - time.Second)
verifyScheduled()
})
Convey("Skip if external id is not present", func() {
execution := rs.Tryjobs.GetState().GetExecutions()[0]
tryjob.LatestAttempt(execution).ExternalId = ""
_, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(deps.tjNotifier.updateScheduled, ShouldBeEmpty)
})
Convey("Skip if tryjob is not in Triggered status", func() {
execution := rs.Tryjobs.GetState().GetExecutions()[0]
tryjob.LatestAttempt(execution).Status = tryjob.Status_ENDED
_, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(deps.tjNotifier.updateScheduled, ShouldBeEmpty)
})
})
})
Convey("Check UseCVTryjobExecutor", func() {
Convey("Skip if UseCVTryjobExecutor is false", func() {
rs.UseCVTryjobExecutor = false
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.UseCVTryjobExecutor, ShouldBeFalse)
})
Convey("Skip if has ExecuteTryjobs long op", func() {
enqueueTryjobsUpdatedTask(ctx, rs, common.TryjobIDs{123})
So(srvcfg.SetTestMigrationConfig(ctx, &migrationpb.Settings{
ApiHosts: []*migrationpb.Settings_ApiHost{
{
Host: ct.Env.LogicalHostname,
Prod: true,
ProjectRegexp: []string{".*"},
},
},
UseCvTryjobExecutor: &migrationpb.Settings_UseCVTryjobExecutor{
ProjectRegexpExclude: []string{lProject},
},
}), ShouldBeNil)
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.UseCVTryjobExecutor, ShouldBeTrue)
})
Convey("Change UseCVTryjobExecutor to false", func() {
So(srvcfg.SetTestMigrationConfig(ctx, &migrationpb.Settings{
ApiHosts: []*migrationpb.Settings_ApiHost{
{
Host: ct.Env.LogicalHostname,
Prod: true,
ProjectRegexp: []string{".*"},
},
},
UseCvTryjobExecutor: &migrationpb.Settings_UseCVTryjobExecutor{
ProjectRegexpExclude: []string{lProject},
},
}), ShouldBeNil)
res, err := h.Poke(ctx, rs)
So(err, ShouldBeNil)
So(res.SideEffectFn, ShouldBeNil)
So(res.PreserveEvents, ShouldBeFalse)
So(res.PostProcessFn, ShouldBeNil)
So(res.State.UseCVTryjobExecutor, ShouldBeFalse)
})
})
})
}