blob: 7792218c41f7400c76dc77d7b8c84f009153e810 [file] [log] [blame]
/*
Provides alerting for Skia.
*/
package main
import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"text/template"
"time"
"unicode"
)
import (
"github.com/gorilla/mux"
"go.skia.org/infra/go/sklog"
)
import (
"go.skia.org/infra/alertserver/go/alerting"
"go.skia.org/infra/alertserver/go/rules"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/email"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/influxdb"
"go.skia.org/infra/go/influxdb_init"
"go.skia.org/infra/go/login"
"go.skia.org/infra/go/metadata"
"go.skia.org/infra/go/skiaversion"
"go.skia.org/infra/go/util"
)
const (
GMAIL_TOKEN_CACHE_FILE = "google_email_token.data"
PARAM_INCLUDE_CATEGORY = "category"
PARAM_EXCLUDE_CATEGORY = "excludeCategory"
)
var (
alertManager *alerting.AlertManager = nil
rulesList []*rules.Rule = nil
alertsTemplate *template.Template = nil
rulesTemplate *template.Template = nil
)
// flags
var (
host = flag.String("host", "localhost", "HTTP service host")
port = flag.String("port", ":8001", "HTTP service port (e.g., ':8001')")
useMetadata = flag.Bool("use_metadata", true, "Load sensitive values from metadata not from flags.")
emailClientIdFlag = flag.String("email_clientid", "", "OAuth Client ID for sending email.")
emailClientSecretFlag = flag.String("email_clientsecret", "", "OAuth Client Secret for sending email.")
alertPollInterval = flag.String("alert_poll_interval", "1s", "How often to check for new alerts.")
alertsFile = flag.String("alerts_file", "alerts.cfg", "Config file containing alert rules.")
testing = flag.Bool("testing", false, "Set to true for locally testing rules. No email will be sent.")
validateAndExit = flag.Bool("validate_and_exit", false, "If set, just validate the config file and then exit.")
resourcesDir = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
influxHost = flag.String("influxdb_host", influxdb.DEFAULT_HOST, "The InfluxDB hostname.")
influxUser = flag.String("influxdb_name", influxdb.DEFAULT_USER, "The InfluxDB username.")
influxPassword = flag.String("influxdb_password", influxdb.DEFAULT_PASSWORD, "The InfluxDB password.")
influxDatabase = flag.String("influxdb_database", influxdb.DEFAULT_DATABASE, "The InfluxDB database.")
)
// StringIsInteresting returns true iff the string contains non-whitespace characters.
func StringIsInteresting(s string) bool {
for _, c := range s {
if !unicode.IsSpace(c) {
return true
}
}
return false
}
func reloadTemplates() {
// Change the current working directory to two directories up from this source file so that we
// can read templates and serve static (res/) files.
if *resourcesDir == "" {
_, filename, _, _ := runtime.Caller(0)
*resourcesDir = filepath.Join(filepath.Dir(filename), "../..")
}
alertsTemplate = template.Must(template.ParseFiles(
filepath.Join(*resourcesDir, "templates/alerts.html"),
filepath.Join(*resourcesDir, "templates/header.html"),
))
rulesTemplate = template.Must(template.ParseFiles(
filepath.Join(*resourcesDir, "templates/rules.html"),
filepath.Join(*resourcesDir, "templates/header.html"),
))
}
func Init() {
reloadTemplates()
}
func userHasEditRights(email string) bool {
if strings.HasSuffix(email, "@google.com") {
return true
}
return false
}
func getIntParam(name string, r *http.Request) (*int, error) {
raw, ok := r.URL.Query()[name]
if !ok {
return nil, nil
}
v64, err := strconv.ParseInt(raw[0], 10, 32)
if err != nil {
return nil, fmt.Errorf("Invalid value for parameter %q: %s -- %v", name, raw, err)
}
v32 := int(v64)
return &v32, nil
}
func makeAlertFilter(r *http.Request) func(*alerting.Alert) bool {
includeCategories := []string{}
excludeCategories := []string{}
queryInclude, ok := r.URL.Query()[PARAM_INCLUDE_CATEGORY]
if ok {
includeCategories = queryInclude
}
queryExclude, ok := r.URL.Query()[PARAM_EXCLUDE_CATEGORY]
if ok {
excludeCategories = queryExclude
}
return func(a *alerting.Alert) bool {
if len(includeCategories) > 0 {
for _, include := range includeCategories {
if a.Category == include {
return true
}
}
return false
}
for _, exclude := range excludeCategories {
if a.Category == exclude {
return false
}
}
return true
}
}
func alertJsonHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
if err := alertManager.WriteActiveAlertsJson(w, makeAlertFilter(r)); err != nil {
sklog.Errorf("Failed to write or encode output: %s", err)
}
}
func handleAlert(alertId int64, comment string, until int, w http.ResponseWriter, r *http.Request) {
email := login.LoggedInAs(r)
if !userHasEditRights(email) {
httputils.ReportError(w, r, fmt.Errorf("User does not have edit rights."), "You must be logged in to an account with edit rights to do that.")
return
}
action, ok := mux.Vars(r)["action"]
if !ok {
httputils.ReportError(w, r, fmt.Errorf("No action provided."), "No action provided.")
return
}
if action == "dismiss" {
sklog.Infof("%s %d", action, alertId)
if err := alertManager.Dismiss(alertId, email, comment); err != nil {
httputils.ReportError(w, r, err, "Failed to dismiss alert.")
return
}
return
} else if action == "snooze" {
if until == 0 {
httputils.ReportError(w, r, fmt.Errorf("Invalid snooze time."), fmt.Sprintf("Invalid snooze time"))
return
}
until := time.Unix(int64(until), 0)
sklog.Infof("%s %d until %v", action, alertId, until.String())
if err := alertManager.Snooze(alertId, until, email, comment); err != nil {
httputils.ReportError(w, r, err, "Failed to snooze alert.")
return
}
return
} else if action == "unsnooze" {
sklog.Infof("%s %d", action, alertId)
if err := alertManager.Unsnooze(alertId, email, comment); err != nil {
httputils.ReportError(w, r, err, "Failed to unsnooze alert.")
return
}
return
} else if action == "addcomment" {
if !StringIsInteresting(comment) {
httputils.ReportError(w, r, fmt.Errorf("Invalid comment text."), comment)
return
}
sklog.Infof("%s %d: %s", action, alertId, comment)
if err := alertManager.AddComment(alertId, email, comment); err != nil {
httputils.ReportError(w, r, err, "Failed to add comment.")
return
}
return
} else {
httputils.ReportError(w, r, fmt.Errorf("Invalid action %s", action), "The requested action is invalid.")
return
}
}
func postAlertsJsonHandler(w http.ResponseWriter, r *http.Request) {
// Get the alert ID.
alertIdStr, ok := mux.Vars(r)["alertId"]
if !ok {
httputils.ReportError(w, r, fmt.Errorf("No alert ID provided."), "No alert ID provided.")
return
}
alertId, err := strconv.ParseInt(alertIdStr, 10, 64)
if err != nil {
httputils.ReportError(w, r, fmt.Errorf("Invalid alert ID %s", alertIdStr), "Not found.")
return
}
var req struct {
Until int `json:"until"`
Comment string `json:"comment"`
}
defer util.Close(r.Body)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.ReportError(w, r, err, "Failed to decode request body.")
return
}
handleAlert(alertId, req.Comment, req.Until, w, r)
}
func postMultiAlertsJsonHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
AlertIDs []int64 `json:"alerts"`
Until int `json:"until"`
Comment string `json:"comment"`
}
defer util.Close(r.Body)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.ReportError(w, r, err, "Failed to decode request body.")
return
}
if len(req.AlertIDs) < 1 {
httputils.ReportError(w, r, fmt.Errorf("No alerts specified."), "No alerts specified.")
return
}
for _, a := range req.AlertIDs {
handleAlert(a, req.Comment, req.Until, w, r)
}
}
func handleAlerts(w http.ResponseWriter, r *http.Request, title string, categories []string, excludeCategories []string) {
w.Header().Set("Content-Type", "text/html")
// Don't use cached templates in testing mode.
if *testing {
reloadTemplates()
}
categoriesJson, err := json.Marshal(categories)
if err != nil {
httputils.ReportError(w, r, fmt.Errorf("Failed to encode JSON."), "Failed to encode JSON")
}
excludeJson, err := json.Marshal(excludeCategories)
if err != nil {
httputils.ReportError(w, r, fmt.Errorf("Failed to encode JSON."), "Failed to encode JSON")
}
inp := struct {
Categories string
ExcludeCategories string
Title string
}{
Categories: string(categoriesJson),
ExcludeCategories: string(excludeJson),
Title: title,
}
if err := alertsTemplate.Execute(w, inp); err != nil {
sklog.Errorf("Failed to write or encode output: %s", err)
}
}
func alertHandler(w http.ResponseWriter, r *http.Request) {
handleAlerts(w, r, "Skia Alerts", []string{}, []string{alerting.INFRA_ALERT})
}
func infraAlertHandler(w http.ResponseWriter, r *http.Request) {
handleAlerts(w, r, "Skia Infra Alerts", []string{alerting.INFRA_ALERT}, []string{})
}
func rulesJsonHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
rules := struct {
Rules []*rules.Rule `json:"rules"`
}{
Rules: rulesList,
}
if err := json.NewEncoder(w).Encode(&rules); err != nil {
sklog.Error(err)
}
}
func rulesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
// Don't use cached templates in testing mode.
if *testing {
reloadTemplates()
}
if err := rulesTemplate.Execute(w, struct{}{}); err != nil {
sklog.Errorln("Failed to expand template:", err)
}
}
func runServer(serverURL string) {
r := mux.NewRouter()
r.PathPrefix("/res/").HandlerFunc(httputils.MakeResourceHandler(*resourcesDir))
r.HandleFunc("/", alertHandler)
r.HandleFunc("/infra", infraAlertHandler)
r.HandleFunc("/rules", rulesHandler)
alerts := r.PathPrefix("/json/alerts").Subrouter()
alerts.HandleFunc("/", httputils.CorsHandler(alertJsonHandler))
alerts.HandleFunc("/{alertId:[0-9]+}/{action}", postAlertsJsonHandler).Methods("POST")
alerts.HandleFunc("/multi/{action}", postMultiAlertsJsonHandler).Methods("POST")
r.HandleFunc("/json/rules", rulesJsonHandler)
r.HandleFunc("/json/version", skiaversion.JsonHandler)
r.HandleFunc("/oauth2callback/", login.OAuth2CallbackHandler)
r.HandleFunc("/logout/", login.LogoutHandler)
r.HandleFunc("/loginstatus/", login.StatusHandler)
http.Handle("/", httputils.LoggingGzipRequestResponse(r))
sklog.Infof("Ready to serve on %s", serverURL)
sklog.Fatal(http.ListenAndServe(*port, nil))
}
func main() {
defer common.LogPanic()
alertDBConf := alerting.DBConfigFromFlags()
flag.Parse()
if !*validateAndExit {
common.InitWithMetrics2("alertserver", influxHost, influxUser, influxPassword, influxDatabase, testing)
} else {
common.Init()
}
v, err := skiaversion.GetVersion()
if err != nil {
sklog.Fatal(err)
}
sklog.Infof("Version %s, built at %s", v.Commit, v.Date)
Init()
if *validateAndExit {
if _, err := rules.MakeRules(*alertsFile, nil, time.Second, nil, true); err != nil {
sklog.Fatalf("Failed to set up rules: %v", err)
}
return
}
parsedPollInterval, err := time.ParseDuration(*alertPollInterval)
if err != nil {
sklog.Fatalf("Failed to parse -alertPollInterval: %s", *alertPollInterval)
}
if *testing {
*useMetadata = false
}
dbClient, err := influxdb_init.NewClientFromParamsAndMetadata(*influxHost, *influxUser, *influxPassword, *influxDatabase, *testing)
if err != nil {
sklog.Fatalf("Failed to initialize InfluxDB client: %s", err)
}
serverURL := "https://" + *host
if *testing {
serverURL = "http://" + *host + *port
}
usr, err := user.Current()
if err != nil {
sklog.Fatal(err)
}
tokenFile, err := filepath.Abs(usr.HomeDir + "/" + GMAIL_TOKEN_CACHE_FILE)
if err != nil {
sklog.Fatal(err)
}
redirectURL := serverURL + "/oauth2callback/"
emailClientId := *emailClientIdFlag
emailClientSecret := *emailClientSecretFlag
if *useMetadata {
emailClientId = metadata.Must(metadata.ProjectGet(metadata.GMAIL_CLIENT_ID))
emailClientSecret = metadata.Must(metadata.ProjectGet(metadata.GMAIL_CLIENT_SECRET))
cachedGMailToken := metadata.Must(metadata.ProjectGet(metadata.GMAIL_CACHED_TOKEN))
err = ioutil.WriteFile(tokenFile, []byte(cachedGMailToken), os.ModePerm)
if err != nil {
sklog.Fatalf("Failed to cache token: %s", err)
}
}
if err := login.Init(redirectURL, login.DEFAULT_DOMAIN_WHITELIST); err != nil {
sklog.Fatalf("Failed to initialize the login system: %s", err)
}
var emailAuth *email.GMail
if !*testing {
if !*useMetadata && (emailClientId == "" || emailClientSecret == "") {
sklog.Fatal("If -use_metadata=false, you must provide -email_clientid and -email_clientsecret")
}
emailAuth, err = email.NewGMail(emailClientId, emailClientSecret, tokenFile)
if err != nil {
sklog.Fatalf("Failed to create email auth: %v", err)
}
}
// Initialize the database.
if !*testing && *useMetadata {
if err := alertDBConf.GetPasswordFromMetadata(); err != nil {
sklog.Fatal(err)
}
}
if err := alertDBConf.InitDB(); err != nil {
sklog.Fatal(err)
}
// Create the AlertManager.
alertManager, err = alerting.MakeAlertManager(parsedPollInterval, emailAuth)
if err != nil {
sklog.Fatalf("Failed to create AlertManager: %v", err)
}
rulesList, err = rules.MakeRules(*alertsFile, dbClient, parsedPollInterval, alertManager, *testing)
if err != nil {
sklog.Fatalf("Failed to set up rules: %v", err)
}
runServer(serverURL)
}