go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luci_notify/notify/notify.go (about)

     1  // Copyright 2017 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 notify
    16  
    17  import (
    18  	"bytes"
    19  	"compress/gzip"
    20  	"context"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"regexp"
    26  	"strings"
    27  	"sync"
    28  	"time"
    29  
    30  	"github.com/golang/protobuf/ptypes"
    31  	"google.golang.org/protobuf/encoding/protojson"
    32  	"google.golang.org/protobuf/proto"
    33  
    34  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    35  	"go.chromium.org/luci/common/data/stringset"
    36  	"go.chromium.org/luci/common/errors"
    37  	"go.chromium.org/luci/common/logging"
    38  	gitpb "go.chromium.org/luci/common/proto/git"
    39  	"go.chromium.org/luci/common/sync/parallel"
    40  	"go.chromium.org/luci/gae/service/datastore"
    41  	"go.chromium.org/luci/server/auth"
    42  	"go.chromium.org/luci/server/mailer"
    43  	"go.chromium.org/luci/server/tq"
    44  
    45  	notifypb "go.chromium.org/luci/luci_notify/api/config"
    46  	"go.chromium.org/luci/luci_notify/config"
    47  	"go.chromium.org/luci/luci_notify/internal"
    48  	"go.chromium.org/luci/luci_notify/mailtmpl"
    49  )
    50  
    51  var validRecipientSuffixes = []string{"@chromium.org", "@grotations.appspotmail.com", "@google.com"}
    52  
    53  // createEmailTasks constructs EmailTasks to be dispatched onto the task queue.
    54  func createEmailTasks(c context.Context, recipients []EmailNotify, input *notifypb.TemplateInput) (map[string]*internal.EmailTask, error) {
    55  	// Get templates.
    56  	bundle, err := getBundle(c, input.Build.Builder.Project)
    57  	if err != nil {
    58  		return nil, errors.Annotate(err, "failed to get a bundle of email templates").Err()
    59  	}
    60  
    61  	// Generate emails.
    62  	// An EmailTask with subject and body per template name.
    63  	// They will be used as templates for actual tasks.
    64  	taskTemplates := map[string]*internal.EmailTask{}
    65  	for _, r := range recipients {
    66  		name := r.Template
    67  		if name == "" {
    68  			name = mailtmpl.DefaultTemplateName
    69  		}
    70  
    71  		if _, ok := taskTemplates[name]; ok {
    72  			continue
    73  		}
    74  		input.MatchingFailedSteps = r.MatchingSteps
    75  
    76  		subject, body := bundle.GenerateEmail(name, input)
    77  
    78  		// Note: this buffer should not be reused.
    79  		buf := &bytes.Buffer{}
    80  		gz := gzip.NewWriter(buf)
    81  		io.WriteString(gz, body)
    82  		if err := gz.Close(); err != nil {
    83  			panic("failed to gzip HTML body in memory")
    84  		}
    85  		taskTemplates[name] = &internal.EmailTask{
    86  			Subject:  subject,
    87  			BodyGzip: buf.Bytes(),
    88  		}
    89  	}
    90  
    91  	// Create a task per recipient.
    92  	// Do not bundle multiple recipients into one task because we don't use BCC.
    93  	tasks := make(map[string]*internal.EmailTask)
    94  	seen := stringset.New(len(recipients))
    95  	for _, r := range recipients {
    96  		name := r.Template
    97  		if name == "" {
    98  			name = mailtmpl.DefaultTemplateName
    99  		}
   100  
   101  		emailKey := fmt.Sprintf("%d-%s-%s", input.Build.Id, name, r.Email)
   102  		if seen.Has(emailKey) {
   103  			continue
   104  		}
   105  		seen.Add(emailKey)
   106  
   107  		task := *taskTemplates[name] // copy
   108  		task.Recipients = []string{r.Email}
   109  		tasks[emailKey] = &task
   110  	}
   111  	return tasks, nil
   112  }
   113  
   114  // isRecipientAllowed returns true if the given recipient is allowed to be notified about the given build.
   115  func isRecipientAllowed(c context.Context, recipient string, build *buildbucketpb.Build) bool {
   116  	// TODO(mknyszek): Do a real ACL check here.
   117  	for _, suffix := range validRecipientSuffixes {
   118  		if strings.HasSuffix(recipient, suffix) {
   119  			return true
   120  		}
   121  	}
   122  	logging.Warningf(c, "Address %q is not allowed to be notified of build %d", recipient, build.Id)
   123  	return false
   124  }
   125  
   126  // BlamelistRepoAllowset computes the aggregate repository allowlist for all
   127  // blamelist notification configurations in a given set of notifications.
   128  func BlamelistRepoAllowset(notifications notifypb.Notifications) stringset.Set {
   129  	allowset := stringset.New(0)
   130  	for _, notification := range notifications.GetNotifications() {
   131  		blamelistInfo := notification.GetNotifyBlamelist()
   132  		for _, repo := range blamelistInfo.GetRepositoryAllowlist() {
   133  			allowset.Add(repo)
   134  		}
   135  	}
   136  	return allowset
   137  }
   138  
   139  // ToNotify encapsulates a notification, along with the list of matching steps
   140  // necessary to render templates for that notification. It's used to pass this
   141  // data between the filtering/matching code and the code responsible for sending
   142  // emails and updating tree status.
   143  type ToNotify struct {
   144  	Notification  *notifypb.Notification
   145  	MatchingSteps []*buildbucketpb.Step
   146  }
   147  
   148  // ComputeRecipients computes the set of recipients given a set of
   149  // notifications, and potentially "input" and "output" blamelists.
   150  //
   151  // An "input" blamelist is computed from the input commit to a build, while an
   152  // "output" blamelist is derived from output commits.
   153  func ComputeRecipients(c context.Context, notifications []ToNotify, inputBlame []*gitpb.Commit, outputBlame Logs) []EmailNotify {
   154  	return computeRecipientsInternal(c, notifications, inputBlame, outputBlame,
   155  		func(c context.Context, url string) ([]byte, error) {
   156  			transport, err := auth.GetRPCTransport(c, auth.AsSelf)
   157  			if err != nil {
   158  				return nil, err
   159  			}
   160  
   161  			req, err := http.NewRequest("GET", url, nil)
   162  			if err != nil {
   163  				return nil, err
   164  			}
   165  			req = req.WithContext(c)
   166  
   167  			response, err := (&http.Client{Transport: transport}).Do(req)
   168  			if err != nil {
   169  				return nil, errors.Annotate(err, "failed to get data from %q", url).Err()
   170  			}
   171  
   172  			defer response.Body.Close()
   173  			bytes, err := io.ReadAll(response.Body)
   174  			if err != nil {
   175  				return nil, errors.Annotate(err, "failed to read response body from %q", url).Err()
   176  			}
   177  
   178  			return bytes, nil
   179  		})
   180  }
   181  
   182  // computeRecipientsInternal also takes fetchFunc, so http requests can be
   183  // mocked out for testing.
   184  func computeRecipientsInternal(c context.Context, notifications []ToNotify, inputBlame []*gitpb.Commit, outputBlame Logs, fetchFunc func(context.Context, string) ([]byte, error)) []EmailNotify {
   185  	recipients := make([]EmailNotify, 0)
   186  	for _, toNotify := range notifications {
   187  		appendRecipient := func(e EmailNotify) {
   188  			e.MatchingSteps = toNotify.MatchingSteps
   189  			recipients = append(recipients, e)
   190  		}
   191  
   192  		n := toNotify.Notification
   193  
   194  		// Aggregate the static list of recipients from the Notifications.
   195  		for _, recipient := range n.GetEmail().GetRecipients() {
   196  			appendRecipient(EmailNotify{
   197  				Email:    recipient,
   198  				Template: n.Template,
   199  			})
   200  		}
   201  
   202  		// Don't bother dealing with anything blamelist related if there's no config for it.
   203  		if n.NotifyBlamelist == nil {
   204  			continue
   205  		}
   206  
   207  		// If the allowlist is empty, use the static blamelist.
   208  		allowlist := n.NotifyBlamelist.GetRepositoryAllowlist()
   209  		if len(allowlist) == 0 {
   210  			for _, e := range commitsBlamelist(inputBlame, n.Template) {
   211  				appendRecipient(e)
   212  			}
   213  			continue
   214  		}
   215  
   216  		// If the allowlist is non-empty, use the dynamic blamelist.
   217  		allowset := stringset.NewFromSlice(allowlist...)
   218  		for _, e := range outputBlame.Filter(allowset).Blamelist(n.Template) {
   219  			appendRecipient(e)
   220  		}
   221  	}
   222  
   223  	// Acquired before appending to "recipients" from the tasks below.
   224  	mRecipients := sync.Mutex{}
   225  	err := parallel.WorkPool(8, func(ch chan<- func() error) {
   226  		for _, toNotify := range notifications {
   227  			template := toNotify.Notification.Template
   228  			steps := toNotify.MatchingSteps
   229  			for _, rotationURL := range toNotify.Notification.GetEmail().GetRotationUrls() {
   230  				rotationURL := rotationURL
   231  				ch <- func() error {
   232  					return fetchOncallers(c, rotationURL, template, steps, fetchFunc, &recipients, &mRecipients)
   233  				}
   234  			}
   235  		}
   236  	})
   237  
   238  	if err != nil {
   239  		// Just log the error and continue. Nothing much else we can do, and it's possible that we only failed
   240  		// to fetch some of the recipients, so we can at least return the ones we were able to compute.
   241  		logging.Errorf(c, "failed to fetch some or all oncallers: %s", err)
   242  	}
   243  
   244  	return recipients
   245  }
   246  
   247  func fetchOncallers(c context.Context, rotationURL, template string, matchingSteps []*buildbucketpb.Step, fetchFunc func(context.Context, string) ([]byte, error), recipients *[]EmailNotify, mRecipients *sync.Mutex) error {
   248  	resp, err := fetchFunc(c, rotationURL)
   249  	if err != nil {
   250  		err = errors.Annotate(err, "failed to fetch rotation URL: %s", rotationURL).Err()
   251  		return err
   252  	}
   253  
   254  	var oncallEmails struct {
   255  		Emails []string
   256  	}
   257  	if err = json.Unmarshal(resp, &oncallEmails); err != nil {
   258  		return errors.Annotate(err, "failed to unmarshal JSON").Err()
   259  	}
   260  
   261  	mRecipients.Lock()
   262  	defer mRecipients.Unlock()
   263  	for _, email := range oncallEmails.Emails {
   264  		*recipients = append(*recipients, EmailNotify{
   265  			Email:         email,
   266  			Template:      template,
   267  			MatchingSteps: matchingSteps,
   268  		})
   269  	}
   270  
   271  	return nil
   272  }
   273  
   274  // ShouldNotify determines whether a trigger's conditions have been met, and returns the list
   275  // of steps matching the filters on the notification, if any.
   276  func ShouldNotify(ctx context.Context, n *notifypb.Notification, oldStatus buildbucketpb.Status, newBuild *buildbucketpb.Build) (bool, []*buildbucketpb.Step) {
   277  	newStatus := newBuild.Status
   278  
   279  	switch {
   280  
   281  	case newStatus == buildbucketpb.Status_STATUS_UNSPECIFIED:
   282  		panic("new status must always be valid")
   283  	case contains(newStatus, n.OnOccurrence):
   284  	case oldStatus != buildbucketpb.Status_STATUS_UNSPECIFIED && newStatus != oldStatus && contains(newStatus, n.OnNewStatus):
   285  
   286  	// deprecated functionality
   287  	case n.OnSuccess && newStatus == buildbucketpb.Status_SUCCESS:
   288  	case n.OnFailure && newStatus == buildbucketpb.Status_FAILURE:
   289  	case n.OnChange && oldStatus != buildbucketpb.Status_STATUS_UNSPECIFIED && newStatus != oldStatus:
   290  	case n.OnNewFailure && newStatus == buildbucketpb.Status_FAILURE && oldStatus != buildbucketpb.Status_FAILURE:
   291  
   292  	default:
   293  		logging.Debugf(ctx, "Build status (%v) did not match notification criteria.", newBuild.Status)
   294  		return false, nil
   295  	}
   296  
   297  	failingSteps := findFailingSteps(newBuild)
   298  	matched, matchedSteps := matchingSteps(failingSteps, n.FailedStepRegexp, n.FailedStepRegexpExclude)
   299  	if n.FailedStepRegexp != "" || n.FailedStepRegexpExclude != "" {
   300  		logging.Debugf(ctx, "%v of %v failing steps match notification criteria.", len(matchedSteps), len(failingSteps))
   301  	}
   302  	return matched, matchedSteps
   303  }
   304  
   305  func findFailingSteps(build *buildbucketpb.Build) []*buildbucketpb.Step {
   306  	var steps []*buildbucketpb.Step
   307  	for _, step := range build.Steps {
   308  		if step.Status == buildbucketpb.Status_FAILURE {
   309  			steps = append(steps, step)
   310  		}
   311  	}
   312  	return steps
   313  }
   314  
   315  func matchingSteps(failingSteps []*buildbucketpb.Step, failedStepRegexp, failedStepRegexpExclude string) (bool, []*buildbucketpb.Step) {
   316  	var includeRegex *regexp.Regexp
   317  	if failedStepRegexp != "" {
   318  		// We should never get an invalid regex here, as our validation should catch this.
   319  		includeRegex = regexp.MustCompile(fmt.Sprintf("^%s$", failedStepRegexp))
   320  	}
   321  
   322  	var excludeRegex *regexp.Regexp
   323  	if failedStepRegexpExclude != "" {
   324  		// Ditto.
   325  		excludeRegex = regexp.MustCompile(fmt.Sprintf("^%s$", failedStepRegexpExclude))
   326  	}
   327  
   328  	var steps []*buildbucketpb.Step
   329  	for _, step := range failingSteps {
   330  		if (includeRegex == nil || includeRegex.MatchString(step.Name)) &&
   331  			(excludeRegex == nil || !excludeRegex.MatchString(step.Name)) {
   332  			steps = append(steps, step)
   333  		}
   334  	}
   335  
   336  	// If there are no regex filters, we return true regardless of whether any
   337  	// steps matched.
   338  	if len(steps) > 0 || (includeRegex == nil && excludeRegex == nil) {
   339  		return true, steps
   340  	}
   341  	return false, nil
   342  }
   343  
   344  // Filter filters out Notification objects from Notifications by checking if we ShouldNotify
   345  // based on two build statuses.
   346  func Filter(ctx context.Context, n *notifypb.Notifications, oldStatus buildbucketpb.Status, newBuild *buildbucketpb.Build) []ToNotify {
   347  	notifications := n.GetNotifications()
   348  	filtered := make([]ToNotify, 0, len(notifications))
   349  	for i, notification := range notifications {
   350  		logging.Debugf(ctx, "Considering notification rule %v: %s", i, formatNotification(notification))
   351  		if match, steps := ShouldNotify(ctx, notification, oldStatus, newBuild); match {
   352  			filtered = append(filtered, ToNotify{
   353  				Notification:  notification,
   354  				MatchingSteps: steps,
   355  			})
   356  		}
   357  	}
   358  	return filtered
   359  }
   360  
   361  // formatNotification formats the notification config
   362  // as a human readable string. Email addreses are removed, to
   363  // allow the string to be recorded to a log file.
   364  func formatNotification(n *notifypb.Notification) string {
   365  	msg := proto.Clone(n).(*notifypb.Notification)
   366  	if msg.Email != nil {
   367  		// Remove email addresses from the formatted version
   368  		// as it is going to be put into logs.
   369  		for i := range msg.Email.Recipients {
   370  			msg.Email.Recipients[i] = "<omitted>"
   371  		}
   372  	}
   373  
   374  	m := &protojson.MarshalOptions{}
   375  	b, err := m.Marshal(msg)
   376  	if err != nil {
   377  		// Swallow any errors as this method is used only for logging.
   378  		return ""
   379  	}
   380  	return string(b)
   381  }
   382  
   383  // contains checks whether or not a build status is in a list of build statuses.
   384  func contains(status buildbucketpb.Status, statusList []buildbucketpb.Status) bool {
   385  	for _, s := range statusList {
   386  		if status == s {
   387  			return true
   388  		}
   389  	}
   390  	return false
   391  }
   392  
   393  // UpdateTreeClosers finds all the TreeClosers that care about a particular
   394  // build, and updates their status according to the results of the build.
   395  func UpdateTreeClosers(c context.Context, build *Build, oldStatus buildbucketpb.Status) error {
   396  	// This reads, modifies and writes back entities in datastore. Hence, it should
   397  	// be called within a transaction to avoid races.
   398  	if datastore.CurrentTransaction(c) == nil {
   399  		panic("UpdateTreeClosers must be run within a transaction")
   400  	}
   401  
   402  	// Don't update the status at all unless we have a definite
   403  	// success or failure - infra failures, for example, shouldn't
   404  	// cause us to close or re-open the tree.
   405  	if build.Status != buildbucketpb.Status_SUCCESS && build.Status != buildbucketpb.Status_FAILURE {
   406  		return nil
   407  	}
   408  
   409  	project := &config.Project{Name: build.Builder.Project}
   410  	parentBuilder := &config.Builder{
   411  		ProjectKey: datastore.KeyForObj(c, project),
   412  		ID:         getBuilderID(&build.Build),
   413  	}
   414  	q := datastore.NewQuery("TreeCloser").Ancestor(datastore.KeyForObj(c, parentBuilder))
   415  	var toUpdate []*config.TreeCloser
   416  	if err := datastore.GetAll(c, q, &toUpdate); err != nil {
   417  		return err
   418  	}
   419  
   420  	for _, tc := range toUpdate {
   421  		newStatus := config.Open
   422  		var steps []*buildbucketpb.Step
   423  		if build.Status == buildbucketpb.Status_FAILURE {
   424  			t := tc.TreeCloser
   425  			var match bool
   426  			if match, steps = matchingSteps(findFailingSteps(&build.Build), t.FailedStepRegexp, t.FailedStepRegexpExclude); match {
   427  				newStatus = config.Closed
   428  			}
   429  		}
   430  
   431  		tc.Status = newStatus
   432  		var err error
   433  		if tc.Timestamp, err = ptypes.Timestamp(build.EndTime); err != nil {
   434  			logging.Warningf(c, "Build EndTime is invalid (%s), defaulting to time.Now()", err)
   435  			tc.Timestamp = time.Now().UTC()
   436  		}
   437  
   438  		if newStatus == config.Closed {
   439  			bundle, err := getBundle(c, project.Name)
   440  			if err != nil {
   441  				return err
   442  			}
   443  			tc.Message = bundle.GenerateStatusMessage(c, tc.TreeCloser.Template,
   444  				&notifypb.TemplateInput{
   445  					BuildbucketHostname: build.BuildbucketHostname,
   446  					Build:               &build.Build,
   447  					OldStatus:           oldStatus,
   448  					MatchingFailedSteps: steps,
   449  				})
   450  		} else {
   451  			// Not strictly necessary, as Message is only used when status is
   452  			// 'Closed'. But it could be confusing when debugging to have a
   453  			// stale message in the entity.
   454  			tc.Message = ""
   455  		}
   456  	}
   457  
   458  	return datastore.Put(c, toUpdate)
   459  }
   460  
   461  // Notify discovers, consolidates and filters recipients from a Builder's notifications,
   462  // and 'email_notify' properties, then dispatches notifications if necessary.
   463  // Does not dispatch a notification for same email, template and build more than
   464  // once. Ignores current transaction in c, if any.
   465  func Notify(c context.Context, recipients []EmailNotify, templateParams *notifypb.TemplateInput) error {
   466  	c = datastore.WithoutTransaction(c)
   467  
   468  	// Remove unallowed recipients.
   469  	allRecipients := recipients
   470  	recipients = recipients[:0]
   471  	for _, r := range allRecipients {
   472  		if isRecipientAllowed(c, r.Email, templateParams.Build) {
   473  			recipients = append(recipients, r)
   474  		}
   475  	}
   476  
   477  	if len(recipients) == 0 {
   478  		logging.Infof(c, "Nobody to notify...")
   479  		return nil
   480  	}
   481  	logging.Infof(c, "Notifying %v recipients...", len(recipients))
   482  
   483  	tasks, err := createEmailTasks(c, recipients, templateParams)
   484  	if err != nil {
   485  		return errors.Annotate(err, "failed to create email tasks").Err()
   486  	}
   487  
   488  	for emailKey, task := range tasks {
   489  		task := &tq.Task{
   490  			Payload:          task,
   491  			Title:            emailKey,
   492  			DeduplicationKey: emailKey,
   493  		}
   494  
   495  		if err := tq.AddTask(c, task); err != nil {
   496  			return err
   497  		}
   498  	}
   499  	return nil
   500  }
   501  
   502  // InitDispatcher registers the send email task with the given dispatcher.
   503  func InitDispatcher(d *tq.Dispatcher) {
   504  	d.RegisterTaskClass(tq.TaskClass{
   505  		ID:        "send-email",
   506  		Kind:      tq.NonTransactional,
   507  		Prototype: &internal.EmailTask{},
   508  		Handler:   SendEmail,
   509  		Queue:     "email",
   510  	})
   511  }
   512  
   513  // SendEmail is a push queue handler that attempts to send an email.
   514  func SendEmail(c context.Context, task proto.Message) error {
   515  	// TODO(mknyszek): Query Milo for additional build information.
   516  	emailTask := task.(*internal.EmailTask)
   517  
   518  	body := emailTask.Body
   519  	if len(emailTask.BodyGzip) > 0 {
   520  		r, err := gzip.NewReader(bytes.NewReader(emailTask.BodyGzip))
   521  		if err != nil {
   522  			return err
   523  		}
   524  		buf, err := io.ReadAll(r)
   525  		if err != nil {
   526  			return err
   527  		}
   528  		body = string(buf)
   529  	}
   530  
   531  	return mailer.Send(c, &mailer.Mail{
   532  		To:       emailTask.Recipients,
   533  		Subject:  emailTask.Subject,
   534  		HTMLBody: body,
   535  		ReplyTo:  emailTask.Recipients[0],
   536  	})
   537  }