go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/run/impl/longops/poststartmessage_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 longops
    16  
    17  import (
    18  	"fmt"
    19  	"testing"
    20  	"time"
    21  
    22  	"go.chromium.org/luci/common/clock"
    23  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    24  	"go.chromium.org/luci/gae/service/datastore"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/grpc/status"
    27  	"google.golang.org/protobuf/types/known/timestamppb"
    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/configs/validation"
    34  	"go.chromium.org/luci/cv/internal/cvtesting"
    35  	"go.chromium.org/luci/cv/internal/gerrit/botdata"
    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/eventpb"
    40  	"go.chromium.org/luci/cv/internal/run/impl/util"
    41  
    42  	. "github.com/smartystreets/goconvey/convey"
    43  )
    44  
    45  func TestPostStartMessage(t *testing.T) {
    46  	t.Parallel()
    47  
    48  	Convey("PostStartMessageOp works", t, func() {
    49  		ct := cvtesting.Test{}
    50  		ctx, cancel := ct.SetUp(t)
    51  		defer cancel()
    52  
    53  		const (
    54  			lProject = "chromeos"
    55  			runID    = lProject + "/777-1-deadbeef"
    56  			gHost    = "g-review.example.com"
    57  			gChange1 = 111
    58  			gChange2 = 222
    59  		)
    60  
    61  		cfg := cfgpb.Config{
    62  			CqStatusHost: validation.CQStatusHostPublic,
    63  			ConfigGroups: []*cfgpb.ConfigGroup{
    64  				{Name: "test"},
    65  			},
    66  		}
    67  		prjcfgtest.Create(ctx, lProject, &cfg)
    68  
    69  		ensureCL := func(ci *gerritpb.ChangeInfo) (*changelist.CL, *run.RunCL) {
    70  			triggers := trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: cfg.GetConfigGroups()[0]})
    71  			So(triggers.GetCqVoteTrigger(), ShouldNotBeNil)
    72  			if triggers.GetCqVoteTrigger() == nil {
    73  				panic(fmt.Errorf("CL %d must be triggered", ci.GetNumber()))
    74  			}
    75  
    76  			if ct.GFake.Has(gHost, int(ci.GetNumber())) {
    77  				ct.GFake.MutateChange(gHost, int(ci.GetNumber()), func(c *gf.Change) {
    78  					c.Info = ci
    79  				})
    80  			} else {
    81  				ct.GFake.AddFrom(gf.WithCIs(gHost, gf.ACLRestricted(lProject), ci))
    82  			}
    83  
    84  			cl := changelist.MustGobID(gHost, ci.GetNumber()).MustCreateIfNotExists(ctx)
    85  			rcl := &run.RunCL{
    86  				ID:         cl.ID,
    87  				ExternalID: cl.ExternalID,
    88  				IndexedID:  cl.ID,
    89  				Trigger:    triggers.GetCqVoteTrigger(),
    90  				Run:        datastore.MakeKey(ctx, common.RunKind, string(runID)),
    91  				Detail: &changelist.Snapshot{
    92  					Kind: &changelist.Snapshot_Gerrit{Gerrit: &changelist.Gerrit{
    93  						Host: gHost,
    94  						Info: ci,
    95  					}},
    96  					ExternalUpdateTime: timestamppb.New(ct.Clock.Now()),
    97  				},
    98  			}
    99  			cl.Snapshot = rcl.Detail
   100  			cl.EVersion++
   101  			So(datastore.Put(ctx, cl, rcl), ShouldBeNil)
   102  			return cl, rcl
   103  		}
   104  
   105  		makeRunWithCLs := func(r *run.Run, cis ...*gerritpb.ChangeInfo) *run.Run {
   106  			if len(cis) == 0 {
   107  				panic(fmt.Errorf("at least one CL required"))
   108  			}
   109  			if r == nil {
   110  				r = &run.Run{}
   111  			}
   112  			r.ID = runID
   113  			r.Status = run.Status_RUNNING
   114  			for _, ci := range cis {
   115  				_, rcl := ensureCL(ci)
   116  				r.CLs = append(r.CLs, rcl.ID)
   117  			}
   118  			if r.Mode == "" {
   119  				r.Mode = run.FullRun
   120  			}
   121  			if r.ConfigGroupID == "" {
   122  				r.ConfigGroupID = prjcfgtest.MustExist(ctx, lProject).ConfigGroupIDs[0]
   123  			}
   124  			So(datastore.Put(ctx, r), ShouldBeNil)
   125  			return r
   126  		}
   127  
   128  		makeOp := func(r *run.Run) *PostStartMessageOp {
   129  			return &PostStartMessageOp{
   130  				Base: &Base{
   131  					Op: &run.OngoingLongOps_Op{
   132  						Deadline:        timestamppb.New(ct.Clock.Now().Add(10000 * time.Hour)),
   133  						CancelRequested: false,
   134  						Work: &run.OngoingLongOps_Op_PostStartMessage{
   135  							PostStartMessage: true,
   136  						},
   137  					},
   138  					IsCancelRequested: func() bool { return false },
   139  					Run:               r,
   140  				},
   141  				Env:      ct.Env,
   142  				GFactory: ct.GFactory(),
   143  			}
   144  		}
   145  
   146  		Convey("Happy path without status URL", func() {
   147  			cfg.CqStatusHost = ""
   148  			prjcfgtest.Update(ctx, lProject, &cfg)
   149  
   150  			op := makeOp(makeRunWithCLs(nil, gf.CI(gChange1, gf.CQ(+2))))
   151  			res, err := op.Do(ctx)
   152  			So(err, ShouldBeNil)
   153  			So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_SUCCEEDED)
   154  			So(ct.GFake.GetChange(gHost, gChange1).Info, gf.ShouldLastMessageContain, "CV is trying the patch.\n\nBot data: ")
   155  		})
   156  
   157  		Convey("Happy path", func() {
   158  			op := makeOp(makeRunWithCLs(nil, gf.CI(gChange1, gf.CQ(+2))))
   159  			res, err := op.Do(ctx)
   160  			So(err, ShouldBeNil)
   161  			So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_SUCCEEDED)
   162  
   163  			ci := ct.GFake.GetChange(gHost, gChange1).Info
   164  			So(ci, gf.ShouldLastMessageContain, "CV is trying the patch.\n\nFollow status at:")
   165  			So(ci, gf.ShouldLastMessageContain, "https://luci-change-verifier.appspot.com/ui/run/chromeos/777-1-deadbeef")
   166  			So(ci, gf.ShouldLastMessageContain, "Bot data:")
   167  			// Should post exactly one message.
   168  			So(ci.GetMessages(), ShouldHaveLength, 1)
   169  
   170  			// Recorded timestamp must be approximately correct.
   171  			So(res.GetPostStartMessage().GetTime().AsTime(), ShouldHappenWithin, time.Second, ci.GetMessages()[0].GetDate().AsTime())
   172  		})
   173  
   174  		Convey("Happy path with multiple CLs", func() {
   175  			op := makeOp(makeRunWithCLs(
   176  				&run.Run{Mode: run.DryRun},
   177  				gf.CI(gChange1, gf.CQ(+1)),
   178  				gf.CI(gChange2, gf.CQ(+1)),
   179  			))
   180  			res, err := op.Do(ctx)
   181  			So(err, ShouldBeNil)
   182  			So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_SUCCEEDED)
   183  
   184  			for _, gChange := range []int{gChange1, gChange2} {
   185  				ci := ct.GFake.GetChange(gHost, gChange).Info
   186  				So(ci, gf.ShouldLastMessageContain, "Dry run: CV is trying the patch.\n\nFollow status at:")
   187  				// Should post exactly one message.
   188  				So(ci.GetMessages(), ShouldHaveLength, 1)
   189  				bd, ok := botdata.Parse(ci.GetMessages()[0])
   190  				So(ok, ShouldBeTrue)
   191  				So(bd.Action, ShouldEqual, botdata.Start)
   192  
   193  				// Recorded timestamp must be approximately correct since both CLs are
   194  				// posted at around the same time.
   195  				So(res.GetPostStartMessage().GetTime().AsTime(), ShouldHappenWithin, time.Second, ci.GetMessages()[0].GetDate().AsTime())
   196  			}
   197  		})
   198  
   199  		Convey("Best effort avoidance of duplicated messages", func() {
   200  			// Make two same PostStartMessageOp objects, since they are single-use
   201  			// only.
   202  			opFirst := makeOp(makeRunWithCLs(nil, gf.CI(gChange1, gf.CQ(+2))))
   203  			opRetry := makeOp(makeRunWithCLs(nil, gf.CI(gChange1, gf.CQ(+2))))
   204  
   205  			// Simulate first try updating Gerrit, but somehow crashing before getting
   206  			// response from Gerrit.
   207  			_, err := opFirst.Do(ctx)
   208  			So(err, ShouldBeNil)
   209  			ci := ct.GFake.GetChange(gHost, gChange1).Info
   210  			So(ci, gf.ShouldLastMessageContain, "CV is trying the patch")
   211  			So(ci.GetMessages(), ShouldHaveLength, 1)
   212  
   213  			Convey("very quick retry leads to dups", func() {
   214  				ct.Clock.Add(time.Second)
   215  				res, err := opRetry.Do(ctx)
   216  				So(err, ShouldBeNil)
   217  				So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_SUCCEEDED)
   218  				So(ct.GFake.GetChange(gHost, gChange1).Info.GetMessages(), ShouldHaveLength, 2)
   219  				// And the timestamp isn't entirely right, but that's fine.
   220  				So(res.GetPostStartMessage().GetTime().AsTime(), ShouldResemble, ct.Clock.Now().UTC().Truncate(time.Second))
   221  			})
   222  
   223  			Convey("later retry", func() {
   224  				ct.Clock.Add(util.StaleCLAgeThreshold)
   225  				res, err := opRetry.Do(ctx)
   226  				So(err, ShouldBeNil)
   227  				So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_SUCCEEDED)
   228  				// There should still be exactly 1 message.
   229  				ci := ct.GFake.GetChange(gHost, gChange1).Info
   230  				So(ci.GetMessages(), ShouldHaveLength, 1)
   231  				// and the timestamp must be exactly correct.
   232  				So(res.GetPostStartMessage().GetTime().AsTime(), ShouldResemble, ci.GetMessages()[0].GetDate().AsTime())
   233  			})
   234  		})
   235  
   236  		Convey("Failures", func() {
   237  			op := makeOp(makeRunWithCLs(
   238  				&run.Run{Mode: run.DryRun},
   239  				gf.CI(gChange1, gf.CQ(+1)),
   240  			))
   241  			ctx, cancel := clock.WithDeadline(ctx, op.Op.Deadline.AsTime())
   242  			defer cancel()
   243  			ct.Clock.Set(op.Op.Deadline.AsTime().Add(-8 * time.Minute))
   244  
   245  			Convey("With a non transient failure", func() {
   246  				ct.GFake.MutateChange(gHost, gChange1, func(c *gf.Change) {
   247  					c.ACLs = func(_ gf.Operation, _ string) *status.Status {
   248  						return status.New(codes.PermissionDenied, "admin-is-angry-today")
   249  					}
   250  				})
   251  			})
   252  			Convey("With a transient failure", func() {
   253  				ct.GFake.MutateChange(gHost, gChange1, func(c *gf.Change) {
   254  					c.ACLs = func(_ gf.Operation, _ string) *status.Status {
   255  						return status.New(codes.Internal, "oops, temp error")
   256  					}
   257  				})
   258  			})
   259  			res, err := op.Do(ctx)
   260  			// Given any failure, the status should be set to FAILED,
   261  			// but the returned error is nil to prevent the TQ retry.
   262  			So(err, ShouldBeNil)
   263  			So(res.GetStatus(), ShouldEqual, eventpb.LongOpCompleted_FAILED)
   264  			So(res.GetPostStartMessage().GetTime(), ShouldBeNil)
   265  			So(ct.GFake.GetChange(gHost, gChange1).Info.GetMessages(), ShouldHaveLength, 0)
   266  		})
   267  	})
   268  }