From 210fa8feac95813cad339927c67f15ab1db539d0 Mon Sep 17 00:00:00 2001 From: Alin Sinpalean Date: Fri, 27 Oct 2017 16:37:22 +0200 Subject: [PATCH] Work in progress. --- .gitignore | 1 + cmd/jiralert/main.go | 153 +++++++++++++++++++++++++ cmd/jiralert/telemetry.go | 17 +++ config.go | 234 ++++++++++++++++++++++++++++++++++++++ examples/config.yaml | 33 ++++++ notify.go | 147 ++++++++++++++++++++++++ release.sh | 11 ++ template/default.tmpl | 10 ++ 8 files changed, 606 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/jiralert/main.go create mode 100644 cmd/jiralert/telemetry.go create mode 100644 config.go create mode 100644 examples/config.yaml create mode 100644 notify.go create mode 100755 release.sh create mode 100644 template/default.tmpl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a4edf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.project diff --git a/cmd/jiralert/main.go b/cmd/jiralert/main.go new file mode 100644 index 0000000..158359c --- /dev/null +++ b/cmd/jiralert/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + + "github.com/alin-sinpalean/jiralert" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + listenAddress = flag.String("listen-address", ":2197", "The address to listen on for HTTP requests.") + configFile = flag.String("config", "config.yaml", "The configuration file") +) + +func main() { + flag.Parse() + log.SetFlags(log.LstdFlags | log.Lshortfile) + + LoadConfig(*configFile) + + http.HandleFunc("/alert", func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + // https://godoc.org/github.com/prometheus/alertmanager/template#Data + data := template.Data{} + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + errorHandler(w, http.StatusBadRequest, err, "?") + return + } + + receiverConf := receiverConfByReceiver(data.Receiver) + if receiverConf == nil { + errorHandler(w, http.StatusBadRequest, fmt.Errorf("Receiver missing: %s", data.Receiver), "?") + return + } + provider, err := providerByName(receiverConf.Provider) + if err != nil { + errorHandler(w, http.StatusInternalServerError, err, receiverConf.Provider) + return + } + + var text string + if len(data.Alerts) > 1 { + labelAlerts := map[string]template.Alerts{ + "Firing": data.Alerts.Firing(), + "Resolved": data.Alerts.Resolved(), + } + for label, alerts := range labelAlerts { + if len(alerts) > 0 { + text += label + ": \n" + for _, alert := range alerts { + text += alert.Labels["alertname"] + " @" + alert.Labels["instance"] + if len(alert.Labels["exported_instance"]) > 0 { + text += " (" + alert.Labels["exported_instance"] + ")" + } + text += "\n" + } + } + } + } else if len(data.Alerts) == 1 { + alert := data.Alerts[0] + tuples := []string{} + for k, v := range alert.Labels { + tuples = append(tuples, k+"= "+v) + } + text = strings.ToUpper(data.Status) + " \n" + strings.Join(tuples, "\n") + } else { + text = "Alert \n" + strings.Join(data.CommonLabels.Values(), " | ") + } + + message := sachet.Message{ + To: receiverConf.To, + From: receiverConf.From, + Text: text, + } + + if err = provider.Send(message); err != nil { + errorHandler(w, http.StatusBadRequest, err, receiverConf.Provider) + return + } + + requestTotal.WithLabelValues("200", receiverConf.Provider).Inc() + }) + + http.Handle("/metrics", prometheus.Handler()) + + if os.Getenv("PORT") != "" { + *listenAddress = ":" + os.Getenv("PORT") + } + + log.Fatal(http.ListenAndServe(*listenAddress, nil)) +} + +// receiverConfByReceiver loops the receiver conf list and returns the first instance with that name +func receiverConfByReceiver(name string) *ReceiverConf { + for i := range config.Receivers { + rc := &config.Receivers[i] + if rc.Name == name { + return rc + } + } + return nil +} + +func providerByName(name string) (sachet.Provider, error) { + switch name { + case "messagebird": + return sachet.NewMessageBird(config.Providers.MessageBird), nil + case "nexmo": + return sachet.NewNexmo(config.Providers.Nexmo) + case "twilio": + return sachet.NewTwilio(config.Providers.Twilio), nil + case "infobip": + return sachet.NewInfobip(config.Providers.Infobip), nil + case "turbosms": + return sachet.NewTurbosms(config.Providers.Turbosms), nil + case "exotel": + return sachet.NewExotel(config.Providers.Exotel), nil + case "cm": + return sachet.NewCM(config.Providers.CM), nil + } + + return nil, fmt.Errorf("%s: Unknown provider", name) +} + +func errorHandler(w http.ResponseWriter, status int, err error, provider string) { + w.WriteHeader(status) + + data := struct { + Error bool + Status int + Message string + }{ + true, + status, + err.Error(), + } + // respond json + bytes, _ := json.Marshal(data) + json := string(bytes[:]) + fmt.Fprint(w, json) + + log.Println("Error: " + json) + requestTotal.WithLabelValues(strconv.FormatInt(int64(status), 10), provider).Inc() +} diff --git a/cmd/jiralert/telemetry.go b/cmd/jiralert/telemetry.go new file mode 100644 index 0000000..becdae8 --- /dev/null +++ b/cmd/jiralert/telemetry.go @@ -0,0 +1,17 @@ +package main + +import "github.com/prometheus/client_golang/prometheus" + +var ( + requestTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "jiralert_requests_total", + Help: "Requests processed, by status code and provider.", + }, + []string{"code", "provider"}, + ) +) + +func init() { + prometheus.MustRegister(requestTotal) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..48342f9 --- /dev/null +++ b/config.go @@ -0,0 +1,234 @@ +package jiralert + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" +) + +// Secret is a string that must not be revealed on marshaling. +type Secret string + +// MarshalYAML implements the yaml.Marshaler interface. +func (s Secret) MarshalYAML() (interface{}, error) { + if s != "" { + return "", nil + } + return nil, nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for Secrets. +func (s *Secret) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain Secret + return unmarshal((*plain)(s)) +} + +// Load parses the YAML input into a Config. +func Load(s string) (*Config, error) { + cfg := &Config{} + err := yaml.Unmarshal([]byte(s), cfg) + if err != nil { + return nil, err + } + return cfg, nil +} + +// LoadFile parses the given YAML file into a Config. +func LoadFile(filename string) (*Config, []byte, error) { + content, err := ioutil.ReadFile(filename) + if err != nil { + return nil, nil, err + } + cfg, err := Load(string(content)) + if err != nil { + return nil, nil, err + } + + resolveFilepaths(filepath.Dir(filename), cfg) + return cfg, content, nil +} + +// resolveFilepaths joins all relative paths in a configuration +// with a given base directory. +func resolveFilepaths(baseDir string, cfg *Config) { + join := func(fp string) string { + if len(fp) > 0 && !filepath.IsAbs(fp) { + fp = filepath.Join(baseDir, fp) + } + return fp + } + + for i, tf := range cfg.Templates { + cfg.Templates[i] = join(tf) + } +} + +var ( + // DefaultJiraConfig defines default values for Jira configurations. + DefaultJiraConfig = JiraConfig{ + IssueType: `Bug`, + Priority: `Critical`, + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + ReopenState: `To Do`, + } +) + +type JiraConfig struct { + Name string `yaml:"name" json:"name"` + + // API access fields + APIURL string `yaml:"api_url" json:"api_url"` + User string `yaml:"user" json:"user"` + Password Secret `yaml:"password" json:"password"` + + // Required issue fields + Project string `yaml:"project" json:"project"` + IssueType string `yaml:"issue_type" json:"issue_type"` + Summary string `yaml:"summary" json:"summary"` + ReopenState string `yaml:"reopen_state" json:"reopen_state"` + + // Optional issue fields + Priority string `yaml:"priority" json:"priority"` + Description string `yaml:"description" json:"description"` + WontFixResolution string `yaml:"wont_fix_resolution" json:"wont_fix_resolution"` + Fields map[string]interface{} `yaml:"fields" json:"fields"` + + // Catches all undefined fields and must be empty after parsing. + XXX map[string]interface{} `yaml:",inline" json:"-"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (jc *JiraConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain JiraConfig + if err := unmarshal((*plain)(jc)); err != nil { + return err + } + return checkOverflow(jc.XXX, "receiver") +} + +// Config is the top-level configuration for JIRAlert's config file. +type Config struct { + Defaults *JiraConfig `yaml:"defaults,omitempty" json:"defaults,omitempty"` + Receivers []*JiraConfig `yaml:"receivers,omitempty" json:"receivers,omitempty"` + Templates []string `yaml:"templates" json:"templates"` + + // Catches all undefined fields and must be empty after parsing. + XXX map[string]interface{} `yaml:",inline" json:"-"` +} + +func (c Config) String() string { + b, err := yaml.Marshal(c) + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { + // We want to set c to the defaults and then overwrite it with the input. + // To make unmarshal fill the plain data struct rather than calling UnmarshalYAML + // again, we have to hide it using a type indirection. + type plain Config + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + // If a defaults block was open but empty, the defaults config is overwritten. + // We have to restore it here. + if c.Defaults == nil { + c.Defaults = &JiraConfig{} + *c.Defaults = DefaultJiraConfig + } + + for _, jc := range c.Receivers { + if jc.Name == "" { + return fmt.Errorf("missing name for receiver %s", jc) + } + + // Check API access fields + if jc.APIURL == "" { + if c.Defaults.APIURL == "" { + return fmt.Errorf("missing api_url in receiver %s", jc.Name) + } + jc.APIURL = c.Defaults.APIURL + } + if jc.User == "" { + if c.Defaults.User == "" { + return fmt.Errorf("missing user in receiver %s", jc.Name) + } + jc.User = c.Defaults.User + } + if jc.Password == "" { + if c.Defaults.Password == "" { + return fmt.Errorf("missing password in receiver %s", jc.Name) + } + jc.Password = c.Defaults.Password + } + + // Check required issue fields + if jc.Project == "" { + if c.Defaults.Project == "" { + return fmt.Errorf("missing project in receiver %s", jc.Name) + } + jc.Project = c.Defaults.Project + } + if jc.IssueType == "" { + if c.Defaults.IssueType == "" { + return fmt.Errorf("missing issue_type in receiver %s", jc.Name) + } + jc.IssueType = c.Defaults.IssueType + } + if jc.Summary == "" { + if c.Defaults.Summary == "" { + return fmt.Errorf("missing summary in receiver %s", jc.Name) + } + jc.Summary = c.Defaults.Summary + } + if jc.ReopenState == "" { + if c.Defaults.ReopenState == "" { + return fmt.Errorf("missing reopen_state in receiver %s", jc.Name) + } + jc.ReopenState = c.Defaults.ReopenState + } + + // Populate optional issue fields, where necessary + if jc.Priority == "" && c.Defaults.Priority != "" { + jc.Priority = c.Defaults.Priority + } + if jc.Description == "" && c.Defaults.Description != "" { + jc.Description = c.Defaults.Description + } + if jc.WontFixResolution == "" && c.Defaults.WontFixResolution != "" { + jc.WontFixResolution = c.Defaults.WontFixResolution + } + if len(c.Defaults.Fields) > 0 { + for key, value := range c.Defaults.Fields { + if _, ok := jc.Fields[key]; !ok { + jc.Fields[key] = c.Defaults.Fields[key] + } + } + } + } + + if len(c.Receivers) == 0 { + return fmt.Errorf("no receivers defined") + } + + return checkOverflow(c.XXX, "config") +} + +func checkOverflow(m map[string]interface{}, ctx string) error { + if len(m) > 0 { + var keys []string + for k := range m { + keys = append(keys, k) + } + return fmt.Errorf("unknown fields in %s: %s", ctx, strings.Join(keys, ", ")) + } + return nil +} diff --git a/examples/config.yaml b/examples/config.yaml new file mode 100644 index 0000000..712168b --- /dev/null +++ b/examples/config.yaml @@ -0,0 +1,33 @@ +# Global defaults, applied to all receivers where not explicitly overridden. (optional) +defaults: + # API access fields + api_url: https://jiralert.atlassian.net + user: jiralert + password: 'JIRAlert' + + # The type of JIRA issue to create (required) + issue_type: Bug + # Issue priority (optional) + priority: Critical + # Go template invocation for generating the summary (required) + summary: '{{ template "jira.default.summary" . }}' + # Go template invocation for generating the description (optional) + description: '{{ template "jira.default.description" . }}' + # State to transition into when reopening a closed issue (required) + reopen_state: To Do + # Do not reopen issues with this resolution (optional) + wont_fix_resolution: Won't Fix + +# Receiver definitions. At least one must be defined. +receivers: + # Must match the Alertmanager receiver name (required) + - name: 'jira-ab' + # JIRA project to create the issue in (required) + project: AB + + - name: 'jira-xy' + project: XY + issue_type: Task + # Standard or custom field values to set on created issue (optional) + fields: + foo: bar diff --git a/notify.go b/notify.go new file mode 100644 index 0000000..219f452 --- /dev/null +++ b/notify.go @@ -0,0 +1,147 @@ +package jiralert + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/andygrunwald/go-jira" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/log" + "github.com/trivago/tgo/tcontainer" + "golang.org/x/net/context" +) + +type Jira struct { + conf *JiraConfig + tmpl *template.Template +} + +func NewJira(c *JiraConfig, t *template.Template) *Jira { + return &Jira{conf: c, tmpl: t} +} + +// Notify implements the Notifier interface. +func (n *Jira) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + client, err := jira.NewClient(http.DefaultClient, n.conf.APIURL) + if err != nil { + return false, err + } + client.Authentication.SetBasicAuth(n.conf.User, string(n.conf.Password)) + + data := n.tmpl.Data(receiverName(ctx), groupLabels(ctx), as...) + tmpl := tmplText(n.tmpl, data, &err) + + project := tmpl(n.conf.Project) + // check errors from tmpl() + if err != nil { + return false, err + } + // Looks like an ALERT metric name, with spaces removed. + issueLabel := "ALERT" + strings.Replace(groupLabels(ctx).String(), " ", "", -1) + issue, retry, err := n.search(client, project, issueLabel) + if err != nil { + return retry, err + } + + if issue != nil { + log.Debugf("Found existing issue: %+v", issue) + // The set of Jira status categories is fixed, this is a safe check to make. + if issue.Fields.Status.StatusCategory.Key != "done" { + // Issue is in a "to do" or "in progress" state, all done here. + log.Debugf("Issue %s for %s is unresolved, nothing to do", issue.Key, issueLabel) + return false, nil + } + if n.conf.WontFixResolution != "" && issue.Fields.Resolution.Name == n.conf.WontFixResolution { + // Issue is resolved as "Won't Fix" or equivalent, log a warning just in case. + log.Warnf("Issue %s for %s is resolved as %q, not reopening", issue.Key, issueLabel, issue.Fields.Resolution.Name) + return false, nil + } + log.Debugf("Issue %s for %s was resolved, reopening", issue.Key, issueLabel) + return n.reopen(client, issue.Key) + } + + issue = &jira.Issue{ + Fields: &jira.IssueFields{ + Project: jira.Project{Key: project}, + Type: jira.IssueType{Name: tmpl(n.conf.IssueType)}, + Description: tmpl(n.conf.Description), + Summary: tmpl(n.conf.Summary), + Labels: []string{ + issueLabel, + }, + Unknowns: tcontainer.NewMarshalMap(), + }, + } + if n.conf.Priority != "" { + issue.Fields.Priority = &jira.Priority{Name: tmpl(n.conf.Priority)} + } + for key, value := range n.conf.Fields { + issue.Fields.Unknowns[key] = tmpl(fmt.Sprint(value)) + } + // check errors from tmpl() + if err != nil { + return false, err + } + return n.create(client, issue) +} + +func (n *Jira) search(client *jira.Client, project, issueLabel string) (*jira.Issue, bool, error) { + query := fmt.Sprintf("project=%s and labels=%q order by key", project, issueLabel) + options := &jira.SearchOptions{ + Fields: []string{"summary", "status", "resolution"}, + MaxResults: 50, + } + issues, resp, err := client.Issue.Search(query, options) + if err != nil { + retry, err := handleJiraError(resp, err) + return nil, retry, err + } + if len(issues) > 0 { + if len(issues) > 1 { + // Swallow it, but log an error. + log.Errorf("More than one issue matched %s, will only update first: %+v", query, issues) + } + return &issues[0], false, nil + } + return nil, false, nil +} + +func (n *Jira) reopen(client *jira.Client, issueKey string) (bool, error) { + transitions, resp, err := client.Issue.GetTransitions(issueKey) + if err != nil { + return handleJiraError(resp, err) + } + for _, t := range transitions { + if t.Name == n.conf.ReopenState { + resp, err = client.Issue.DoTransition(issueKey, t.ID) + if err != nil { + return handleJiraError(resp, err) + } + return false, nil + } + } + return false, fmt.Errorf("Jira state %q does not exist or no transition possible for %s", n.conf.ReopenState, issueKey) +} + +func (n *Jira) create(client *jira.Client, issue *jira.Issue) (bool, error) { + issue, resp, err := client.Issue.Create(issue) + if err != nil { + return handleJiraError(resp, err) + } + + log.Debugf("Created issue %s (ID: %s)", issue.Key, issue.ID) + return false, nil +} + +func handleJiraError(resp *jira.Response, err error) (bool, error) { + if resp != nil && resp.StatusCode/100 != 2 { + retry := resp.StatusCode == 500 || resp.StatusCode == 503 + body, _ := ioutil.ReadAll(resp.Body) + // go-jira error message is not particularly helpful, replace it + return retry, fmt.Errorf("Jira request %s returned status %d, body %q", resp.Request.URL.String(), resp.Status, string(body)) + } + return false, err +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..8ef375d --- /dev/null +++ b/release.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# a hack to generate releases like other prometheus projects +# use like this: +# VERSION=1.0.1 ./release.sh + + +rm -rf "bin/sachet-$VERSION.linux-amd64" +mkdir "bin/sachet-$VERSION.linux-amd64" +env GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o "bin/sachet-$VERSION.linux-amd64/sachet" github.com/messagebird/sachet/cmd/sachet +cd bin +tar -zcvf "sachet-$VERSION.linux-amd64.tar.gz" "sachet-$VERSION.linux-amd64" diff --git a/template/default.tmpl b/template/default.tmpl new file mode 100644 index 0000000..0880626 --- /dev/null +++ b/template/default.tmpl @@ -0,0 +1,10 @@ +{{ define "jira.default.summary" }}{{ template "__subject" . }}{{ end }} + +{{ define "jira.default.description" }}{{ range .Alerts.Firing }}Labels: +{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} +{{ end }} +Annotations: +{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} +{{ end }} +Source: {{ .GeneratorURL }} +{{ end }}{{ end }}