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 ¬ifypb.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 }