go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/handler/cl_update_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 "fmt" 19 "testing" 20 "time" 21 22 "google.golang.org/protobuf/proto" 23 "google.golang.org/protobuf/types/known/timestamppb" 24 25 "go.chromium.org/luci/common/clock" 26 gerritpb "go.chromium.org/luci/common/proto/gerrit" 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/configs/prjcfg/prjcfgtest" 33 "go.chromium.org/luci/cv/internal/cvtesting" 34 gf "go.chromium.org/luci/cv/internal/gerrit/gerritfake" 35 "go.chromium.org/luci/cv/internal/gerrit/trigger" 36 "go.chromium.org/luci/cv/internal/run" 37 "go.chromium.org/luci/cv/internal/run/eventpb" 38 "go.chromium.org/luci/cv/internal/run/impl/state" 39 40 . "github.com/smartystreets/goconvey/convey" 41 . "go.chromium.org/luci/common/testing/assertions" 42 ) 43 44 func TestOnCLsUpdated(t *testing.T) { 45 Convey("OnCLsUpdated", t, func() { 46 ct := cvtesting.Test{} 47 ctx, cancel := ct.SetUp(t) 48 defer cancel() 49 50 const ( 51 lProject = "chromium" 52 gHost = "x-review.example.com" 53 committers = "committer-group" 54 dryRunners = "dry-runner-group" 55 ) 56 57 cfg := &cfgpb.Config{ 58 ConfigGroups: []*cfgpb.ConfigGroup{ 59 { 60 Name: "main", 61 Verifiers: &cfgpb.Verifiers{ 62 GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{ 63 CommitterList: []string{committers}, 64 DryRunAccessList: []string{dryRunners}, 65 }, 66 }, 67 }, 68 }, 69 } 70 prjcfgtest.Create(ctx, lProject, cfg) 71 h, _ := makeTestHandler(&ct) 72 73 // initial state 74 triggerTime := clock.Now(ctx).UTC() 75 rs := &state.RunState{ 76 Run: run.Run{ 77 ID: common.MakeRunID(lProject, ct.Clock.Now(), 1, []byte("deadbeef")), 78 StartTime: triggerTime.Add(1 * time.Minute), 79 Status: run.Status_RUNNING, 80 ConfigGroupID: prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0], 81 CLs: common.CLIDs{1}, 82 Mode: run.DryRun, 83 }, 84 } 85 updateCL := func(clID common.CLID, ci *gerritpb.ChangeInfo, ap *changelist.ApplicableConfig, acc *changelist.Access) changelist.CL { 86 cl := changelist.CL{ 87 ID: clID, 88 ExternalID: changelist.MustGobID(gHost, ci.GetNumber()), 89 Snapshot: &changelist.Snapshot{ 90 LuciProject: lProject, 91 Patchset: ci.GetRevisions()[ci.GetCurrentRevision()].GetNumber(), 92 Kind: &changelist.Snapshot_Gerrit{ 93 Gerrit: &changelist.Gerrit{ 94 Host: gHost, 95 Info: ci, 96 }, 97 }, 98 }, 99 ApplicableConfig: ap, 100 Access: acc, 101 } 102 103 So(datastore.Put(ctx, &cl), ShouldBeNil) 104 return cl 105 } 106 107 verifyHasResetTriggerLongOpScheduled := func(res *Result, expect map[common.CLID]string, endStatus run.Status) { 108 // The status should be still RUNNING, 109 // because it has not been cancelled yet. 110 // It's scheduled to be cancelled. 111 So(res.State.Status, ShouldEqual, run.Status_RUNNING) 112 So(res.SideEffectFn, ShouldBeNil) 113 So(res.PreserveEvents, ShouldBeFalse) 114 115 longOp := res.State.OngoingLongOps.GetOps()[res.State.NewLongOpIDs[0]] 116 cancelOp := longOp.GetResetTriggers() 117 So(cancelOp.Requests, ShouldHaveLength, len(expect)) 118 for _, req := range cancelOp.Requests { 119 clid := common.CLID(req.Clid) 120 So(expect, ShouldContainKey, clid) 121 So(req.Message, ShouldContainSubstring, expect[clid]) 122 delete(expect, clid) 123 } 124 So(expect, ShouldBeEmpty) 125 So(cancelOp.RunStatusIfSucceeded, ShouldEqual, endStatus) 126 } 127 128 aplConfigOK := &changelist.ApplicableConfig{Projects: []*changelist.ApplicableConfig_Project{ 129 {Name: lProject, ConfigGroupIds: prjcfgtest.MustExist(ctx, lProject).ConfigGroupNames}, 130 }} 131 accessOK := (*changelist.Access)(nil) 132 133 const gChange1 = 1 134 const gPatchSet1 = 5 135 136 ci1 := gf.CI( 137 gChange1, gf.PS(gPatchSet1), 138 gf.Owner("foo"), 139 gf.CQ(+2, triggerTime, gf.U("foo")), 140 gf.Approve(), 141 ) 142 ct.AddMember("foo", committers) 143 cl1 := updateCL(1, ci1, aplConfigOK, accessOK) 144 triggers1 := trigger.Find(&trigger.FindInput{ChangeInfo: ci1, ConfigGroup: cfg.GetConfigGroups()[0]}) 145 So(triggers1.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{ 146 Time: timestamppb.New(triggerTime), 147 Mode: string(run.FullRun), 148 Email: "foo@example.com", 149 GerritAccountId: 1, 150 }) 151 runCLs := []*run.RunCL{ 152 { 153 ID: 1, 154 Run: datastore.MakeKey(ctx, common.RunKind, string(rs.ID)), 155 Detail: cl1.Snapshot, 156 Trigger: triggers1.GetCqVoteTrigger(), 157 }, 158 } 159 So(runCLs[0].Trigger, ShouldNotBeNil) // ensure trigger find is working fine. 160 So(datastore.Put(ctx, runCLs), ShouldBeNil) 161 162 Convey("Single CL Run", func() { 163 ensureNoop := func() { 164 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 165 So(err, ShouldBeNil) 166 So(res.State, ShouldResemble, rs) 167 So(res.SideEffectFn, ShouldBeNil) 168 So(res.PreserveEvents, ShouldBeFalse) 169 } 170 Convey("Noop", func() { 171 statuses := []run.Status{ 172 run.Status_SUCCEEDED, 173 run.Status_FAILED, 174 run.Status_CANCELLED, 175 } 176 for _, status := range statuses { 177 Convey(fmt.Sprintf("When Run is %s", status), func() { 178 rs.Status = status 179 ensureNoop() 180 }) 181 } 182 183 Convey("When new CL Version", func() { 184 Convey("is a message update", func() { 185 newCI1 := proto.Clone(ci1).(*gerritpb.ChangeInfo) 186 gf.Messages(&gerritpb.ChangeMessageInfo{ 187 Message: "This is a message", 188 })(newCI1) 189 updateCL(1, newCI1, aplConfigOK, accessOK) 190 ensureNoop() 191 }) 192 193 Convey("is triggered by different user at the exact same time", func() { 194 updateCL(1, gf.CI( 195 gChange1, gf.PS(gPatchSet1), 196 gf.CQ(+2, triggerTime, gf.U("bar")), 197 gf.Approve(), 198 ), aplConfigOK, accessOK) 199 ensureNoop() 200 }) 201 }) 202 }) 203 Convey("Preserve events for SUBMITTING Run", func() { 204 rs.Status = run.Status_SUBMITTING 205 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 206 So(err, ShouldBeNil) 207 So(res.State, ShouldResemble, rs) 208 So(res.SideEffectFn, ShouldBeNil) 209 So(res.PreserveEvents, ShouldBeTrue) 210 }) 211 212 Convey("Preserve events for if trigger reset is ongoing", func() { 213 rs.OngoingLongOps = &run.OngoingLongOps{ 214 Ops: map[string]*run.OngoingLongOps_Op{ 215 "op_id": { 216 Work: &run.OngoingLongOps_Op_ResetTriggers_{ 217 ResetTriggers: &run.OngoingLongOps_Op_ResetTriggers{ 218 Requests: []*run.OngoingLongOps_Op_ResetTriggers_Request{ 219 { 220 Clid: 1, 221 Message: "no permission to Run", 222 }, 223 }, 224 }, 225 }, 226 }, 227 }, 228 } 229 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 230 So(err, ShouldBeNil) 231 So(res.State, ShouldResemble, rs) 232 So(res.SideEffectFn, ShouldBeNil) 233 So(res.PreserveEvents, ShouldBeTrue) 234 }) 235 236 runAndVerifyCancelled := func(reason string) { 237 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 238 So(err, ShouldBeNil) 239 So(res.State.Status, ShouldEqual, run.Status_CANCELLED) 240 So(res.State.CancellationReasons, ShouldResemble, []string{reason}) 241 So(res.SideEffectFn, ShouldNotBeNil) 242 So(res.PreserveEvents, ShouldBeFalse) 243 } 244 245 Convey("Cancels Run on new Patchset", func() { 246 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1+1), gf.CQ(+2, triggerTime, gf.U("foo"))), aplConfigOK, accessOK) 247 runAndVerifyCancelled("the patchset of https://x-review.example.com/c/1 has changed from 5 to 6") 248 }) 249 Convey("Cancels Run on moved Ref", func() { 250 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Ref("refs/heads/new")), aplConfigOK, accessOK) 251 runAndVerifyCancelled("the ref of https://x-review.example.com/c/1 has moved from refs/heads/main to refs/heads/new") 252 }) 253 Convey("Cancels Run on removed trigger", func() { 254 newCI1 := gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(0, triggerTime.Add(1*time.Minute), gf.U("foo"))) 255 So(trigger.Find(&trigger.FindInput{ChangeInfo: newCI1, ConfigGroup: cfg.GetConfigGroups()[0]}), ShouldBeNil) 256 updateCL(1, newCI1, aplConfigOK, accessOK) 257 runAndVerifyCancelled("the FULL_RUN trigger on https://x-review.example.com/c/1 has been removed") 258 }) 259 Convey("Cancels Run on changed mode", func() { 260 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+1, triggerTime.Add(1*time.Minute), gf.U("foo"))), aplConfigOK, accessOK) 261 runAndVerifyCancelled("the triggering vote on https://x-review.example.com/c/1 has requested a different run mode: DRY_RUN") 262 }) 263 Convey("Cancels Run on change of triggering time", func() { 264 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime.Add(2*time.Minute), gf.U("foo"))), aplConfigOK, accessOK) 265 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))) 266 }) 267 268 Convey("Change of access level to the CL", func() { 269 Convey("cancel if another project started watching the same CL", func() { 270 ac := proto.Clone(aplConfigOK).(*changelist.ApplicableConfig) 271 ac.Projects = append(ac.Projects, &changelist.ApplicableConfig_Project{ 272 Name: "other-project", ConfigGroupIds: []string{"other-group"}, 273 }) 274 updateCL(1, ci1, ac, accessOK) 275 runAndVerifyCancelled(fmt.Sprintf("no longer have access to https://x-review.example.com/c/1: watched not only by LUCI Project %q", lProject)) 276 }) 277 Convey("wait if code review access was just lost, potentially due to eventual consistency", func() { 278 noAccessAt := ct.Clock.Now().Add(42 * time.Second) 279 acc := &changelist.Access{ByProject: map[string]*changelist.Access_Project{ 280 // Set NoAccessTime to the future, providing some grace period to 281 // recover. 282 lProject: {NoAccessTime: timestamppb.New(noAccessAt)}, 283 }} 284 updateCL(1, ci1, aplConfigOK, acc) 285 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 286 So(err, ShouldBeNil) 287 So(res.State, ShouldResemble, rs) 288 So(res.SideEffectFn, ShouldBeNil) 289 // Event must be preserved, s.t. the same CL is re-visited later. 290 So(res.PreserveEvents, ShouldBeTrue) 291 // And Run Manager must have a task to re-check itself at around 292 // NoAccessTime. 293 So(ct.TQ.Tasks().Payloads(), ShouldHaveLength, 1) 294 So(ct.TQ.Tasks().Payloads()[0].(*eventpb.ManageRunTask).GetRunId(), ShouldResemble, string(rs.ID)) 295 So(ct.TQ.Tasks()[0].ETA, ShouldHappenOnOrBetween, noAccessAt, noAccessAt.Add(time.Second)) 296 }) 297 Convey("cancel if code review access was lost a while ago", func() { 298 acc := &changelist.Access{ByProject: map[string]*changelist.Access_Project{ 299 lProject: {NoAccessTime: timestamppb.New(ct.Clock.Now())}, 300 }} 301 updateCL(1, ci1, aplConfigOK, acc) 302 runAndVerifyCancelled("no longer have access to https://x-review.example.com/c/1: code review site denied access") 303 }) 304 Convey("wait if access level is unknown", func() { 305 cl1.Snapshot = nil 306 cl1.EVersion++ 307 So(datastore.Put(ctx, &cl1), ShouldBeNil) 308 ensureNoop() 309 }) 310 }) 311 312 Convey("Schedules a ResetTrigger long op if the approval was revoked", func() { 313 updateCL(1, gf.CI( 314 gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(), 315 ), aplConfigOK, accessOK) 316 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 317 So(err, ShouldBeNil) 318 verifyHasResetTriggerLongOpScheduled(res, map[common.CLID]string{ 319 1: "CV cannot start a Run because this CL is not submittable.", 320 }, run.Status_FAILED) 321 }) 322 }) 323 324 Convey("Multi CL Run", func() { 325 const gChange2 = 2 326 const gPatchSet2 = 7 327 ci2 := gf.CI( 328 gChange2, gf.PS(gPatchSet2), 329 gf.Owner("foo"), 330 gf.CQ(+2, triggerTime, gf.U("foo")), 331 gf.Approve(), 332 ) 333 cl2 := updateCL(2, ci2, aplConfigOK, accessOK) 334 triggers2 := trigger.Find(&trigger.FindInput{ChangeInfo: ci2, ConfigGroup: cfg.GetConfigGroups()[0]}) 335 So(triggers2.GetCqVoteTrigger(), ShouldResembleProto, &run.Trigger{ 336 Time: timestamppb.New(triggerTime), 337 Mode: string(run.FullRun), 338 Email: "foo@example.com", 339 GerritAccountId: 1, 340 }) 341 rs.CLs = append(rs.CLs, 2) 342 runCLs = append(runCLs, &run.RunCL{ 343 ID: 2, 344 Run: datastore.MakeKey(ctx, common.RunKind, string(rs.ID)), 345 Detail: cl2.Snapshot, 346 Trigger: triggers2.GetCqVoteTrigger(), 347 }) 348 So(runCLs[1].Trigger, ShouldNotBeNil) // ensure trigger find is working fine. 349 So(datastore.Put(ctx, runCLs), ShouldBeNil) 350 351 Convey("Schedules a ResetTrigger long op", func() { 352 Convey("Part of the CLs cause cancellation", func() { 353 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1+1), gf.CQ(+2, triggerTime, gf.U("foo"))), aplConfigOK, accessOK) 354 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 355 So(err, ShouldBeNil) 356 So(res.State.CancellationReasons, ShouldResemble, []string{"the patchset of https://x-review.example.com/c/1 has changed from 5 to 6"}) 357 verifyHasResetTriggerLongOpScheduled(res, map[common.CLID]string{ 358 2: "Reset the trigger of this CL because the patchset of https://x-review.example.com/c/1 has changed from 5 to 6", 359 }, run.Status_CANCELLED) 360 Convey("Cancel directly if it is root CL causing cancellation", func() { 361 rs.RootCL = common.CLID(1) 362 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 363 So(err, ShouldBeNil) 364 So(res.State.Status, ShouldEqual, run.Status_CANCELLED) 365 So(isCurrentlyResettingTriggers(rs), ShouldBeFalse) 366 }) 367 }) 368 369 Convey("Approval was revoked", func() { 370 Convey("Partial", func() { 371 updateCL(1, gf.CI( 372 gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(), 373 ), aplConfigOK, accessOK) 374 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 375 So(err, ShouldBeNil) 376 verifyHasResetTriggerLongOpScheduled(res, map[common.CLID]string{ 377 1: "CV cannot start a Run because this CL is not submittable.", 378 2: "CV cannot start a Run due to errors in the following CL(s).", 379 }, run.Status_FAILED) 380 Convey("Only reset trigger on root Cl", func() { 381 rs.RootCL = common.CLID(1) 382 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1}) 383 So(err, ShouldBeNil) 384 verifyHasResetTriggerLongOpScheduled(res, map[common.CLID]string{ 385 1: "CV cannot start a Run because this CL is not submittable.", 386 }, run.Status_FAILED) 387 }) 388 }) 389 Convey("Both", func() { 390 updateCL(1, gf.CI( 391 gChange1, gf.PS(gPatchSet1), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(), 392 ), aplConfigOK, accessOK) 393 updateCL(2, gf.CI( 394 gChange2, gf.PS(gPatchSet2), gf.CQ(+2, triggerTime, gf.U("foo")), gf.Disapprove(), 395 ), aplConfigOK, accessOK) 396 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1, 2}) 397 So(err, ShouldBeNil) 398 verifyHasResetTriggerLongOpScheduled(res, map[common.CLID]string{ 399 1: "CV cannot start a Run because this CL is not submittable.", 400 2: "CV cannot start a Run because this CL is not submittable.", 401 }, run.Status_FAILED) 402 }) 403 }) 404 }) 405 406 Convey("All CLs causes cancellation", func() { 407 updateCL(1, gf.CI(gChange1, gf.PS(gPatchSet1+1), gf.CQ(+2, triggerTime, gf.U("foo"))), aplConfigOK, accessOK) 408 updateCL(2, gf.CI(gChange2, gf.PS(gPatchSet2+1), gf.CQ(+2, triggerTime, gf.U("foo"))), aplConfigOK, accessOK) 409 res, err := h.OnCLsUpdated(ctx, rs, common.CLIDs{1, 2}) 410 So(err, ShouldBeNil) 411 So(res.State.Status, ShouldEqual, run.Status_CANCELLED) 412 So(res.State.CancellationReasons, ShouldResemble, []string{ 413 "the patchset of https://x-review.example.com/c/1 has changed from 5 to 6", 414 "the patchset of https://x-review.example.com/c/2 has changed from 7 to 8", 415 }) 416 So(res.SideEffectFn, ShouldNotBeNil) 417 So(res.PreserveEvents, ShouldBeFalse) 418 So(isCurrentlyResettingTriggers(rs), ShouldBeFalse) 419 }) 420 }) 421 }) 422 }