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  }