From 3df616256be6eb69443f3ec46cb5e1d2943af027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Wed, 1 Nov 2023 19:19:55 +0100 Subject: [PATCH] Initial jira integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jan-Otto Kröpke --- asset/assets_vfsdata.go | 4 +- config/config.go | 35 ++++ config/notifiers.go | 57 ++++++ config/receiver/receiver.go | 4 + docs/configuration.md | 88 +++++++++ notify/jira/jira.go | 368 ++++++++++++++++++++++++++++++++++++ notify/jira/types.go | 98 ++++++++++ notify/notify.go | 1 + template/default.tmpl | 12 ++ trigger.sh | 14 ++ 10 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 notify/jira/jira.go create mode 100644 notify/jira/types.go create mode 100644 trigger.sh diff --git a/asset/assets_vfsdata.go b/asset/assets_vfsdata.go index fe0d3ce719..ae20ffe07d 100644 --- a/asset/assets_vfsdata.go +++ b/asset/assets_vfsdata.go @@ -163,9 +163,9 @@ var Assets = func() http.FileSystem { "/templates/default.tmpl": &vfsgen۰CompressedFileInfo{ name: "default.tmpl", modTime: time.Date(1970, 1, 1, 0, 0, 1, 0, time.UTC), - uncompressedSize: 5875, + uncompressedSize: 6247, - compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x58\x41\x6f\xa3\x3a\x10\xbe\xf3\x2b\xac\xf4\xd2\x1c\x42\xdf\xb9\x52\xf5\x54\x3d\xbd\xdd\x4b\xb5\x5a\xa5\xca\x5e\x56\x2b\xe4\xc0\x84\xba\x31\x36\xb5\x87\xb4\x11\xe1\xbf\xaf\x0c\x94\x40\x0c\xa9\x49\xb3\xa7\xcd\xad\xb8\x33\xdf\x8c\xbf\xf9\x98\x19\x92\xe7\x24\x82\x15\x13\x40\x26\x41\x40\x39\x28\x4c\xa8\xa0\x31\xa8\x09\x29\x8a\xfb\xd6\x73\x9e\x13\x10\x11\x29\x0a\x6f\xd0\x65\x31\x7f\x30\x5e\x79\x4e\xfc\xff\xdf\x10\x94\xa0\x7c\x31\x7f\x20\x45\x71\x73\x75\x53\xda\xe9\x7f\x15\x84\xc0\x36\xa0\xee\x8c\xd1\xbc\x7e\x20\x3b\x92\x29\xfe\x92\x81\xda\x56\xee\x75\xa0\x6e\x24\x9d\x2d\x9f\x21\x44\x13\xe1\xa7\xf1\x7e\x44\x8a\x99\x26\x3b\x82\x72\x91\xa6\xa0\x2a\x57\xb6\x22\xf0\xd2\xfc\x73\xb2\x62\x8a\x89\xd8\xf8\xdc\x1a\x9f\xf2\x42\xda\xff\x52\x9e\x92\x1d\xe1\x20\xda\x11\x7f\x11\x63\xf4\x55\xc9\x2c\x7d\xa0\x4b\xe0\xda\x7f\x94\x0a\x21\xfa\x4e\x99\xd2\xfe\x0f\xca\x33\x30\x01\x9f\x25\x13\x64\x42\x0c\x2a\xa9\x42\xc6\x48\xae\x0d\x96\xff\x9f\x4c\x12\x29\x2a\xe7\x69\x7d\xd6\xc2\x9b\x92\xa2\xb8\xce\x73\xf2\xca\xf0\xa9\x6b\xec\xcf\x21\x91\x1b\xe8\x46\xff\x46\x13\xd0\x35\xa3\x7d\xd1\x9b\xc4\xa7\xcd\x5f\x03\x65\x8a\x40\x87\x8a\xa5\xc8\xa4\x98\x1c\xe1\x18\xe1\x0d\xab\x92\x06\x9c\x69\xac\x4d\x15\x15\x31\x10\x9f\x14\x45\x95\xd7\xad\xb7\x3f\xb4\x79\x32\xac\xcc\x4a\x22\x4d\xfa\xe6\xe9\x8e\x34\x17\xa8\x13\xab\x82\xdf\x0b\x21\x91\x9a\x9c\x3a\x90\xad\xe3\xd3\x70\x1f\x65\xa6\x42\xb8\xad\x8a\x09\x02\x14\x45\xa9\x2a\x25\x7a\x3d\x44\x1d\xa5\x20\x48\xa8\x5a\x47\xf2\x55\x58\x5c\x78\xae\x64\x38\x66\xed\x8d\xa7\xc3\x15\xd9\x89\x10\xaf\x9f\x11\xcd\x69\xb8\xf6\x23\x58\xd1\x8c\xa3\x8f\x0c\x39\xd4\x54\x20\x24\x29\xa7\xd8\x7d\x39\xfd\x21\x0d\x76\x71\x32\x6d\xda\x43\xd2\x07\xd5\x6d\x42\x8e\x78\x2b\xca\xf9\x92\x86\x6b\x0b\xaf\x37\x7d\x03\x4a\x76\xe4\x23\x43\xce\xc4\xda\x39\x83\xb0\xce\x80\x45\x13\x37\x87\x54\x81\xd1\x9a\xa3\x75\x2b\xa1\xa3\x8c\x95\x3d\xd8\x31\x65\x16\x4a\x01\x89\x7c\x66\x13\x77\xfb\x4c\x71\xd7\x8c\xdd\x2f\xb7\x92\x12\xab\x89\xd3\xd2\x60\xdb\x3c\x35\x57\x8b\x32\xdc\x36\x2e\x76\x43\x1b\x27\x47\x1b\x31\xe4\x0c\x04\x9e\x2e\xc8\x21\xc4\xfd\x54\x3c\xad\x66\x36\x2e\x13\x1a\xa9\x08\x41\xf7\xe0\x5a\x1d\xdc\x1f\x66\x55\xa6\x3a\x06\xc1\xa0\x01\x4e\x40\x6b\x1a\x9f\xf6\x7e\x5b\x60\x76\x85\xea\x81\x37\xd0\xd0\x7a\x27\x9c\x77\x30\x5f\x3b\x03\x7c\x4a\xfe\x21\x33\xd3\x38\xcb\x43\x52\x1d\x96\xad\xf3\x38\x23\xdd\x2d\xa0\x0c\x32\x6b\xdd\xa8\x27\xde\x1c\xb4\xe4\x1b\x88\x0e\x22\xbe\x1f\xbb\xc7\x7c\xf7\xb0\xa2\xce\x5c\x28\xd5\x65\x1f\x1f\xaf\xa6\x4e\xd5\x5f\x21\x7c\xa2\x38\xb6\xe6\xde\xa5\x7e\x47\xea\xd7\x5e\x94\x17\x8a\x5b\x78\xbd\xf5\x19\xa8\xfa\x41\x7d\x50\x06\x66\x58\x0e\x76\x52\xdb\x3c\xa5\x0a\xb7\x23\xec\x91\xc6\xae\xd6\x34\x06\x81\xc1\xe1\x88\xeb\xea\x6b\xc3\x42\x94\x4a\xa6\x7a\x2f\x5b\xa4\x08\x41\x57\x68\x17\x2d\x8d\xeb\x05\x36\xab\x20\x90\xe1\x36\x88\x98\x4e\x39\xdd\x06\x03\xdb\xd4\xc7\x8d\xdb\x46\x4e\xa4\x60\x28\x0d\x21\x01\x4a\xc9\x47\x8e\xc4\xce\xec\xca\xf4\x93\xdc\x80\x3a\xc3\xfe\x68\x41\xfd\x79\x3d\x9d\x47\x4e\xee\x6a\x3a\x9f\x98\xec\x95\xfe\x18\x93\xfb\x9d\x6e\xcc\x4c\x69\x6f\x73\xa2\xf5\xb2\xef\x3f\xd3\xc7\x7f\x23\xb4\x70\x2e\xe5\x1d\x53\xde\x36\x8b\x08\x1c\x62\x45\x93\x3e\x2a\xff\x5a\x52\x22\xa6\x43\xa9\xa2\x33\x34\xa2\x43\xa4\x0b\xbb\x66\x4d\x58\xc2\xdb\xe5\xd5\xfd\x34\x8f\x89\x46\xa0\x89\x3e\x83\x4a\x2d\xa4\xfa\x6b\xdc\x85\xda\x2b\x32\x8a\xdc\xd6\x4f\x64\x9f\x66\xb9\x09\xed\xca\x73\x4f\xf0\x8f\x08\xff\x1d\x00\x00\xff\xff\xc6\x76\xea\x54\xf3\x16\x00\x00"), + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x58\x4f\x6f\xbb\x46\x10\xbd\xf3\x29\x56\xfe\x5d\xe2\x83\x49\xcf\x91\xa2\x2a\xaa\xda\x5e\xa2\xaa\x72\xe4\x5e\xaa\x0a\xad\x61\x4c\xd6\xde\x3f\x64\x77\x70\x62\x61\xbe\x7b\xb5\x40\x6c\xf0\x82\xb3\x38\xee\xa9\xbe\x85\xcd\xcc\x9b\xd9\x37\x8f\x99\xc1\x45\x41\x12\x58\x31\x09\x64\x12\x45\x94\x83\x46\x41\x25\x4d\x41\x4f\x48\x59\x3e\xb5\x9e\x8b\x82\x80\x4c\x48\x59\x06\x83\x2e\x8b\xf9\xb3\xf5\x2a\x0a\x12\xfe\xfa\x81\xa0\x25\xe5\x8b\xf9\x33\x29\xcb\xfb\x1f\xf7\x95\x9d\xf9\x59\x43\x0c\x6c\x0b\xfa\xd1\x1a\xcd\x9b\x07\xb2\x27\xb9\xe6\x6f\x39\xe8\x5d\xed\xde\x04\xea\x46\x32\xf9\x72\x0d\x31\xda\x08\x7f\x5b\xef\x17\xa4\x98\x1b\xb2\x27\xa8\x16\x59\x06\xba\x76\x65\x2b\x02\x6f\x87\x7f\x4e\x56\x4c\x33\x99\x5a\x9f\x07\xeb\x53\x5d\xc8\x84\xbf\x55\xa7\x64\x4f\x38\xc8\x76\xc4\x7f\x88\x35\xfa\x5d\xab\x3c\x7b\xa6\x4b\xe0\x26\x7c\x51\x1a\x21\xf9\x93\x32\x6d\xc2\xbf\x28\xcf\xc1\x06\x5c\x2b\x26\xc9\x84\x58\x54\x52\x87\x4c\x91\xdc\x59\xac\xf0\x17\x25\x84\x92\xb5\xf3\xb4\x39\x6b\xe1\x4d\x49\x59\xde\x15\x05\x79\x67\xf8\xda\x35\x0e\xe7\x20\xd4\x16\xba\xd1\xff\xa0\x02\x4c\xc3\x68\x5f\xf4\x43\xe2\xd3\xc3\x5f\x03\x65\x4a\xc0\xc4\x9a\x65\xc8\x94\x9c\x9c\xe1\x18\xe1\x03\xeb\x92\x46\x9c\x19\x6c\x4c\x35\x95\x29\x90\x90\x94\x65\x9d\xd7\x43\x70\x3c\x74\x79\xb2\xac\xcc\x2a\x22\x6d\xfa\xf6\xe9\x91\x1c\x2e\xd0\x24\x56\x07\x7f\x92\x52\x21\xb5\x39\x75\x20\x5b\xc7\x97\xe1\xbe\xa8\x5c\xc7\xf0\x50\x17\x13\x24\x68\x8a\x4a\xd7\x4a\x0c\x7a\x88\x3a\x4b\x41\x24\xa8\xde\x24\xea\x5d\x3a\x5c\x04\xbe\x64\x78\x66\x1d\x8c\xa7\xc3\x17\xd9\x8b\x90\xa0\x9f\x11\xc3\x69\xbc\x09\x13\x58\xd1\x9c\x63\x88\x0c\x39\x34\x54\x20\x88\x8c\x53\xec\xbe\x9c\xe1\x90\x06\xbb\x38\xb9\xb1\xed\x41\xf4\x41\x75\x9b\x90\x27\xde\x8a\x72\xbe\xa4\xf1\xc6\xc1\xeb\x4d\xdf\x82\x92\x3d\xf9\xca\x90\x33\xb9\xf1\xce\x20\x6e\x32\x60\xc9\xc4\xcf\x21\xd3\x60\xb5\xe6\x69\xdd\x4a\xe8\x2c\x63\x55\x0f\xf6\x4c\x99\xc5\x4a\x82\x50\x6b\x36\xf1\xb7\xcf\x35\xf7\xcd\xd8\xff\x72\x2b\xa5\xb0\x9e\x38\x2d\x0d\xb6\xcd\x33\x7b\xb5\x24\xc7\xdd\xc1\xc5\x6d\x68\xe3\xe4\xe8\x22\xc6\x9c\x81\xc4\xcb\x05\x39\x84\x78\x9c\x8a\x97\xd5\xcc\xc5\x65\xd2\x20\x95\x31\x98\x1e\x5c\xa7\x83\x87\xc3\xac\xaa\xcc\xa4\x20\x19\x1c\x80\x05\x18\x43\xd3\xcb\xde\x6f\x07\xcc\xad\x50\x33\xf0\x06\x1a\x5a\xef\x84\x0b\x4e\xe6\x6b\x67\x80\x4f\xc9\x4f\x64\x66\x1b\x67\x75\x48\xea\xc3\xaa\x75\x9e\x67\xa4\xbb\x05\x54\x41\x66\xad\x1b\xf5\xc4\x9b\x83\x51\x7c\x0b\xc9\x49\xc4\xcf\x63\xff\x98\x9f\x1e\x4e\xd4\x99\x0f\xa5\xa6\xea\xe3\xe3\xd5\xd4\xa9\xfa\x3b\xc4\xaf\x14\xc7\xd6\x3c\xb8\xd5\xef\x4c\xfd\xda\x8b\xf2\x42\x73\x07\xaf\xb7\x3e\x03\x55\x3f\xa9\x0f\xaa\xc8\x0e\xcb\xc1\x4e\xea\x9a\x67\x54\xe3\x6e\x84\x3d\xd2\xd4\xd7\x9a\xa6\x20\x31\x3a\x1d\x71\x5d\x7d\x6d\x59\x8c\x4a\xab\xcc\x1c\x65\x8b\x14\x21\xea\x0a\xed\xa6\xa5\x71\xbd\xc0\x65\x15\x24\x32\xdc\x45\x09\x33\x19\xa7\xbb\x68\x60\x9b\xfa\xba\x71\xbb\xc8\x42\x49\x86\xca\x12\x12\xa1\x52\x7c\xe4\x48\xec\xcc\xae\xdc\xbc\xaa\x2d\xe8\x2b\xec\x8f\x0e\xd4\x7f\xaf\xa7\xeb\xc8\xc9\x5f\x4d\xd7\x13\x93\xbb\xd2\x9f\x63\xf2\xb8\xd3\x8d\x99\x29\xed\x6d\x4e\xb6\x5e\xf6\xe3\x67\xfa\xf8\x6f\x84\x16\xce\xad\xbc\x63\xca\xdb\x66\x11\x81\x43\xaa\xa9\xe8\xa3\xf2\x7f\x4b\x4a\xc2\x4c\xac\x74\x72\x85\x46\x74\x8a\x74\x63\xd7\xae\x09\x4b\xf8\xb8\xbd\xba\xdf\xe6\x51\x18\x04\x2a\xcc\x15\x54\xea\x20\x35\x5f\xe3\x3e\xd4\xfe\x20\xa3\xc8\x6d\xfd\x44\xf6\x6d\x96\x0f\xa1\x7d\x79\xee\x09\x3e\x86\xf0\x35\xd3\xb4\x35\xba\x84\xa0\x7a\x77\x11\xdf\x1d\xa0\x93\x4f\xdf\x1b\xe7\xc1\xbf\x01\x00\x00\xff\xff\x9d\x10\x28\x99\x67\x18\x00\x00"), }, "/templates/email.tmpl": &vfsgen۰CompressedFileInfo{ name: "email.tmpl", diff --git a/config/config.go b/config/config.go index bf236aefe0..68d0851e8d 100644 --- a/config/config.go +++ b/config/config.go @@ -257,6 +257,9 @@ func resolveFilepaths(baseDir string, cfg *Config) { for _, cfg := range receiver.MSTeamsConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } + for _, cfg := range receiver.JiraConfigs { + cfg.HTTPConfig.SetDirectory(baseDir) + } } } @@ -539,6 +542,33 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { return fmt.Errorf("no msteams webhook URL provided") } } + for _, jira := range rcv.JiraConfigs { + if jira.HTTPConfig == nil { + jira.HTTPConfig = c.Global.HTTPConfig + } + if jira.APIURL == nil { + if c.Global.JiraAPIURL == nil { + return fmt.Errorf("no global Jira Cloud URL set") + } + jira.APIURL = c.Global.JiraAPIURL + } + if !strings.HasSuffix(jira.APIURL.Path, "/") { + jira.APIURL.Path += "/" + } + if jira.APIUsername == "" { + if c.Global.JiraAPIUsername == "" { + return fmt.Errorf("no global Jira Cloud username set") + } + jira.APIUsername = c.Global.JiraAPIUsername + } + if jira.APIToken == "" && len(jira.APITokenFile) == 0 { + if c.Global.JiraAPIToken == "" && len(c.Global.JiraAPITokenFile) == 0 { + return fmt.Errorf("no global Jira Cloud API Token set either inline or in a file") + } + jira.APIToken = c.Global.JiraAPIToken + jira.APITokenFile = c.Global.JiraAPITokenFile + } + } names[rcv.Name] = struct{}{} } @@ -741,6 +771,10 @@ type GlobalConfig struct { HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + JiraAPIURL *URL `yaml:"jira_api_url,omitempty" json:"jira_api_url,omitempty"` + JiraAPIUsername string `yaml:"jira_api_username,omitempty" json:"jira_api_username,omitempty"` + JiraAPIToken Secret `yaml:"jira_api_token,omitempty" json:"jira_api_token,omitempty"` + JiraAPITokenFile string `yaml:"jira_api_token_file,omitempty" json:"jira_api_token_file,omitempty"` SMTPFrom string `yaml:"smtp_from,omitempty" json:"smtp_from,omitempty"` SMTPHello string `yaml:"smtp_hello,omitempty" json:"smtp_hello,omitempty"` SMTPSmarthost HostPort `yaml:"smtp_smarthost,omitempty" json:"smtp_smarthost,omitempty"` @@ -908,6 +942,7 @@ type Receiver struct { TelegramConfigs []*TelegramConfig `yaml:"telegram_configs,omitempty" json:"telegram_configs,omitempty"` WebexConfigs []*WebexConfig `yaml:"webex_configs,omitempty" json:"webex_configs,omitempty"` MSTeamsConfigs []*MSTeamsConfig `yaml:"msteams_configs,omitempty" json:"msteams_configs,omitempty"` + JiraConfigs []*JiraConfig `yaml:"jira_configs,omitempty" json:"jira_configs,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for Receiver. diff --git a/config/notifiers.go b/config/notifiers.go index 2650db5f3b..331a7133d9 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -172,6 +172,14 @@ var ( Title: `{{ template "msteams.default.title" . }}`, Text: `{{ template "msteams.default.text" . }}`, } + + DefaultJiraConfig = JiraConfig{ + NotifierConfig: NotifierConfig{ + VSendResolved: true, + }, + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + } ) // NotifierConfig contains base options common across all notifier configurations. @@ -797,3 +805,52 @@ func (c *MSTeamsConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { type plain MSTeamsConfig return unmarshal((*plain)(c)) } + +type JiraConfig struct { + NotifierConfig `yaml:",inline" json:",inline"` + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + APIURL *URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` + APIUsername string `yaml:"api_username,omitempty" json:"api_username,omitempty"` + APIToken Secret `yaml:"api_token,omitempty" json:"api_token,omitempty"` + APITokenFile string `yaml:"api_token_file,omitempty" json:"api_token_file,omitempty"` + + Project string `yaml:"project,omitempty" json:"project,omitempty"` + Summary string `yaml:"summary,omitempty" json:"summary,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + StaticLabels []string `yaml:"static_labels,omitempty" json:"static_labels,omitempty"` + GroupLabels []string `yaml:"group_labels,omitempty" json:"group_labels,omitempty"` + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty"` + + ReopenTransition string `yaml:"reopen_transition,omitempty" json:"reopen_transition,omitempty"` + ResolveTransition string `yaml:"resolve_transition,omitempty" json:"resolve_transition,omitempty"` + WontFixResolution string `yaml:"wont_fix_resolution,omitempty" json:"wont_fix_resolution,omitempty"` + ReopenDuration duration `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty"` + + Fields map[any]any `yaml:"custom_fields,omitempty" json:"custom_fields,omitempty"` +} + +func (c *JiraConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultJiraConfig + type plain JiraConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.APIToken == "" && c.APITokenFile == "" { + return fmt.Errorf("missing api_token or api_token_file on jira_config") + } + + if c.APIToken != "" && len(c.APITokenFile) > 0 { + return fmt.Errorf("at most one of api_token & api_token_file must be configured") + } + + if c.Project == "" { + return fmt.Errorf("missing project on jira_config") + } + if c.IssueType == "" { + return fmt.Errorf("missing issue_type on jira_config") + } + + return nil +} diff --git a/config/receiver/receiver.go b/config/receiver/receiver.go index 9bb039ef05..a4ea528a3c 100644 --- a/config/receiver/receiver.go +++ b/config/receiver/receiver.go @@ -15,6 +15,7 @@ package receiver import ( "github.com/go-kit/log" + "github.com/prometheus/alertmanager/notify/jira" commoncfg "github.com/prometheus/common/config" @@ -92,6 +93,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg for i, c := range nc.MSTeamsConfigs { add("msteams", i, c, func(l log.Logger) (notify.Notifier, error) { return msteams.New(c, tmpl, l, httpOpts...) }) } + for i, c := range nc.JiraConfigs { + add("jira", i, c, func(l log.Logger) (notify.Notifier, error) { return jira.New(c, tmpl, l, httpOpts...) }) + } if errs.Len() > 0 { return nil, &errs diff --git a/docs/configuration.md b/docs/configuration.md index 8a8d796066..9ebf8d4ec2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,6 +84,11 @@ global: # Note that Go does not support unencrypted connections to remote SMTP endpoints. [ smtp_require_tls: | default = true ] + [ jira_api_url: ] + [ jira_api_username: ] + [ jira_api_token: ] + [ jira_api_token_file: ] + # The API URL to use for Slack notifications. [ slack_api_url: ] [ slack_api_url_file: ] @@ -504,6 +509,8 @@ email_configs: [ - , ... ] msteams_configs: [ - , ... ] +jira_configs: + [ - , ... ] opsgenie_configs: [ - , ... ] pagerduty_configs: @@ -743,6 +750,87 @@ Microsoft Teams notifications are sent via the [Incoming Webhooks](https://learn [ http_config: | default = global.http_config ] ``` +### `` + +JIRA notification are sent via [JIRA Rest API v2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/) +or [JIRA REST API v3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#version). + +Both APIs have the same feature set. The difference is that V2 uses [Wiki Markup](https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all) +for format the issue description and V3 uses [Atlassian Document Format (ADF)](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/). +The default `jira.default.description` template only works with V2. + +```yaml +# Whether to notify about resolved alerts. +[ send_resolved: | default = true ] + +# The incoming webhook URL. +[ webhook_url: ] + +# The Atlassian Side to send Jira API requests to. API path must be included. +# Example: https://company.atlassian.net/rest/api/2/ +[ api_url: | default = global.jira_api_url ] +[ api_username: | default = global.jira_api_username ] +[ api_token: | default = global.jira_api_token ] +[ api_token_file: | default = global.jira_api_token_file ] + +# The project key where issues are created. +project: + +# Issue summary template. +[ summary: | default = '{{ template "jira.default.summary" . }}' ] + +# Issue description template. +[ description: | default = '{{ template "jira.default.description" . }}' ] + +# Add labels to issues +static_labels: + [ - ... ] + +# Add specific group labels to issue +group_labels: + [ - ... ] + +# Priority of issue +[ priority: ] + +# Type of issue, e.g. Bug +[ issue_type: ] + +# Name of the workflow transition to resolve an issue. The target status must have the category "done" +[ resolve_transition: ] + +# Name of the workflow transition to reopen an issue. The target status should not have the category "done" +[ reopen_transition: ] + +# If reopen_transition is defined, ignore issues with that resolution +[ wont_fix_resolution: ] + +# If reopen_transition is defined, reopen issue not older than ... +[ reopen_duration: ] + +# Custom fields +custom_fields: + [ : ... ] + + +# The HTTP client's configuration. +[ http_config: | default = global.http_config ] +``` + +#### `` + +Jira custom field can have multiple types. Depends on the filed type, the values must be provided differently. + +```yaml +fields: + # TextField + customfield_10001: "Random text" + # SelectList + customfield_10002: {"value": "red"} + # MultiSelect + customfield_10003: [{"value": "red"}, {"value": "blue"}, {"value": "green"}] +``` + ### `` OpsGenie notifications are sent via the [OpsGenie API](https://docs.opsgenie.com/docs/alert-api). diff --git a/notify/jira/jira.go b/notify/jira/jira.go new file mode 100644 index 0000000000..6f6cd8e573 --- /dev/null +++ b/notify/jira/jira.go @@ -0,0 +1,368 @@ +// Copyright 2023 Prometheus Team +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jira + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "reflect" + "sort" + "strings" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/pkg/errors" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +// Notifier implements a Notifier for JIRA notifications. +type Notifier struct { + conf *config.JiraConfig + tmpl *template.Template + logger log.Logger + client *http.Client + retrier *notify.Retrier +} + +// New returns a new Webhook. +func New(c *config.JiraConfig, t *template.Template, l log.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { + client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "jira", httpOpts...) + if err != nil { + return nil, err + } + return &Notifier{ + conf: c, + tmpl: t, + logger: l, + client: client, + retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, + }, nil +} + +// Notify implements the Notifier interface. +func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + + level.Debug(n.logger).Log("alert", key) + + var ( + tmplTextErr error + + alerts = types.Alerts(as...) + data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger) + tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr) + tmplTextFunc = func(tmpl string) (string, error) { + result := tmplText(tmpl) + return result, tmplTextErr + } + ) + + existingIssue, shouldRetry, err := n.searchExistingIssue(tmplTextFunc, key, alerts.Status()) + if err != nil { + return shouldRetry, err + } + + // Do not create new issues for resolved alerts + if existingIssue == nil && alerts.Status() == model.AlertResolved { + return false, nil + } + + path := "issue" + method := http.MethodPost + + if existingIssue == nil { + level.Debug(n.logger).Log("msg", "create new issue", "alert", key.String()) + } else { + level.Debug(n.logger).Log("msg", "updating existing issue", "key", existingIssue.Key, "alert", key.String()) + + path += "/" + existingIssue.Key + method = http.MethodPut + } + + requestBody, err := n.prepareIssueRequestBody(tmplTextFunc) + if err != nil { + return false, err + } + + requestBody.Fields.Labels = append(requestBody.Fields.Labels, key.Hash()) + + for _, labelKey := range n.conf.GroupLabels { + if val, ok := data.GroupLabels[labelKey]; ok { + requestBody.Fields.Labels = append(requestBody.Fields.Labels, val) + } + } + + _, shouldRetry, err = n.doAPIRequest(tmplTextFunc, method, path, requestBody) + if err != nil { + return shouldRetry, err + } + + if existingIssue != nil { + if n.conf.ResolveTransition != "" && alerts.Status() == model.AlertResolved && existingIssue.Fields.Status.StatusCategory.Key != "done" { + return n.TransitionIssue(tmplTextFunc, key, existingIssue.Key, n.conf.ResolveTransition) + } else if n.conf.ReopenTransition != "" && alerts.Status() == model.AlertFiring && existingIssue.Fields.Status.StatusCategory.Key == "done" { + return n.TransitionIssue(tmplTextFunc, key, existingIssue.Key, n.conf.ReopenTransition) + } + } + + return false, nil +} + +func (n *Notifier) prepareIssueRequestBody(tmplTextFunc templateFunc) (issue, error) { + summary, err := tmplTextFunc(n.conf.Summary) + if err != nil { + return issue{}, errors.Wrap(err, "template error") + } + + fields, err := customFields(n.conf.Fields) + if err != nil { + return issue{}, err + } + + requestBody := issue{Fields: &issueFields{ + Project: &issueProject{Key: n.conf.Project}, + Issuetype: &idNameValue{Name: n.conf.IssueType}, + Summary: summary, + Labels: make([]string, 0), + CustomFields: fields, + }} + + issueDescriptionString, err := tmplTextFunc(n.conf.Summary) + if err != nil { + return issue{}, errors.Wrap(err, "template error") + } + + if strings.HasSuffix(n.conf.APIURL.Path, "/3") { + var issueDescription any + if err := json.Unmarshal([]byte(issueDescriptionString), &issueDescription); err != nil { + return issue{}, nil + } + requestBody.Fields.Description = issueDescription + } else { + requestBody.Fields.Description = issueDescriptionString + } + + if n.conf.StaticLabels != nil { + requestBody.Fields.Labels = n.conf.StaticLabels + } + + priority, err := tmplTextFunc(n.conf.Priority) + if err != nil { + return issue{}, errors.Wrap(err, "template error") + } + + if priority != "" { + requestBody.Fields.Priority = &idNameValue{Name: priority} + } + + sort.Strings(requestBody.Fields.Labels) + + return requestBody, nil +} + +func (n *Notifier) searchExistingIssue(tmplTextFunc templateFunc, key notify.Key, status model.AlertStatus) (*issue, bool, error) { + jql := strings.Builder{} + + if n.conf.WontFixResolution != "" { + jql.WriteString(fmt.Sprintf(`resolution != "%s" and `, n.conf.WontFixResolution)) + } + + // if alert is firing, do not search for closed issues unless reopen transition is defined. + if n.conf.ReopenTransition == "" { + if status != model.AlertResolved { + jql.WriteString(`statusCategory != Done and `) + } + } else { + reopenDuration := int64(time.Duration(n.conf.ReopenDuration).Minutes()) + if reopenDuration != 0 { + jql.WriteString(fmt.Sprintf(`(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenDuration)) + } + } + + jql.WriteString(fmt.Sprintf(`project = "%s" and labels=%q order by status ASC,resolutiondate DESC`, n.conf.Project, key.Hash())) + + requestBody := issueSearch{} + requestBody.Jql = jql.String() + requestBody.MaxResults = 2 + requestBody.Fields = []string{"status"} + requestBody.Expand = []string{} + + level.Debug(n.logger).Log("msg", "search for recent issues", "alert", key.String(), "jql", jql.String()) + + responseBody, shouldRetry, err := n.doAPIRequest(tmplTextFunc, http.MethodPost, "search", requestBody) + if err != nil { + return nil, shouldRetry, err + } + + var issueSearchResult issueSearchResult + err = json.Unmarshal(responseBody, &issueSearchResult) + if err != nil { + return nil, false, err + } + + switch issueSearchResult.Total { + case 0: + level.Debug(n.logger).Log("msg", "found no existing issue", "alert", key.String()) + return nil, false, nil + default: + level.Warn(n.logger).Log("msg", "more than one issue matched, picking most recently resolved", "alert", key.String(), "picked", issueSearchResult.Issues[0].Key) + + fallthrough + case 1: + return &issueSearchResult.Issues[0], false, nil + } +} + +func (n *Notifier) getIssueTransitionByName(tmplTextFunc templateFunc, issueKey, transitionName string) (string, bool, error) { + path := fmt.Sprintf("issue/%s/transitions", issueKey) + + responseBody, shouldRetry, err := n.doAPIRequest(tmplTextFunc, http.MethodGet, path, nil) + if err != nil { + return "", shouldRetry, err + } + + var issueTransitions issueTransitions + err = json.Unmarshal(responseBody, &issueTransitions) + if err != nil { + return "", false, err + } + + for _, issueTransition := range issueTransitions.Transitions { + if issueTransition.Name == transitionName { + return issueTransition.ID, false, nil + } + } + + return "", false, fmt.Errorf("can't find transition %s for issue %s", transitionName, issueKey) +} + +func (n *Notifier) TransitionIssue(tmplText templateFunc, key notify.Key, issueKey, transitionName string) (bool, error) { + transitionID, shouldRetry, err := n.getIssueTransitionByName(tmplText, issueKey, transitionName) + if err != nil { + return shouldRetry, err + } + + requestBody := issue{} + requestBody.Transition = &idNameValue{ID: transitionID} + + path := fmt.Sprintf("issue/%s/transitions", issueKey) + + level.Debug(n.logger).Log("msg", "transitions jira issue", "alert", key.String(), "key", issueKey, "transition", transitionName) + _, shouldRetry, err = n.doAPIRequest(tmplText, http.MethodPost, path, requestBody) + + return shouldRetry, err +} + +func (n *Notifier) doAPIRequest(tmplTextFunc templateFunc, method, path string, requestBody any) ([]byte, bool, error) { + var body io.Reader + if requestBody != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(requestBody); err != nil { + return nil, false, err + } + + body = &buf + } + + url := n.conf.APIURL.Copy() + url.Path += path + + req, err := http.NewRequest(method, url.String(), body) + if err != nil { + return nil, false, err + } + + var token string + if n.conf.APIToken != "" { + token, err = tmplTextFunc(string(n.conf.APIToken)) + if err != nil { + return nil, false, errors.Wrap(err, "template error") + } + } else { + content, err := os.ReadFile(n.conf.APITokenFile) + if err != nil { + return nil, false, errors.Wrap(err, "read key_file error") + } + token, err = tmplTextFunc(string(content)) + if err != nil { + return nil, false, errors.Wrap(err, "template error") + } + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "en") + req.Header.Set("X-Force-Accept-Language", "true") + req.SetBasicAuth(n.conf.APIUsername, strings.TrimSpace(token)) + + resp, err := n.client.Do(req) + defer resp.Body.Close() + + if err != nil { + return nil, false, err + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, err + } + + shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(responseBody)) + if err != nil { + return nil, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) + } + + return responseBody, false, nil +} + +// customFields ensure that all nested properties has a string +func customFields(fields map[any]any) (map[string]any, error) { + var err error + + customFieldMap := map[string]any{} + + for key, field := range fields { + key, ok := key.(string) + if !ok { + return nil, errors.New("Detect non string key in custom_fields") + } + + v := reflect.ValueOf(field) + switch v.Kind() { + case reflect.Map: + customFieldMap[key], err = customFields(field.(map[any]any)) + if err != nil { + return nil, err + } + default: + customFieldMap[key] = field + } + } + + return customFieldMap, nil +} diff --git a/notify/jira/types.go b/notify/jira/types.go new file mode 100644 index 0000000000..40f3048fc5 --- /dev/null +++ b/notify/jira/types.go @@ -0,0 +1,98 @@ +package jira + +import ( + "encoding/json" +) + +type templateFunc func(string) (string, error) + +type issue struct { + Key string `json:"key,omitempty"` + Fields *issueFields `json:"fields,omitempty"` + Transition *idNameValue `json:"transition,omitempty"` +} + +type issueFields struct { + Description any `json:"description"` + Issuetype *idNameValue `json:"issuetype,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority *idNameValue `json:"priority,omitempty"` + Project *issueProject `json:"project,omitempty"` + Resolution *idNameValue `json:"resolution,omitempty"` + Summary string `json:"summary"` + Status *issueStatus `json:"status,omitempty"` + + CustomFields map[string]any `json:"-"` +} + +type idNameValue struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type issueProject struct { + Key string `json:"key"` +} + +type issueStatus struct { + Name string `json:"name"` + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` +} + +type issueSearch struct { + Expand []string `json:"expand"` + Fields []string `json:"fields"` + FieldsByKeys bool `json:"fieldsByKeys"` + Jql string `json:"jql"` + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` +} + +type issueSearchResult struct { + Total int `json:"total"` + Issues []issue `json:"issues"` +} + +type issueTransitions struct { + Transitions []idNameValue `json:"transitions"` +} + +// MarshalJSON merges the struct issueFields and issueFields.CustomField together +func (i issueFields) MarshalJSON() ([]byte, error) { + jsonFields := map[string]interface{}{ + "description": i.Description, + "summary": i.Summary, + } + + if i.Issuetype != nil { + jsonFields["issuetype"] = i.Issuetype + } + + if i.Labels != nil { + jsonFields["labels"] = i.Labels + } + + if i.Priority != nil { + jsonFields["priority"] = i.Priority + } + + if i.Project != nil { + jsonFields["project"] = i.Project + } + + if i.Resolution != nil { + jsonFields["resolution"] = i.Resolution + } + + if i.Status != nil { + jsonFields["status"] = i.Status + } + + for key, customField := range i.CustomFields { + jsonFields[key] = customField + } + + return json.Marshal(jsonFields) +} diff --git a/notify/notify.go b/notify/notify.go index 33d499af30..9a90249d9a 100644 --- a/notify/notify.go +++ b/notify/notify.go @@ -344,6 +344,7 @@ func (m *Metrics) InitializeFor(receiver map[string][]Integration) { "discord", "webex", "msteams", + "jira", } { m.numNotifications.WithLabelValues(integration) m.numNotificationRequestsTotal.WithLabelValues(integration) diff --git a/template/default.tmpl b/template/default.tmpl index a1bbfe96fc..fff86aaf35 100644 --- a/template/default.tmpl +++ b/template/default.tmpl @@ -157,3 +157,15 @@ Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} {{ end }} + +{{ define "jira.default.summary" }}{{ template "__subject" . }}{{ end }} +{{ define "jira.default.description" }} +{{ if gt (len .Alerts.Firing) 0 }} +# Alerts Firing: +{{ template "__text_alert_list_markdown" .Alerts.Firing }} +{{ end }} +{{ if gt (len .Alerts.Resolved) 0 }} +# Alerts Resolved: +{{ template "__text_alert_list_markdown" .Alerts.Resolved }} +{{ end }} +{{ end }} diff --git a/trigger.sh b/trigger.sh new file mode 100644 index 0000000000..87521ebfb1 --- /dev/null +++ b/trigger.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +alertname=${1:-$RANDOM} +url='http://localhost:9093/api/v1/alerts' +echo "Firing up alert" +curl -XPOST $url -d '[{"status": "firing","labels": {"alertname": "'$alertname'","service": "curl","severity": "warning","instance": "0"},"annotations": {"summary": "This is a summary","description": "This is a description."},"generatorURL": "http://prometheus.int.example.net/","startsAt": "2020-07-23T01:05:36+00:00"}]' +echo "" + +echo "press enter to resolve alert" +read + +echo "sending resolve" +curl -XPOST $url -d '[{"status": "resolved","labels": {"alertname": "'$alertname'","service": "curl","severity": "warning","instance": "0"},"annotations": {"summary": "This is a summary","description": "This is a description."},"generatorURL": "http://prometheus.int.example.net/","startsAt": "2020-07-23T01:05:36+00:00","endsAt": "2020-07-23T01:05:38+00:00"}]' +echo ""