blob: f4d95cd0c37b59aa14d9a3c0299734942632965b [file] [log] [blame]
// Package reminder emails periodic reminders to active alert owners.
package reminder
import (
"bytes"
"context"
"fmt"
"html/template"
"time"
"cloud.google.com/go/datastore"
"go.skia.org/infra/am/go/incident"
"go.skia.org/infra/am/go/silence"
"go.skia.org/infra/email/go/emailclient"
"go.skia.org/infra/go/ds"
"go.skia.org/infra/go/email"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/rotations"
"go.skia.org/infra/go/sklog"
)
const (
// Currently setup to email reminders daily at 4am UTC time.
reminderHourUTC = 4
reminderDuration = 24 * time.Hour
emailTemplate = `
Hi {{.Owner}},
<br/><br/>
You either own or were assigned these alerts on am.skia.org:
<ul>
{{range $a := .Alerts}}
<li>{{$a}}</li>
{{end}}
</ul>
This is a friendly reminder to add a silence or to resolve them whenever possible.
<br/><br/>
Thanks!
`
)
var (
emailTemplateParsed = template.Must(template.New("reminder_email").Parse(emailTemplate))
)
// Reminder - Keeps track of which days reminders were sent out in the Datastore.
// Uses named keys which are in "YYYY-MM-DD" format.
type Reminder struct {
}
type emailTicker struct {
t *time.Timer
iStore *incident.Store
sStore *silence.Store
email emailclient.Client
}
// getDailyNextTickDuration returns the duration after which the reminder should be sent.
// If startTimeUTC is after reminderHourUTC then the next day's reminderHourUTC is used.
// If startTimeUTC is before reminderHourUTC then the current day's reminderHourUTC is used.
func getDailyNextTickDuration(startTimeUTC time.Time, reminderHourUTC int) time.Duration {
nextTick := time.Date(startTimeUTC.Year(), startTimeUTC.Month(), startTimeUTC.Day(), reminderHourUTC, 0, 0, 0, time.UTC)
if nextTick.Before(startTimeUTC) {
nextTick = nextTick.Add(reminderDuration)
}
sklog.Infof("[reminder] Next tick is %s", nextTick)
return nextTick.Sub(startTimeUTC)
}
// getOwnersToAlerts returns a map of owners to alerts.
// An owner of an alert is determined by looking at "assigned_to" of an active alert. If "assigned_to"
// does not exist then it looks at the "owner" of the active alert.
func getOwnersToAlerts(ins []incident.Incident, silences []silence.Silence) map[string][]incident.Incident {
ownersToAlerts := map[string][]incident.Incident{}
for _, i := range ins {
if !i.IsSilenced(silences, true) && (i.Params["owner"] != "" || i.Params["assigned_to"] != "") {
owner := i.Params["assigned_to"]
if owner == "" {
owner = i.Params["owner"]
}
ownersToAlerts[owner] = append(ownersToAlerts[owner], i)
}
}
return ownersToAlerts
}
func (et emailTicker) updateEmailTicker() {
et.t.Reset(getDailyNextTickDuration(time.Now().UTC(), reminderHourUTC))
}
// remindAlertOwners sends a reminder email with a list of firing alerts to
// the owners/assignees of the alerts.
func (et emailTicker) remindAlertOwners() error {
ins, err := et.iStore.GetAll()
if err != nil {
return fmt.Errorf("Failed to load incidents: %s", err)
}
silences, err := et.sStore.GetAll()
if err != nil {
return fmt.Errorf("Failed to load silences: %s", err)
}
if silences == nil {
silences = []silence.Silence{}
}
// Find the current infra gardener.
gardeners, err := rotations.FromURL(httputils.NewTimeoutClient(), rotations.InfraGardenerURL)
if err != nil {
return fmt.Errorf("Could not get current gardener: %s", err)
}
if len(gardeners) != 1 {
return fmt.Errorf("Expected 1 entry from %s. Instead got %s", rotations.InfraGardenerURL, gardeners)
}
gardener := gardeners[0]
// Send reminder emails to alert owners (but not to the gardener).
ownersToAlerts := getOwnersToAlerts(ins, silences)
for o, alerts := range ownersToAlerts {
if o == gardener {
sklog.Infof("Not going to email %s because they are the current gardener", o)
continue
}
sklog.Infof("Going to email %s for these alerts:\n", o)
alertDescriptions := []string{}
for _, a := range alerts {
desc := fmt.Sprintf("%s - %s", a.Params["alertname"], a.Params["abbr"])
alertDescriptions = append(alertDescriptions, desc)
sklog.Infof("\t%s\n", desc)
}
emailBytes := new(bytes.Buffer)
if err := emailTemplateParsed.Execute(emailBytes, struct {
Owner string
Alerts []string
}{
Owner: o,
Alerts: alertDescriptions,
}); err != nil {
return fmt.Errorf("Failed to execute email template: %s", err)
}
emailSubject := "You have active alerts on am.skia.org"
viewActionMarkup, err := email.GetViewActionMarkup("am.skia.org/?tab=0", "View Alerts", "View alerts owned by you")
if err != nil {
return fmt.Errorf("Failed to get view action markup: %s", err)
}
if _, err := et.email.SendWithMarkup("Alert Manager", "alertserver@skia.org", []string{o}, emailSubject, emailBytes.String(), viewActionMarkup, ""); err != nil {
return fmt.Errorf("Could not send email: %s", err)
}
}
return nil
}
// StartReminderTicker sends reminders on a periodic basis.
func StartReminderTicker(iStore *incident.Store, sStore *silence.Store, email emailclient.Client) {
et := emailTicker{
t: time.NewTimer(getDailyNextTickDuration(time.Now().UTC(), reminderHourUTC)),
iStore: iStore,
sStore: sStore,
email: email,
}
go func() {
for {
<-et.t.C
var err error
if _, err = ds.DS.RunInTransaction(context.Background(), func(tx *datastore.Transaction) error {
var reminderFromDS Reminder
// Construct the key and see if it already exists in the Datastore.
k := ds.NewKey(ds.REMINDER_AM)
k.Name = time.Now().UTC().Format("2006-01-02")
if err := tx.Get(k, &reminderFromDS); err != nil {
if err == datastore.ErrNoSuchEntity {
sklog.Info("[reminder] Adding entry to datastore")
if _, err := tx.Put(k, &reminderFromDS); err != nil {
return err
}
} else {
return err
}
} else {
return fmt.Errorf("%s key already exists in the datastore", k.Name)
}
return nil
}); err != nil {
sklog.Errorf("[reminder] Error talking to the datastore: %s", err)
} else {
sklog.Info("[reminder] Going to send reminders")
if err := et.remindAlertOwners(); err != nil {
sklog.Errorf("[reminder] Error emailing alert owners: %s", err)
}
}
et.updateEmailTicker()
}
}()
}