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  }