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  }