go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/clpurger/clpurger.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 clpurger 16 17 import ( 18 "context" 19 "fmt" 20 "strings" 21 "time" 22 23 "google.golang.org/protobuf/proto" 24 25 "go.chromium.org/luci/common/clock" 26 "go.chromium.org/luci/common/errors" 27 "go.chromium.org/luci/common/logging" 28 "go.chromium.org/luci/common/retry/transient" 29 "go.chromium.org/luci/gae/service/datastore" 30 "go.chromium.org/luci/server/tq" 31 32 cfgpb "go.chromium.org/luci/cv/api/config/v2" 33 "go.chromium.org/luci/cv/internal/changelist" 34 "go.chromium.org/luci/cv/internal/common" 35 "go.chromium.org/luci/cv/internal/configs/prjcfg" 36 "go.chromium.org/luci/cv/internal/gerrit" 37 "go.chromium.org/luci/cv/internal/gerrit/trigger" 38 "go.chromium.org/luci/cv/internal/prjmanager" 39 "go.chromium.org/luci/cv/internal/prjmanager/prjpb" 40 "go.chromium.org/luci/cv/internal/run" 41 "go.chromium.org/luci/cv/internal/usertext" 42 ) 43 44 // Purger purges CLs for Project Manager. 45 type Purger struct { 46 pmNotifier *prjmanager.Notifier 47 gFactory gerrit.Factory 48 clUpdater clUpdater 49 clMutator *changelist.Mutator 50 } 51 52 // clUpdater is a subset of the *changelist.Updater which Purger needs. 53 type clUpdater interface { 54 Schedule(context.Context, *changelist.UpdateCLTask) error 55 } 56 57 // NoNotification indicates that no notification should be sent for the purging 58 // task on a given CL. 59 // 60 // this is just for readability. 61 var NoNotification = &prjpb.PurgingCL_Notification{} 62 63 // New creates a Purger and registers it for handling tasks created by the given 64 // PM Notifier. 65 func New(n *prjmanager.Notifier, g gerrit.Factory, u clUpdater, clm *changelist.Mutator) *Purger { 66 p := &Purger{n, g, u, clm} 67 n.TasksBinding.PurgeProjectCL.AttachHandler( 68 func(ctx context.Context, payload proto.Message) error { 69 task := payload.(*prjpb.PurgeCLTask) 70 ctx = logging.SetFields(ctx, logging.Fields{ 71 "project": task.GetLuciProject(), 72 "cl": task.GetPurgingCl().GetClid(), 73 }) 74 err := p.PurgeCL(ctx, task) 75 return common.TQifyError(ctx, err) 76 }, 77 ) 78 return p 79 } 80 81 // Schedule enqueues a task to purge a CL for immediate execution. 82 func (p *Purger) Schedule(ctx context.Context, t *prjpb.PurgeCLTask) error { 83 return p.pmNotifier.TasksBinding.TQDispatcher.AddTask(ctx, &tq.Task{ 84 Payload: t, 85 // No DeduplicationKey as these tasks are created transactionally by PM. 86 Title: fmt.Sprintf("%s/%d/%s", t.GetLuciProject(), t.GetPurgingCl().GetClid(), t.GetPurgingCl().GetOperationId()), 87 }) 88 } 89 90 // PurgeCL purges a CL and notifies PM on success or failure. 91 func (p *Purger) PurgeCL(ctx context.Context, task *prjpb.PurgeCLTask) error { 92 now := clock.Now(ctx) 93 94 if len(task.GetPurgeReasons()) == 0 { 95 return errors.Reason("no reasons given in %s", task).Err() 96 } 97 98 d := task.GetPurgingCl().GetDeadline() 99 if d == nil { 100 return errors.Reason("no deadline given in %s", task).Err() 101 } 102 switch dt := d.AsTime(); { 103 case dt.Before(now): 104 logging.Warningf(ctx, "purging task running too late (deadline %s, now %s)", dt, now) 105 default: 106 dctx, cancel := clock.WithDeadline(ctx, dt) 107 defer cancel() 108 if err := p.purgeWithDeadline(dctx, task); err != nil { 109 return err 110 } 111 } 112 return p.pmNotifier.NotifyPurgeCompleted(ctx, task.GetLuciProject(), task.GetPurgingCl()) 113 } 114 115 func (p *Purger) purgeWithDeadline(ctx context.Context, task *prjpb.PurgeCLTask) error { 116 cl := &changelist.CL{ID: common.CLID(task.GetPurgingCl().GetClid())} 117 if err := datastore.Get(ctx, cl); err != nil { 118 return errors.Annotate(err, "failed to load %d", cl.ID).Tag(transient.Tag).Err() 119 } 120 121 configGroups, err := loadConfigGroups(ctx, task) 122 if err != nil { 123 return nil 124 } 125 purgeTriggers, msg, err := triggersToPurge(ctx, configGroups[0].Content, cl, task) 126 switch { 127 case err != nil: 128 return errors.Annotate(err, "CL %d of project %q", cl.ID, task.GetLuciProject()).Err() 129 case purgeTriggers == nil: 130 return nil 131 } 132 133 var atteWhoms gerrit.Whoms 134 var notiWhoms gerrit.Whoms 135 if notification := task.GetPurgingCl().GetNotification(); notification == nil { 136 var whoms gerrit.Whoms 137 if cqMode := purgeTriggers.GetCqVoteTrigger().GetMode(); cqMode != "" { 138 whoms = append(whoms, run.Mode(cqMode).GerritNotifyTargets()...) 139 } 140 if nprMode := purgeTriggers.GetNewPatchsetRunTrigger().GetMode(); nprMode != "" { 141 whoms = append(whoms, run.Mode(nprMode).GerritNotifyTargets()...) 142 } 143 if len(whoms) == 0 { 144 panic(fmt.Errorf("expected the trigger(s) to purge to have a RunMode")) 145 } 146 whoms.Dedupe() 147 atteWhoms = whoms 148 notiWhoms = whoms 149 } else { 150 for _, whom := range notification.GetNotify() { 151 notiWhoms = append(notiWhoms, gerrit.Whom(whom)) 152 } 153 notiWhoms.Dedupe() 154 for _, whom := range notification.GetAttention() { 155 atteWhoms = append(atteWhoms, gerrit.Whom(whom)) 156 } 157 atteWhoms.Dedupe() 158 } 159 160 logging.Debugf(ctx, "proceeding to purge CL due to\n%s", msg) 161 err = trigger.Reset(ctx, trigger.ResetInput{LUCIProject: task.GetLuciProject(), 162 CL: cl, 163 LeaseDuration: time.Minute, 164 Notify: notiWhoms, 165 AddToAttentionSet: atteWhoms, 166 AttentionReason: "CV can't start a new Run as requested", 167 Requester: "prjmanager/clpurger", 168 Triggers: purgeTriggers, 169 Message: msg, 170 ConfigGroups: configGroups, 171 GFactory: p.gFactory, 172 CLMutator: p.clMutator}) 173 174 switch { 175 case err == nil: 176 logging.Debugf(ctx, "purging done") 177 case trigger.ErrResetPreconditionFailedTag.In(err): 178 logging.Debugf(ctx, "cancel is not necessary: %s", err) 179 case trigger.ErrResetPermanentTag.In(err): 180 logging.Errorf(ctx, "permanently failed to purge CL: %s", err) 181 default: 182 return errors.Annotate(err, "failed to purge CL %d of project %q", cl.ID, task.GetLuciProject()).Err() 183 } 184 185 // Schedule a refresh of a CL. 186 // TODO(crbug.com/1284393): use Gerrit's consistency-on-demand when available. 187 return p.clUpdater.Schedule(ctx, &changelist.UpdateCLTask{ 188 LuciProject: task.GetLuciProject(), 189 ExternalId: string(cl.ExternalID), 190 Id: int64(cl.ID), 191 Requester: changelist.UpdateCLTask_CL_PURGER, 192 }) 193 } 194 195 func triggersToPurge(ctx context.Context, cg *cfgpb.ConfigGroup, cl *changelist.CL, task *prjpb.PurgeCLTask) (*run.Triggers, string, error) { 196 if cl.Snapshot == nil { 197 logging.Warningf(ctx, "CL without Snapshot can't be purged\n%s", task) 198 return nil, "", nil 199 } 200 if p := cl.Snapshot.GetLuciProject(); p != task.GetLuciProject() { 201 logging.Warningf(ctx, "CL now belongs to different project %q", p) 202 return nil, "", nil 203 } 204 if cl.Snapshot.GetGerrit() == nil { 205 panic(fmt.Errorf("CL %d has non-Gerrit snapshot", cl.ID)) 206 } 207 ci := cl.Snapshot.GetGerrit().GetInfo() 208 currentTriggers := trigger.Find(&trigger.FindInput{ChangeInfo: ci, ConfigGroup: cg, TriggerNewPatchsetRunAfterPS: cl.TriggerNewPatchsetRunAfterPS}) 209 if currentTriggers == nil { 210 return nil, "", nil 211 } 212 213 ret := &run.Triggers{} 214 var sb strings.Builder 215 for _, pr := range task.GetPurgeReasons() { 216 taskNPRTrigger := pr.GetTriggers().GetNewPatchsetRunTrigger() 217 taskCQVTrigger := pr.GetTriggers().GetCqVoteTrigger() 218 var clErrorMode run.Mode 219 switch { 220 case pr.GetAllActiveTriggers(): 221 ret = currentTriggers 222 switch { 223 // If multiple triggers are being purged, the mode of the CQ Vote 224 // trigger takes precedence for the purposes of formatting. 225 case ret.GetCqVoteTrigger() != nil: 226 clErrorMode = run.Mode(ret.GetCqVoteTrigger().GetMode()) 227 case ret.GetNewPatchsetRunTrigger() != nil: 228 clErrorMode = run.Mode(ret.GetNewPatchsetRunTrigger().GetMode()) 229 } 230 // If we are purging a specific trigger, only proceed if the trigger to 231 // purge has not been updated since the task was scheduled. 232 // Note that we can't entirely avoid races with users modifying the CL. 233 case taskNPRTrigger != nil && proto.Equal(currentTriggers.GetNewPatchsetRunTrigger().GetTime(), taskNPRTrigger.GetTime()): 234 ret.NewPatchsetRunTrigger = taskNPRTrigger 235 clErrorMode = run.Mode(taskNPRTrigger.GetMode()) 236 case taskCQVTrigger != nil && proto.Equal(currentTriggers.GetCqVoteTrigger().GetTime(), taskCQVTrigger.GetTime()): 237 ret.CqVoteTrigger = taskCQVTrigger 238 clErrorMode = run.Mode(taskCQVTrigger.GetMode()) 239 default: 240 continue 241 } 242 if sb.Len() > 0 { 243 sb.WriteRune('\n') 244 } 245 if err := usertext.FormatCLError(ctx, pr.GetClError(), cl, clErrorMode, &sb); err != nil { 246 return nil, "", err 247 } 248 } 249 if ret.CqVoteTrigger == nil && ret.NewPatchsetRunTrigger == nil { 250 return nil, "", nil 251 } 252 return ret, sb.String(), nil 253 } 254 255 func loadConfigGroups(ctx context.Context, task *prjpb.PurgeCLTask) ([]*prjcfg.ConfigGroup, error) { 256 // There is usually exactly 1 config group. 257 res := make([]*prjcfg.ConfigGroup, len(task.GetConfigGroups())) 258 for i, id := range task.GetConfigGroups() { 259 cg, err := prjcfg.GetConfigGroup(ctx, task.GetLuciProject(), prjcfg.ConfigGroupID(id)) 260 if err != nil { 261 return nil, errors.Annotate(err, "failed to load a ConfigGroup").Tag(transient.Tag).Err() 262 } 263 res[i] = cg 264 } 265 return res, nil 266 }