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 }