go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/handler/poke_test.go (about) 1 // Copyright 2021 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 handler 16 17 import ( 18 "context" 19 "fmt" 20 "testing" 21 "time" 22 23 "google.golang.org/protobuf/types/known/timestamppb" 24 25 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 26 "go.chromium.org/luci/common/clock" 27 "go.chromium.org/luci/gae/service/datastore" 28 29 cfgpb "go.chromium.org/luci/cv/api/config/v2" 30 "go.chromium.org/luci/cv/internal/changelist" 31 "go.chromium.org/luci/cv/internal/common" 32 "go.chromium.org/luci/cv/internal/common/tree" 33 "go.chromium.org/luci/cv/internal/configs/prjcfg/prjcfgtest" 34 "go.chromium.org/luci/cv/internal/cvtesting" 35 "go.chromium.org/luci/cv/internal/gerrit" 36 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 37 "go.chromium.org/luci/cv/internal/gerrit/trigger" 38 "go.chromium.org/luci/cv/internal/run" 39 "go.chromium.org/luci/cv/internal/run/impl/state" 40 "go.chromium.org/luci/cv/internal/run/runtest" 41 "go.chromium.org/luci/cv/internal/tryjob" 42 43 . "github.com/smartystreets/goconvey/convey" 44 . "go.chromium.org/luci/common/testing/assertions" 45 ) 46 47 func TestPoke(t *testing.T) { 48 t.Parallel() 49 50 Convey("Poke", t, func() { 51 ct := cvtesting.Test{} 52 ctx, cancel := ct.SetUp(t) 53 defer cancel() 54 55 const ( 56 lProject = "infra" 57 gHost = "x-review.example.com" 58 dryRunners = "dry-runner-group" 59 gChange = 1 60 gPatchSet = 5 61 ) 62 63 cfg := &cfgpb.Config{ 64 ConfigGroups: []*cfgpb.ConfigGroup{ 65 { 66 Name: "main", 67 Verifiers: &cfgpb.Verifiers{ 68 TreeStatus: &cfgpb.Verifiers_TreeStatus{ 69 Url: "tree.example.com", 70 }, 71 GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{ 72 DryRunAccessList: []string{dryRunners}, 73 }, 74 }, 75 }, 76 }, 77 } 78 prjcfgtest.Create(ctx, lProject, cfg) 79 h, deps := makeTestHandler(&ct) 80 81 rid := common.MakeRunID(lProject, ct.Clock.Now(), gChange, []byte("deadbeef")) 82 rs := &state.RunState{ 83 Run: run.Run{ 84 ID: rid, 85 CreateTime: ct.Clock.Now().UTC().Add(-2 * time.Minute), 86 StartTime: ct.Clock.Now().UTC().Add(-1 * time.Minute), 87 CLs: common.CLIDs{gChange}, 88 ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0], 89 Status: run.Status_RUNNING, 90 Mode: run.DryRun, 91 }, 92 } 93 94 ci := gf.CI( 95 gChange, gf.PS(gPatchSet), 96 gf.Owner("foo"), 97 gf.CQ(+1, clock.Now(ctx).UTC(), gf.U("foo")), 98 ) 99 ct.AddMember("foo", dryRunners) 100 cl := &changelist.CL{ 101 ID: gChange, 102 ExternalID: changelist.MustGobID(gHost, ci.GetNumber()), 103 Snapshot: &changelist.Snapshot{ 104 LuciProject: lProject, 105 Patchset: ci.GetRevisions()[ci.GetCurrentRevision()].GetNumber(), 106 Kind: &changelist.Snapshot_Gerrit{ 107 Gerrit: &changelist.Gerrit{ 108 Host: gHost, 109 Info: ci, 110 }, 111 }, 112 }, 113 } 114 triggers := trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: cfg.GetConfigGroups()[0]}) 115 So(triggers.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{ 116 Time: timestamppb.New(clock.Now(ctx).UTC()), 117 Mode: string(run.DryRun), 118 Email: "foo@example.com", 119 GerritAccountId: 1, 120 }) 121 rcl := &run.RunCL{ 122 ID: gChange, 123 Run: datastore.MakeKey(ctx, common.RunKind, string(rid)), 124 Detail: cl.Snapshot, 125 Trigger: triggers.GetCqVoteTrigger(), 126 } 127 So(datastore.Put(ctx, cl, rcl), ShouldBeNil) 128 129 now := ct.Clock.Now() 130 ctx = context.WithValue(ctx, &fakeTaskIDKey, "task-foo") 131 132 verifyNoOp := func() { 133 res, err := h.Poke(ctx, rs) 134 So(err, ShouldBeNil) 135 So(res.State, cvtesting.SafeShouldResemble, rs) 136 So(res.SideEffectFn, ShouldBeNil) 137 So(res.PreserveEvents, ShouldBeFalse) 138 So(res.PostProcessFn, ShouldBeNil) 139 So(deps.clUpdater.refreshedCLs, ShouldBeEmpty) 140 } 141 142 Convey("Cancels run exceeding max duration", func() { 143 ct.Clock.Add(2 * common.MaxRunTotalDuration) 144 res, err := h.Poke(ctx, rs) 145 So(err, ShouldBeNil) 146 So(res.State.Status, ShouldEqual, run.Status_CANCELLED) 147 }) 148 149 Convey("Tree checks", func() { 150 Convey("Check Tree if condition matches", func() { 151 // WAITING_FOR_SUBMISSION makes sense only for FullRun. 152 // It's an error condition, 153 // if run == DryRun, but status == WAITING_FOR_SUBMISSION. 154 rs.Mode = run.FullRun 155 rs.Status = run.Status_WAITING_FOR_SUBMISSION 156 rs.Submission = &run.Submission{ 157 TreeOpen: false, 158 LastTreeCheckTime: timestamppb.New(now.Add(-1 * time.Minute)), 159 } 160 161 Convey("Open", func() { 162 res, err := h.Poke(ctx, rs) 163 So(err, ShouldBeNil) 164 So(res.SideEffectFn, ShouldBeNil) 165 So(res.PreserveEvents, ShouldBeFalse) 166 So(res.PostProcessFn, ShouldNotBeNil) 167 // proceed to submission right away 168 So(res.State.Status, ShouldEqual, run.Status_SUBMITTING) 169 So(res.State.Submission, ShouldResembleProto, &run.Submission{ 170 Deadline: timestamppb.New(now.Add(defaultSubmissionDuration)), 171 Cls: []int64{gChange}, 172 TaskId: "task-foo", 173 TreeOpen: true, 174 LastTreeCheckTime: timestamppb.New(now), 175 }) 176 }) 177 178 Convey("Close", func() { 179 ct.TreeFake.ModifyState(ctx, tree.Closed) 180 res, err := h.Poke(ctx, rs) 181 So(err, ShouldBeNil) 182 So(res.SideEffectFn, ShouldBeNil) 183 So(res.PreserveEvents, ShouldBeFalse) 184 So(res.PostProcessFn, ShouldBeNil) 185 So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION) 186 // record the result and check again after 1 minute. 187 So(res.State.Submission, ShouldResembleProto, &run.Submission{ 188 TreeOpen: false, 189 LastTreeCheckTime: timestamppb.New(now), 190 }) 191 runtest.AssertReceivedPoke(ctx, rid, now.Add(1*time.Minute)) 192 }) 193 194 Convey("Failed", func() { 195 ct.TreeFake.ModifyState(ctx, tree.StateUnknown) 196 ct.TreeFake.InjectErr(fmt.Errorf("error retrieving tree status")) 197 Convey("Not too long", func() { 198 res, err := h.Poke(ctx, rs) 199 So(err, ShouldBeNil) 200 So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION) 201 }) 202 203 Convey("Too long", func() { 204 rs.Submission.TreeErrorSince = timestamppb.New(now.Add(-11 * time.Minute)) 205 res, err := h.Poke(ctx, rs) 206 So(err, ShouldBeNil) 207 So(res.State, ShouldNotPointTo, rs) 208 So(res.SideEffectFn, ShouldBeNil) 209 So(res.PreserveEvents, ShouldBeFalse) 210 So(res.PostProcessFn, ShouldBeNil) 211 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 212 ct := res.State.OngoingLongOps.Ops[res.State.NewLongOpIDs[0]].GetResetTriggers() 213 So(ct.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED) 214 So(ct.Requests, ShouldHaveLength, 1) 215 So(ct.Requests[0].Message, ShouldContainSubstring, "Could not submit this CL because the tree status app at tree.example.com repeatedly returned failures") 216 So(res.State.Status, ShouldEqual, run.Status_WAITING_FOR_SUBMISSION) 217 Convey("Reset trigger on root CL only", func() { 218 rs.CLs = append(rs.CLs, cl.ID+1000) 219 rs.RootCL = cl.ID 220 res, err := h.Poke(ctx, rs) 221 So(err, ShouldBeNil) 222 So(res.State.NewLongOpIDs, ShouldHaveLength, 1) 223 ct := res.State.OngoingLongOps.Ops[res.State.NewLongOpIDs[0]].GetResetTriggers() 224 So(ct.Requests, ShouldHaveLength, 1) 225 So(ct.Requests[0].Clid, ShouldEqual, rs.RootCL) 226 }) 227 }) 228 }) 229 }) 230 231 Convey("No-op if condition doesn't match", func() { 232 Convey("Not in WAITING_FOR_SUBMISSION status", func() { 233 rs.Status = run.Status_RUNNING 234 verifyNoOp() 235 }) 236 237 Convey("Tree is open in the previous check", func() { 238 rs.Status = run.Status_WAITING_FOR_SUBMISSION 239 rs.Submission = &run.Submission{ 240 TreeOpen: true, 241 LastTreeCheckTime: timestamppb.New(now.Add(-2 * time.Minute)), 242 } 243 verifyNoOp() 244 }) 245 246 Convey("Last Tree check is too recent", func() { 247 rs.Status = run.Status_WAITING_FOR_SUBMISSION 248 rs.Submission = &run.Submission{ 249 TreeOpen: false, 250 LastTreeCheckTime: timestamppb.New(now.Add(-1 * time.Second)), 251 } 252 verifyNoOp() 253 }) 254 }) 255 }) 256 257 Convey("CLs Refresh", func() { 258 Convey("No-op if finalized", func() { 259 rs.Status = run.Status_CANCELLED 260 verifyNoOp() 261 }) 262 Convey("No-op if recently created", func() { 263 rs.CreateTime = ct.Clock.Now() 264 rs.LatestCLsRefresh = time.Time{} 265 verifyNoOp() 266 }) 267 Convey("No-op if recently refreshed", func() { 268 rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval / 2) 269 verifyNoOp() 270 }) 271 Convey("Schedule refresh", func() { 272 verifyScheduled := func() { 273 res, err := h.Poke(ctx, rs) 274 So(err, ShouldBeNil) 275 So(res.SideEffectFn, ShouldBeNil) 276 So(res.PreserveEvents, ShouldBeFalse) 277 So(res.PostProcessFn, ShouldBeNil) 278 So(res.State, ShouldNotPointTo, rs) 279 So(res.State.LatestCLsRefresh, ShouldResemble, datastore.RoundTime(ct.Clock.Now().UTC())) 280 So(deps.clUpdater.refreshedCLs.Contains(1), ShouldBeTrue) 281 } 282 Convey("For the first time", func() { 283 rs.CreateTime = ct.Clock.Now().Add(-clRefreshInterval - time.Second) 284 rs.LatestCLsRefresh = time.Time{} 285 verifyScheduled() 286 }) 287 Convey("For the second (and later) time", func() { 288 rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval - time.Second) 289 verifyScheduled() 290 }) 291 }) 292 Convey("Run fails if no longer eligible", func() { 293 rs.LatestCLsRefresh = ct.Clock.Now().Add(-clRefreshInterval - time.Second) 294 ct.ResetMockedAuthDB(ctx) 295 296 // verify that it did not schedule refresh but reset triggers. 297 res, err := h.Poke(ctx, rs) 298 So(err, ShouldBeNil) 299 So(res.SideEffectFn, ShouldBeNil) 300 So(res.PreserveEvents, ShouldBeFalse) 301 So(res.PostProcessFn, ShouldBeNil) 302 So(res.State.Status, ShouldEqual, rs.Status) 303 So(deps.clUpdater.refreshedCLs, ShouldBeEmpty) 304 305 longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 306 resetOp := longOp.GetResetTriggers() 307 So(resetOp.Requests, ShouldHaveLength, 1) 308 So(resetOp.Requests[0], ShouldResembleProto, 309 &run.OngoingLongOps_Op_ResetTriggers_Request{ 310 Clid: int64(gChange), 311 Message: "CV cannot start a Run for `foo@example.com` because the user is not a dry-runner.", 312 Notify: gerrit.Whoms{ 313 gerrit.Whom_OWNER, 314 gerrit.Whom_CQ_VOTERS, 315 }, 316 AddToAttention: gerrit.Whoms{ 317 gerrit.Whom_OWNER, 318 gerrit.Whom_CQ_VOTERS, 319 }, 320 AddToAttentionReason: "CQ/CV Run failed", 321 }, 322 ) 323 So(resetOp.RunStatusIfSucceeded, ShouldEqual, run.Status_FAILED) 324 }) 325 }) 326 327 Convey("Tryjobs Refresh", func() { 328 reqmt := &tryjob.Requirement{ 329 Definitions: []*tryjob.Definition{ 330 { 331 Backend: &tryjob.Definition_Buildbucket_{ 332 Buildbucket: &tryjob.Definition_Buildbucket{ 333 Builder: &buildbucketpb.BuilderID{ 334 Project: "test_proj", 335 Bucket: "test_bucket", 336 Builder: "test_builder", 337 }, 338 }, 339 }, 340 }, 341 }, 342 } 343 rs.Tryjobs = &run.Tryjobs{ 344 Requirement: reqmt, 345 State: &tryjob.ExecutionState{ 346 Requirement: reqmt, 347 Executions: []*tryjob.ExecutionState_Execution{ 348 { 349 Attempts: []*tryjob.ExecutionState_Execution_Attempt{ 350 { 351 TryjobId: 1, 352 ExternalId: string(tryjob.MustBuildbucketID("bb.example.com", 456)), 353 Status: tryjob.Status_ENDED, 354 }, 355 { 356 TryjobId: 2, 357 ExternalId: string(tryjob.MustBuildbucketID("bb.example.com", 123)), 358 Status: tryjob.Status_TRIGGERED, 359 }, 360 }, 361 }, 362 }, 363 }, 364 } 365 Convey("No-op if finalized", func() { 366 rs.Status = run.Status_CANCELLED 367 verifyNoOp() 368 }) 369 Convey("No-op if recently created", func() { 370 rs.CreateTime = ct.Clock.Now() 371 rs.LatestTryjobsRefresh = time.Time{} 372 verifyNoOp() 373 }) 374 Convey("No-op if recently refreshed", func() { 375 rs.LatestTryjobsRefresh = ct.Clock.Now().Add(-tryjobRefreshInterval / 2) 376 verifyNoOp() 377 }) 378 Convey("Schedule refresh", func() { 379 verifyScheduled := func() { 380 res, err := h.Poke(ctx, rs) 381 So(err, ShouldBeNil) 382 So(res.SideEffectFn, ShouldBeNil) 383 So(res.PreserveEvents, ShouldBeFalse) 384 So(res.PostProcessFn, ShouldBeNil) 385 So(res.State, ShouldNotPointTo, rs) 386 So(res.State.LatestTryjobsRefresh, ShouldEqual, datastore.RoundTime(ct.Clock.Now().UTC())) 387 So(deps.tjNotifier.updateScheduled, ShouldResemble, common.TryjobIDs{2}) 388 } 389 Convey("For the first time", func() { 390 rs.CreateTime = ct.Clock.Now().Add(-tryjobRefreshInterval - time.Second) 391 rs.LatestTryjobsRefresh = time.Time{} 392 verifyScheduled() 393 }) 394 Convey("For the second (and later) time", func() { 395 rs.LatestTryjobsRefresh = ct.Clock.Now().Add(-tryjobRefreshInterval - time.Second) 396 verifyScheduled() 397 }) 398 399 Convey("Skip if external id is not present", func() { 400 execution := rs.Tryjobs.GetState().GetExecutions()[0] 401 tryjob.LatestAttempt(execution).ExternalId = "" 402 _, err := h.Poke(ctx, rs) 403 So(err, ShouldBeNil) 404 So(deps.tjNotifier.updateScheduled, ShouldBeEmpty) 405 }) 406 407 Convey("Skip if tryjob is not in Triggered status", func() { 408 execution := rs.Tryjobs.GetState().GetExecutions()[0] 409 tryjob.LatestAttempt(execution).Status = tryjob.Status_ENDED 410 _, err := h.Poke(ctx, rs) 411 So(err, ShouldBeNil) 412 So(deps.tjNotifier.updateScheduled, ShouldBeEmpty) 413 }) 414 }) 415 }) 416 }) 417 }