From a509c14b103e97895e7339a1a8cf87d293187b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Wed, 31 Jul 2024 17:15:26 +0200 Subject: [PATCH] jira integration (#3590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial jira integration Signed-off-by: Jan-Otto Kröpke Signed-off-by: Jan-Otto Kröpke Signed-off-by: Jan-Otto Kröpke Co-authored-by: Simon Pasquier Co-authored-by: Ben Kochie --- asset/assets_vfsdata.go | 4 +- config/config.go | 16 + config/notifiers.go | 48 +++ config/receiver/receiver.go | 4 + docs/configuration.md | 96 +++++ go.mod | 1 + go.sum | 2 + notify/jira/jira.go | 345 +++++++++++++++ notify/jira/jira_test.go | 808 ++++++++++++++++++++++++++++++++++++ notify/jira/types.go | 110 +++++ notify/notify.go | 1 + template/default.tmpl | 39 ++ 12 files changed, 1472 insertions(+), 2 deletions(-) create mode 100644 notify/jira/jira.go create mode 100644 notify/jira/jira_test.go create mode 100644 notify/jira/types.go diff --git a/asset/assets_vfsdata.go b/asset/assets_vfsdata.go index f74112ad01..16ab608eb3 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: 5951, + uncompressedSize: 7283, - compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x58\x4f\x6f\xbb\x46\x10\xbd\xf3\x29\x56\xce\x25\x3e\x98\xf4\x1c\x29\xaa\xa2\xaa\xed\x25\xaa\x2a\x47\xee\xa5\xaa\xd0\x1a\xc6\x64\xe3\xfd\x43\x76\x07\x27\x16\xe6\xbb\x57\x0b\xc4\x06\x2f\x38\x8b\xe3\xdf\xe9\xe7\x5b\xd8\xcc\xbc\x99\x7d\x6f\x98\x19\x5c\x14\x24\x81\x15\x93\x40\x26\x51\x44\x39\x68\x14\x54\xd2\x14\xf4\x84\x94\xe5\x63\xeb\xb9\x28\x08\xc8\x84\x94\x65\x30\xe8\xb2\x98\x3f\x59\xaf\xa2\x20\xe1\xef\x1f\x08\x5a\x52\xbe\x98\x3f\x91\xb2\xbc\xbb\xb9\xab\xec\xcc\xaf\x1a\x62\x60\x1b\xd0\x0f\xd6\x68\xde\x3c\x90\x1d\xc9\x35\x7f\xcb\x41\x6f\x6b\xf7\x26\x50\x37\x92\xc9\x97\xaf\x10\xa3\x8d\xf0\xaf\xf5\x7e\x46\x8a\xb9\x21\x3b\x82\x6a\x91\x65\xa0\x6b\x57\xb6\x22\xf0\xb6\xff\xe7\x64\xc5\x34\x93\xa9\xf5\xb9\xb7\x3e\xd5\x85\x4c\xf8\x47\x75\x4a\x76\x84\x83\x6c\x47\xfc\x8f\x58\xa3\x3f\xb5\xca\xb3\x27\xba\x04\x6e\xc2\x67\xa5\x11\x92\xbf\x29\xd3\x26\xfc\x87\xf2\x1c\x6c\xc0\x57\xc5\x24\x99\x10\x8b\x4a\xea\x90\x29\x92\x5b\x8b\x15\xfe\xa6\x84\x50\xb2\x76\x9e\x36\x67\x2d\xbc\x29\x29\xcb\xdb\xa2\x20\xef\x0c\x5f\xba\xc6\xe1\x1c\x84\xda\x40\x37\xfa\x5f\x54\x80\x69\x18\xed\x8b\xbe\x4f\x7c\xba\xff\x6b\x40\xa6\x04\x4c\xac\x59\x86\x4c\xc9\xc9\x09\x8e\x11\x3e\xb0\x96\x34\xe2\xcc\x60\x63\xaa\xa9\x4c\x81\x84\xa4\x2c\xeb\xbc\xee\x83\xc3\xa1\xcb\x93\x65\x65\x56\x11\x69\xd3\xb7\x4f\x0f\x64\x7f\x81\x26\xb1\x3a\xf8\xa3\x94\x0a\xa9\xcd\xa9\x03\xd9\x3a\x3e\x0f\xf7\x59\xe5\x3a\x86\xfb\x5a\x4c\x90\xa0\x29\x2a\x5d\x57\x62\xd0\x43\xd4\x49\x0a\x22\x41\xf5\x3a\x51\xef\xd2\xe1\x22\xf0\x25\xc3\x33\xeb\x60\x3c\x1d\xbe\xc8\x5e\x84\x04\xfd\x8c\x18\x4e\xe3\x75\x98\xc0\x8a\xe6\x1c\x43\x64\xc8\xa1\xa1\x02\x41\x64\x9c\x62\xf7\xe5\x0c\x87\x6a\xb0\x8b\x93\x1b\xdb\x1e\x44\x1f\x54\xb7\x09\x79\xe2\xad\x28\xe7\x4b\x1a\xaf\x1d\xbc\xde\xf4\x2d\x28\xd9\x91\xaf\x0c\x39\x93\x6b\xef\x0c\xe2\x26\x03\x96\x4c\xfc\x1c\x32\x0d\xb6\xd6\x3c\xad\x5b\x09\x9d\x64\xac\xea\xc1\x9e\x29\xb3\x58\x49\x10\xea\x95\x4d\xfc\xed\x73\xcd\x7d\x33\xf6\xbf\xdc\x4a\x29\xac\x27\x4e\xab\x06\xdb\xe6\x99\xbd\x5a\x92\xe3\x76\xef\xe2\x36\xb4\x71\xe5\xe8\x22\xc6\x9c\x81\xc4\xf3\x0b\x72\x08\xf1\x30\x15\xcf\xd3\xcc\xc5\x65\xd2\x20\x95\x31\x98\x1e\x5c\xa7\x83\x87\xc3\xac\xaa\xcc\xa4\x20\x19\xec\x81\x05\x18\x43\xd3\xf3\xde\x6f\x07\xcc\x55\xa8\x19\x78\x03\x0d\xad\x77\xc2\x05\x47\xf3\xb5\x33\xc0\xa7\xe4\x17\x32\xb3\x8d\xb3\x3a\x24\xf5\x61\xd5\x3a\x4f\x33\xd2\xdd\x02\xaa\x20\xb3\xd6\x8d\x7a\xe2\xcd\xc1\x28\xbe\x81\xe4\x28\xe2\xe7\xb1\x7f\xcc\x4f\x0f\x27\xea\xcc\x87\x52\x53\xf5\xf1\xf1\xd5\xd4\x51\xfd\x1d\xe2\x17\x8a\x63\x35\x0f\xae\xfa\x9d\xd0\xaf\xbd\x28\x2f\x34\x77\xf0\x7a\xf5\x19\x50\xfd\x48\x1f\x54\x91\x1d\x96\x83\x9d\xd4\x35\xcf\xa8\xc6\xed\x08\x7b\xa4\xa9\xaf\x35\x4d\x41\x62\x74\x3c\xe2\xba\xf5\xb5\x61\x31\x2a\xad\x32\x73\x28\x5b\xa4\x08\x51\xb7\xd0\xae\xb5\x34\xae\x17\xb8\xac\x82\x44\x86\xdb\x28\x61\x26\xe3\x74\x1b\x0d\x6c\x53\x5f\x37\x6e\x17\x59\x28\xc9\x50\x59\x42\x22\x54\x8a\x8f\x1c\x89\x9d\xd9\x95\x9b\x17\xb5\x01\x7d\x81\xfd\xd1\x81\xfa\xf1\xf5\x74\x99\x72\xf2\xaf\xa6\xcb\x15\x93\xbb\xd2\x9f\x62\xf2\xb0\xd3\x8d\x99\x29\xed\x6d\x4e\xb6\x5e\xf6\xc3\x67\xfa\xf8\x6f\x84\x16\xce\x55\xde\x31\xf2\xb6\x59\x44\xe0\x90\x6a\x2a\xfa\xa8\xfc\x69\x49\x49\x98\x89\x95\x4e\x2e\xd0\x88\x8e\x91\xae\xec\xda\x35\x61\x09\x1f\xd7\x57\xf7\xdb\x3c\x0a\x83\x40\x45\xbb\x99\x0a\x41\xf5\xf6\xac\x3a\x3d\xc6\x3a\xbf\xe2\x1d\xa4\xe6\xcb\xde\x47\xa6\x1b\x32\x4a\xa8\xd6\xcf\x6d\xdf\x56\x6c\x1f\xda\x57\xb3\x9e\xe0\x5f\x89\xf7\x7f\x00\x00\x00\xff\xff\xf6\x0e\x88\xb1\x3f\x17\x00\x00"), + compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x59\xcd\x6e\xeb\x36\x13\xdd\xfb\x29\x06\xba\xdf\x22\x5e\x44\xf7\x5b\x07\x08\x8a\x8b\xa2\x3f\x8b\xb4\x28\x1c\xa4\x9b\xa2\x10\x18\x69\xac\x30\xa1\x48\x85\x1c\xd9\x31\x1c\xbd\x7b\x41\x49\x96\x45\x49\xb6\x29\x5f\x77\x55\xef\x62\x7a\xe6\x9c\xe1\x39\xa3\x21\xe5\x6c\xb7\x90\xe0\x92\x4b\x84\x20\x8a\x98\x40\x4d\x19\x93\x2c\x45\x1d\x40\x59\x7e\xeb\x7c\xde\x6e\x01\x65\x02\x65\x39\x3b\x98\xf2\xb4\x78\xb0\x59\xdb\x2d\x84\x3f\x7d\x10\x6a\xc9\xc4\xd3\xe2\x01\xca\xf2\xeb\x97\xaf\x55\x9c\xf9\x41\x63\x8c\x7c\x85\xfa\xde\x06\x2d\x9a\x0f\xf0\x09\x85\x16\xef\x05\xea\x4d\x9d\xde\x10\xb9\x4c\xa6\x78\x7e\xc5\x98\x2c\xc3\x5f\x36\xfb\x91\x18\x15\x06\x3e\x81\xd4\x53\x9e\xa3\xae\x53\xf9\x12\xf0\xbd\xfd\x32\x58\x72\xcd\x65\x6a\x73\xee\x6c\x4e\xb5\x21\x13\xfe\x5c\xad\xc2\x27\x08\x94\x5d\xc6\xbf\xc1\x06\xfd\xa2\x55\x91\x3f\xb0\x67\x14\x26\x7c\x54\x9a\x30\xf9\x83\x71\x6d\xc2\x3f\x99\x28\xd0\x12\xbe\x2a\x2e\x21\x00\x8b\x0a\x35\x65\x4a\x70\x63\xb1\xc2\x1f\x55\x96\x29\x59\x27\xcf\x9b\xb5\x0e\xde\x1c\xca\xf2\x66\xbb\x85\x35\xa7\x17\x37\x38\x5c\x60\xa6\x56\xe8\xb2\xff\xce\x32\x34\x8d\xa2\x63\xec\x6d\xe1\xf3\xf6\xaf\x03\x36\x25\x68\x62\xcd\x73\xe2\x4a\x06\x47\x34\x26\xfc\xa0\xda\xd2\x48\x70\x43\x4d\xa8\x66\x32\x45\x08\xa1\x2c\xeb\xba\xee\x66\xfb\xc5\xa1\x4e\x56\x95\xdb\x4a\x48\x5b\xbe\xfd\x74\x0f\xed\x06\x9a\xc2\x6a\xf2\x6f\x52\x2a\x62\xb6\x26\x07\xb2\xb3\x7c\x1e\xee\xa3\x2a\x74\x8c\x77\xb5\x99\x28\x51\x33\x52\xba\xee\xc4\xd9\x88\x50\x47\x25\x88\x32\xa6\xdf\x12\xb5\x96\x03\x2d\x66\xbe\x62\x78\x56\x3d\x9b\x2e\x87\x2f\xb2\x97\x20\xb3\x71\x45\x8c\x60\xf1\x5b\x98\xe0\x92\x15\x82\x42\xe2\x24\xb0\x91\x82\x30\xcb\x05\x23\xf7\xe1\x0c\x0f\xf5\xa0\x8b\x53\x18\x3b\x1e\xb2\x31\x28\x77\x08\x79\xe2\x2d\x99\x10\xcf\x2c\x7e\x1b\xe0\x8d\x96\x6f\x41\xe1\x13\x4e\x05\x0a\x2e\xdf\xbc\x2b\x88\x9b\x0a\x78\x12\xf8\x25\xe4\x1a\x6d\xaf\x79\x46\x77\x0a\x3a\xaa\x58\x35\x83\x3d\x4b\xe6\xb1\x92\x98\xa9\x57\x1e\xf8\xc7\x17\x5a\xf8\x56\xec\xbf\xb9\xa5\x52\x54\x9f\x38\x9d\x1e\xec\x86\xe7\x76\x6b\x49\x41\x9b\x36\x65\x38\xd0\xa6\xb5\xe3\x10\x31\x16\x1c\x25\x9d\xdf\x90\x87\x10\xf7\xa7\xe2\x79\x9e\x0d\x71\xb9\x34\xc4\x64\x8c\x66\x04\x77\x30\xc1\xc3\xc3\xaa\xaa\xdc\xa4\x28\x39\xb6\xc0\x19\x1a\xc3\xd2\xf3\x9e\xef\x01\xd8\xd0\xa1\xe6\xc0\x3b\x30\xd0\x46\x4f\xb8\x59\xef\x7c\x75\x0e\xf0\x39\xfc\x1f\x6e\xed\xe0\xac\x16\xa1\x5e\xac\x46\xe7\x71\x45\xdc\x5b\x40\x45\x72\xdb\xd9\xd1\x08\xdf\x02\x8d\x12\x2b\x4c\x7a\x8c\xbb\x65\x7f\xce\x5d\xc6\x80\xf5\xd6\x47\x52\x53\xcd\xf1\xe9\xdd\xe4\xb8\xbe\xc6\xf8\x85\xd1\x54\xcf\x67\x57\xff\x8e\xf8\xd7\xbd\x28\x3f\x69\x31\xc0\x1b\xf5\xe7\x80\xeb\x3d\x7f\x48\x45\xf6\xb0\x3c\x38\x49\x87\xe1\x39\xd3\xb4\x99\x10\x4f\x2c\xf5\x8d\x66\x29\x4a\x8a\xfa\x47\x9c\xdb\x5f\x2b\x1e\x93\xd2\x2a\x37\xfb\xb6\x25\x46\x18\xb9\x8d\x76\xed\xa5\x69\xb3\x60\xa8\x2a\x4a\xe2\xb4\x89\x12\x6e\x72\xc1\x36\xd1\x81\xdb\xd4\xe9\xc1\x3d\x44\xce\x94\xe4\xa4\xac\x20\x11\x29\x25\x26\x1e\x89\xce\xd9\x55\x98\x17\xb5\x42\x7d\x81\xfb\xe3\x00\xea\xdf\xef\xa7\xcb\xb4\x93\x7f\x37\x5d\xae\x99\x86\x57\xfa\x63\x4a\xee\xef\x74\x53\xce\x94\xee\x6d\x4e\x76\x1e\xf6\xfd\x6b\xfa\xf4\x77\x84\x0e\xce\xd5\xde\x29\xf6\x76\x55\x24\x14\x98\x6a\x96\x8d\x49\xf9\x9f\x15\x25\xe1\x26\x56\x3a\xb9\xc0\x20\xea\x23\x5d\xd5\xb5\xd7\x84\x67\xfc\xb8\x3e\xba\xdf\xad\x63\x66\x08\x59\xd6\x1d\xa6\x59\xc6\xf4\xe6\xac\x3e\xed\x63\x9d\xdf\xf1\x03\xa4\xe6\xcd\xde\xc7\xa6\x2f\x30\xc9\xa8\xce\xcf\x6d\xdf\xed\x58\x4b\xed\xeb\xd9\x08\xf9\x14\xf3\x5e\xb9\x66\x17\x71\xce\x01\xea\xbd\x46\x5f\x35\x9f\x55\x37\xe4\x51\xad\x72\xcd\x95\xe6\xf6\xe5\xe7\xb6\xb9\x48\xff\x6f\xb7\x04\x77\xf7\x10\x04\xbb\xfb\xf5\xee\xa7\x55\x67\xb7\x36\x07\x00\xa0\xca\x33\xb8\xc2\x5d\x1e\x97\x09\x7e\xec\x7e\xdd\x85\x60\xf7\x55\xe0\x64\xf0\x25\xdc\xe0\x7b\x27\x31\x88\x35\x27\x1e\x33\x11\xcc\xdb\xc0\x16\xbe\x2d\xeb\x1e\x82\x5f\x79\xfa\xe2\x62\xa1\x30\x58\x01\x32\x99\xf4\x51\xd7\x4c\x4b\x2e\xd3\x60\x0e\x37\x12\x3b\x40\x35\xcc\xfc\x04\xd7\x6f\x98\xf0\x22\xf3\x67\xe3\x72\xa9\x2c\x95\x5d\xdd\x53\x9d\xa4\x79\x50\xeb\x1e\x87\x4c\x5a\x4f\xba\x7f\xd7\xff\xae\xe9\x42\x3b\x69\xae\x4f\x6d\x63\x0c\xb8\x27\xb9\x35\xd9\x31\x0f\xd7\x2e\xee\x9c\x97\x7b\x97\x73\xf0\xb4\x8b\x7d\x27\x4f\x39\xbb\x47\xea\x7f\xfb\x4f\x00\x00\x00\xff\xff\x30\xb3\x3d\xcd\x73\x1c\x00\x00"), }, "/templates/email.tmpl": &vfsgen۰CompressedFileInfo{ name: "email.tmpl", diff --git a/config/config.go b/config/config.go index 3f356996a0..4d694eefcc 100644 --- a/config/config.go +++ b/config/config.go @@ -263,6 +263,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) + } } } @@ -548,6 +551,17 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { return fmt.Errorf("no msteams webhook URL or URLFile 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 + } + } names[rcv.Name] = struct{}{} } @@ -752,6 +766,7 @@ 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"` 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"` @@ -920,6 +935,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 34bef22cd1..6984166170 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -22,6 +22,7 @@ import ( "time" commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" "github.com/prometheus/common/sigv4" ) @@ -172,6 +173,15 @@ var ( Summary: `{{ template "msteams.default.summary" . }}`, Text: `{{ template "msteams.default.text" . }}`, } + + DefaultJiraConfig = JiraConfig{ + NotifierConfig: NotifierConfig{ + VSendResolved: true, + }, + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + Priority: `{{ template "jira.default.priority" . }}`, + } ) // NotifierConfig contains base options common across all notifier configurations. @@ -825,3 +835,41 @@ func (c *MSTeamsConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } + +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"` + + Project string `yaml:"project,omitempty" json:"project,omitempty"` + Summary string `yaml:"summary,omitempty" json:"summary,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Labels []string `yaml:"labels,omitempty" json:"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 model.Duration `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty"` + + Fields map[string]any `yaml:"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.Project == "" { + return fmt.Errorf("missing project in jira_config") + } + if c.IssueType == "" { + return fmt.Errorf("missing issue_type in jira_config") + } + + return nil +} diff --git a/config/receiver/receiver.go b/config/receiver/receiver.go index 9bb039ef05..040d7534b9 100644 --- a/config/receiver/receiver.go +++ b/config/receiver/receiver.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/discord" "github.com/prometheus/alertmanager/notify/email" + "github.com/prometheus/alertmanager/notify/jira" "github.com/prometheus/alertmanager/notify/msteams" "github.com/prometheus/alertmanager/notify/opsgenie" "github.com/prometheus/alertmanager/notify/pagerduty" @@ -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 992e1fe425..3269b3a59e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,6 +97,9 @@ global: # The default TLS configuration for SMTP receivers [ smtp_tls_config: ] + # Default settings for the JIRA integration. + [ jira_api_url: ] + # The API URL to use for Slack notifications. [ slack_api_url: ] [ slack_api_url_file: ] @@ -697,6 +700,8 @@ email_configs: [ - , ... ] msteams_configs: [ - , ... ] +jira_configs: + [ - , ... ] opsgenie_configs: [ - , ... ] pagerduty_configs: @@ -944,6 +949,97 @@ Microsoft Teams notifications are sent via the [Incoming Webhooks](https://learn [ http_config: | default = global.http_config ] ``` +### `` + +JIRA notifications 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). + +Note: This integration is only tested against a Jira Cloud instance. +Jira Data Center (on premise instance) can work, but it's not guaranteed. + +Both APIs have the same feature set. The difference is that V2 supports [Wiki Markup](https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all) +for the issue description and V3 supports [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 URL to send API requests to. The full API path must be included. +# Example: https://company.atlassian.net/rest/api/2/ +[ api_url: | default = global.jira_api_url ] + +# 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" . }}' ] + +# Labels to be added to the issue. +labels: + [ - ... ] + +# Priority of the issue. +[ priority: | default = '{{ template "jira.default.priority" . }}' ] + +# Type of the issue (e.g. Bug). +[ issue_type: ] + +# Name of the workflow transition to resolve an issue. The target status must have the category "done". +# NOTE: The name of the transition can be localized and depends on the language setting of the service account. +[ resolve_transition: ] + +# Name of the workflow transition to reopen an issue. The target status should not have the category "done". +# NOTE: The name of the transition can be localized and depends on the language setting of the service account. +[ reopen_transition: ] + +# If reopen_transition is defined, ignore issues with that resolution. +[ wont_fix_resolution: ] + +# If reopen_transition is defined, reopen the issue when it is not older than this value (rounded down to the nearest minute). +# The resolutiondate field is used to determine the age of the issue. +[ reopen_duration: ] + +# Other issue and custom fields. +fields: + [ : ... ] + + +# The HTTP client's configuration. You must use this configuration to supply the personal access token (PAT) as part of the HTTP `Authorization` header. +# For Jira Cloud, use basic_auth with the email address as the username and the PAT as the password. +# For Jira Data Center, use the 'authorization' field with 'credentials: '. +[ http_config: | default = global.http_config ] +``` + +The `labels` field is a list of labels added to the issue. Template expressions are supported. For example: + +```yaml +labels: + - 'alertmanager' + - '{{ .CommonLabels.severity }}' +``` + +#### `` + +Jira issue field can have multiple types. +Depends on the field type, the values must be provided differently. +See https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/#setting-custom-field-data-for-other-field-types for further examples. + +```yaml +fields: + # Components + components: { name: "Monitoring" } + # Custom Field TextField + customfield_10001: "Random text" + # Custom Field SelectList + customfield_10002: {"value": "red"} + # Custom Field 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/go.mod b/go.mod index e354ba6e8c..2ac6dacf99 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 github.com/stretchr/testify v1.9.0 + github.com/trivago/tgo v1.0.7 github.com/xlab/treeprint v1.2.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.5.3 diff --git a/go.sum b/go.sum index 723843baa1..bab3442404 100644 --- a/go.sum +++ b/go.sum @@ -502,6 +502,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= +github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= diff --git a/notify/jira/jira.go b/notify/jira/jira.go new file mode 100644 index 0000000000..51fabe2f08 --- /dev/null +++ b/notify/jira/jira.go @@ -0,0 +1,345 @@ +// 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" + "sort" + "strings" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/trivago/tgo/tcontainer" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +const ( + maxSummaryLenRunes = 255 + maxDescriptionLenRunes = 32767 +) + +// 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 +} + +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 + } + + path string + method string + ) + + existingIssue, shouldRetry, err := n.searchExistingIssue(ctx, key, alerts.Status()) + if err != nil { + return shouldRetry, fmt.Errorf("error searching existing issues: %w", err) + } + + if existingIssue == nil { + // Do not create new issues for resolved alerts + if alerts.Status() == model.AlertResolved { + return false, nil + } + + level.Debug(n.logger).Log("msg", "create new issue", "alert", key.String()) + + path = "issue" + method = http.MethodPost + } else { + level.Debug(n.logger).Log("msg", "updating existing issue", "key", existingIssue.Key, "alert", key.String()) + + path = "issue/" + existingIssue.Key + method = http.MethodPut + } + + requestBody, err := n.prepareIssueRequestBody(ctx, tmplTextFunc) + if err != nil { + return false, err + } + + requestBody.Fields.Labels = append(requestBody.Fields.Labels, fmt.Sprintf("ALERT{%s}", key.Hash())) + + sort.Strings(requestBody.Fields.Labels) + + _, shouldRetry, err = n.doAPIRequest(ctx, method, path, requestBody) + if err != nil { + return shouldRetry, fmt.Errorf("error create/update existing issues: %w", err) + } + + if existingIssue != nil && existingIssue.Key != "" && existingIssue.Fields != nil && existingIssue.Fields.Status != nil { + if n.conf.ResolveTransition != "" && alerts.Status() == model.AlertResolved && existingIssue.Fields.Status.StatusCategory.Key != "done" { + return n.transitionIssue(ctx, key, existingIssue.Key, n.conf.ResolveTransition) + } else if n.conf.ReopenTransition != "" && alerts.Status() == model.AlertFiring && existingIssue.Fields.Status.StatusCategory.Key == "done" { + return n.transitionIssue(ctx, key, existingIssue.Key, n.conf.ReopenTransition) + } + } + + return false, nil +} + +func (n *Notifier) prepareIssueRequestBody(ctx context.Context, tmplTextFunc templateFunc) (issue, error) { + summary, err := tmplTextFunc(n.conf.Summary) + if err != nil { + return issue{}, fmt.Errorf("template error: %w", err) + } + + // Recursively convert any maps to map[string]interface{}, filtering out all non-string keys, so the json encoder + // doesn't blow up when marshaling JIRA requests. + fieldsWithStringKeys, err := tcontainer.ConvertToMarshalMap(n.conf.Fields, func(v string) string { return v }) + if err != nil { + return issue{}, fmt.Errorf("convertToMarshalMap error: %w", err) + } + + summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes) + if truncated { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return issue{}, err + } + level.Warn(n.logger).Log("msg", "Truncated summary", "key", key, "max_runes", maxSummaryLenRunes) + } + + requestBody := issue{Fields: &issueFields{ + Project: &issueProject{Key: n.conf.Project}, + Issuetype: &idNameValue{Name: n.conf.IssueType}, + Summary: summary, + Labels: make([]string, 0, len(n.conf.Labels)+1), + Fields: fieldsWithStringKeys, + }} + + issueDescriptionString, err := tmplTextFunc(n.conf.Description) + if err != nil { + return issue{}, fmt.Errorf("template error: %w", err) + } + + issueDescriptionString, truncated = notify.TruncateInRunes(issueDescriptionString, maxDescriptionLenRunes) + if truncated { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return issue{}, err + } + level.Warn(n.logger).Log("msg", "Truncated description", "key", key, "max_runes", maxDescriptionLenRunes) + } + + 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.Labels != nil { + for _, label := range n.conf.Labels { + label, err = tmplTextFunc(label) + if err != nil { + return issue{}, fmt.Errorf("template error: %w", err) + } + requestBody.Fields.Labels = append(requestBody.Fields.Labels, label) + } + } + + priority, err := tmplTextFunc(n.conf.Priority) + if err != nil { + return issue{}, fmt.Errorf("template error: %w", err) + } + + if priority != "" { + requestBody.Fields.Priority = &idNameValue{Name: priority} + } + + return requestBody, nil +} + +func (n *Notifier) searchExistingIssue(ctx context.Context, key notify.Key, status model.AlertStatus) (*issue, bool, error) { + jql := strings.Builder{} + + if n.conf.WontFixResolution != "" { + jql.WriteString(fmt.Sprintf(`resolution != %q and `, n.conf.WontFixResolution)) + } + + // if the 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)) + } + } + + alertLabel := fmt.Sprintf("ALERT{%s}", key.Hash()) + jql.WriteString(fmt.Sprintf(`project=%q and labels=%q order by status ASC,resolutiondate DESC`, n.conf.Project, alertLabel)) + + 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(ctx, 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 + } + + if issueSearchResult.Total == 0 { + level.Debug(n.logger).Log("msg", "found no existing issue", "alert", key.String()) + return nil, false, nil + } + + if issueSearchResult.Total > 1 { + level.Warn(n.logger).Log("msg", "more than one issue matched, selecting the most recently resolved", "alert", key.String(), "selected", issueSearchResult.Issues[0].Key) + } + + return &issueSearchResult.Issues[0], false, nil +} + +func (n *Notifier) getIssueTransitionByName(ctx context.Context, issueKey, transitionName string) (string, bool, error) { + path := fmt.Sprintf("issue/%s/transitions", issueKey) + + responseBody, shouldRetry, err := n.doAPIRequest(ctx, 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(ctx context.Context, key notify.Key, issueKey, transitionName string) (bool, error) { + transitionID, shouldRetry, err := n.getIssueTransitionByName(ctx, 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(ctx, http.MethodPost, path, requestBody) + + return shouldRetry, err +} + +func (n *Notifier) doAPIRequest(ctx context.Context, 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.JoinPath(path) + req, err := http.NewRequestWithContext(ctx, method, url.String(), body) + if err != nil { + return nil, false, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "en") + + resp, err := n.client.Do(req) + if err != nil { + return nil, false, err + } + + defer notify.Drain(resp) + + 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 +} diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go new file mode 100644 index 0000000000..8e2576ca3e --- /dev/null +++ b/notify/jira/jira_test.go @@ -0,0 +1,808 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/go-kit/log" + + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/test" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +func TestJiraRetry(t *testing.T) { + notifier, err := New( + &config.JiraConfig{ + APIURL: &config.URL{ + URL: &url.URL{ + Scheme: "https", + Host: "example.atlassian.net", + Path: "/rest/api/2", + }, + }, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, + test.CreateTmpl(t), + log.NewNopLogger(), + ) + require.NoError(t, err) + + retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests) + + for statusCode, expected := range test.RetryTests(retryCodes) { + actual, _ := notifier.retrier.Check(statusCode, nil) + require.Equal(t, expected, actual, fmt.Sprintf("retry - error on status %d", statusCode)) + } +} + +func TestJiraTemplating(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + w.Write([]byte(`{"total": 0, "issues": []}`)) + return + default: + dec := json.NewDecoder(r.Body) + out := make(map[string]any) + err := dec.Decode(&out) + if err != nil { + panic(err) + } + } + })) + defer srv.Close() + u, _ := url.Parse(srv.URL) + + for _, tc := range []struct { + title string + cfg *config.JiraConfig + + retry bool + errMsg string + }{ + { + title: "full-blown message", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + }, + retry: false, + }, + { + title: "summary with templating errors", + cfg: &config.JiraConfig{ + Summary: "{{ ", + }, + errMsg: "template: :1: unclosed action", + }, + { + title: "description with templating errors", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: "{{ ", + }, + errMsg: "template: :1: unclosed action", + }, + { + title: "priority with templating errors", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + Priority: "{{ ", + }, + errMsg: "template: :1: unclosed action", + }, + } { + tc := tc + + t.Run(tc.title, func(t *testing.T) { + tc.cfg.APIURL = &config.URL{URL: u} + tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} + pd, err := New(tc.cfg, test.CreateTmpl(t), log.NewNopLogger()) + require.NoError(t, err) + + ctx := context.Background() + ctx = notify.WithGroupKey(ctx, "1") + + ok, err := pd.Notify(ctx, []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "lbl1": "val1", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }...) + if tc.errMsg == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errMsg) + } + require.Equal(t, tc.retry, ok) + }) + } +} + +func TestJiraNotify(t *testing.T) { + for _, tc := range []struct { + title string + cfg *config.JiraConfig + + alert *types.Alert + + customFieldAssetFn func(t *testing.T, issue map[string]any) + searchResponse issueSearchResult + issue issue + errMsg string + }{ + { + title: "create new issue", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + IssueType: "Incident", + Project: "OPS", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Total: 0, + Issues: []issue{}, + }, + issue: issue{ + Key: "", + Fields: &issueFields{ + Summary: "[FIRING:1] test (vm1 critical)", + Description: "\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n", + Issuetype: &idNameValue{Name: "Incident"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "OPS"}, + Priority: &idNameValue{Name: "High"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, + errMsg: "", + }, + { + title: "create new issue with custom field and too long summary", + cfg: &config.JiraConfig{ + Summary: strings.Repeat("A", maxSummaryLenRunes+10), + Description: `{{ template "jira.default.description" . }}`, + IssueType: "Incident", + Project: "OPS", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + Fields: map[string]any{ + "components": map[any]any{"name": "Monitoring"}, + "customfield_10001": "value", + "customfield_10002": 0, + "customfield_10003": []any{0}, + "customfield_10004": map[any]any{"value": "red"}, + "customfield_10005": map[any]any{"value": 0}, + "customfield_10006": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": "green"}}, + "customfield_10007": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": 0}}, + "customfield_10008": []map[any]any{{"value": 0}, {"value": 1}, {"value": 2}}, + }, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Total: 0, + Issues: []issue{}, + }, + issue: issue{ + Key: "", + Fields: &issueFields{ + Summary: strings.Repeat("A", maxSummaryLenRunes-1) + "…", + Description: "\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n", + Issuetype: &idNameValue{Name: "Incident"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "OPS"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) { + require.Equal(t, "value", issue["customfield_10001"]) + require.Equal(t, float64(0), issue["customfield_10002"]) + require.Equal(t, []any{float64(0)}, issue["customfield_10003"]) + require.Equal(t, map[string]any{"value": "red"}, issue["customfield_10004"]) + require.Equal(t, map[string]any{"value": float64(0)}, issue["customfield_10005"]) + require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": "green"}}, issue["customfield_10006"]) + require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": float64(0)}}, issue["customfield_10007"]) + require.Equal(t, []any{map[string]any{"value": float64(0)}, map[string]any{"value": float64(1)}, map[string]any{"value": float64(2)}}, issue["customfield_10008"]) + }, + errMsg: "", + }, + { + title: "reopen issue", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + IssueType: "Incident", + Project: "OPS", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Total: 1, + Issues: []issue{ + { + Key: "OPS-1", + Fields: &issueFields{ + Status: &issueStatus{ + Name: "Closed", + StatusCategory: struct { + Key string `json:"key"` + }{ + Key: "done", + }, + }, + }, + }, + }, + }, + issue: issue{ + Key: "", + Fields: &issueFields{ + Summary: "[FIRING:1] test (vm1)", + Description: "\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n", + Issuetype: &idNameValue{Name: "Incident"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "OPS"}, + Priority: &idNameValue{Name: "High"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, + errMsg: "", + }, + { + title: "error resolve transition not found", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + IssueType: "Incident", + Project: "OPS", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + }, + StartsAt: time.Now().Add(-time.Hour), + EndsAt: time.Now().Add(-time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Total: 1, + Issues: []issue{ + { + Key: "OPS-3", + Fields: &issueFields{ + Status: &issueStatus{ + Name: "Open", + StatusCategory: struct { + Key string `json:"key"` + }{ + Key: "open", + }, + }, + }, + }, + }, + }, + issue: issue{ + Key: "", + Fields: &issueFields{ + Summary: "[RESOLVED] test (vm1)", + Description: "\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n", + Issuetype: &idNameValue{Name: "Incident"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "OPS"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, + errMsg: "can't find transition CLOSE for issue OPS-3", + }, + { + title: "error reopen transition not found", + cfg: &config.JiraConfig{ + Summary: `{{ template "jira.default.summary" . }}`, + Description: `{{ template "jira.default.description" . }}`, + IssueType: "Incident", + Project: "OPS", + Priority: `{{ template "jira.default.priority" . }}`, + Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, + ReopenDuration: model.Duration(1 * time.Hour), + ReopenTransition: "REOPEN", + ResolveTransition: "CLOSE", + WontFixResolution: "WONTFIX", + }, + alert: &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + searchResponse: issueSearchResult{ + Total: 1, + Issues: []issue{ + { + Key: "OPS-3", + Fields: &issueFields{ + Status: &issueStatus{ + Name: "Closed", + StatusCategory: struct { + Key string `json:"key"` + }{ + Key: "done", + }, + }, + }, + }, + }, + }, + issue: issue{ + Key: "", + Fields: &issueFields{ + Summary: "[FIRING:1] test (vm1)", + Description: "\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n", + Issuetype: &idNameValue{Name: "Incident"}, + Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, + Project: &issueProject{Key: "OPS"}, + }, + }, + customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, + errMsg: "can't find transition REOPEN for issue OPS-3", + }, + } { + tc := tc + + t.Run(tc.title, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + enc := json.NewEncoder(w) + if err := enc.Encode(tc.searchResponse); err != nil { + panic(err) + } + + return + case "/issue/OPS-1/transitions": + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + + transitions := issueTransitions{ + Transitions: []idNameValue{ + {ID: "12345", Name: "REOPEN"}, + }, + } + + enc := json.NewEncoder(w) + if err := enc.Encode(transitions); err != nil { + panic(err) + } + case http.MethodPost: + dec := json.NewDecoder(r.Body) + var out issue + err := dec.Decode(&out) + if err != nil { + panic(err) + } + + require.Equal(t, issue{Transition: &idNameValue{ID: "12345"}}, out) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + + return + case "/issue/OPS-2/transitions": + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + + transitions := issueTransitions{ + Transitions: []idNameValue{ + {ID: "54321", Name: "CLOSE"}, + }, + } + + enc := json.NewEncoder(w) + if err := enc.Encode(transitions); err != nil { + panic(err) + } + case http.MethodPost: + dec := json.NewDecoder(r.Body) + var out issue + err := dec.Decode(&out) + if err != nil { + panic(err) + } + + require.Equal(t, issue{Transition: &idNameValue{ID: "54321"}}, out) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + + return + case "/issue/OPS-3/transitions": + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + + transitions := issueTransitions{ + Transitions: []idNameValue{}, + } + + enc := json.NewEncoder(w) + if err := enc.Encode(transitions); err != nil { + panic(err) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + + return + case "/issue/OPS-1": + case "/issue/OPS-2": + case "/issue/OPS-3": + fallthrough + case "/issue": + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + + var ( + issue issue + raw map[string]any + ) + + if err := json.Unmarshal(body, &issue); err != nil { + panic(err) + } + + // We don't care about the key, so copy it over. + issue.Fields.Fields = tc.issue.Fields.Fields + + require.Equal(t, tc.issue.Key, issue.Key) + require.Equal(t, tc.issue.Fields, issue.Fields) + + if err := json.Unmarshal(body, &raw); err != nil { + panic(err) + } + + if fields, ok := raw["fields"].(map[string]any); ok { + tc.customFieldAssetFn(t, fields) + } else { + t.Errorf("fields should a map of string") + } + + w.WriteHeader(http.StatusCreated) + + w.WriteHeader(http.StatusCreated) + + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + u, _ := url.Parse(srv.URL) + + tc.cfg.APIURL = &config.URL{URL: u} + tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} + + notifier, err := New(tc.cfg, test.CreateTmpl(t), log.NewNopLogger()) + require.NoError(t, err) + + ctx := context.Background() + ctx = notify.WithGroupKey(ctx, "1") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "test"}) + + _, err = notifier.Notify(ctx, tc.alert) + if tc.errMsg == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.EqualError(t, err, tc.errMsg) + } + }) + } +} + +func TestJiraPriority(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + title string + + alerts []*types.Alert + + expectedPriority string + }{ + { + "empty", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "", + }, + { + "critical", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "High", + }, + { + "warning", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "warning", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "Medium", + }, + { + "info", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "info", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "Low", + }, + { + "critical+warning+info", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "warning", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "info", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "High", + }, + { + "warning+info", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "warning", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "info", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "Medium", + }, + { + "critical(resolved)+warning+info", + []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "critical", + }, + StartsAt: time.Now().Add(-time.Hour), + EndsAt: time.Now().Add(-time.Hour), + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "warning", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "test", + "instance": "vm1", + "severity": "info", + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }, + }, + "Medium", + }, + } { + tc := tc + + t.Run(tc.title, func(t *testing.T) { + t.Parallel() + u, err := url.Parse("http://example.com/") + require.NoError(t, err) + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + + tmpl.ExternalURL = u + + var ( + data = tmpl.Data("jira", model.LabelSet{}, tc.alerts...) + + tmplTextErr error + tmplText = notify.TmplText(tmpl, data, &tmplTextErr) + tmplTextFunc = func(tmpl string) (string, error) { + result := tmplText(tmpl) + return result, tmplTextErr + } + ) + + priority, err := tmplTextFunc(`{{ template "jira.default.priority" . }}`) + require.NoError(t, err) + require.Equal(t, tc.expectedPriority, priority) + }) + } +} diff --git a/notify/jira/types.go b/notify/jira/types.go new file mode 100644 index 0000000000..af5335c4b0 --- /dev/null +++ b/notify/jira/types.go @@ -0,0 +1,110 @@ +// 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 ( + "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"` + + Fields 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"` + 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, field := range i.Fields { + jsonFields[key] = field + } + + return json.Marshal(jsonFields) +} diff --git a/notify/notify.go b/notify/notify.go index d1065ab793..b5e1ae91b6 100644 --- a/notify/notify.go +++ b/notify/notify.go @@ -365,6 +365,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 8b2bb7470e..299bdecbb6 100644 --- a/template/default.tmpl +++ b/template/default.tmpl @@ -158,3 +158,42 @@ 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 }} + +{{- define "jira.default.priority" -}} +{{- $priority := "" }} +{{- range .Alerts.Firing -}} + {{- $severity := index .Labels "severity" -}} + {{- if (eq $severity "critical") -}} + {{- $priority = "High" -}} + {{- else if (and (eq $severity "warning") (ne $priority "High")) -}} + {{- $priority = "Medium" -}} + {{- else if (and (eq $severity "info") (eq $priority "")) -}} + {{- $priority = "Low" -}} + {{- end -}} +{{- end -}} +{{- if eq $priority "" -}} + {{- range .Alerts.Resolved -}} + {{- $severity := index .Labels "severity" -}} + {{- if (eq $severity "critical") -}} + {{- $priority = "High" -}} + {{- else if (and (eq $severity "warning") (ne $priority "High")) -}} + {{- $priority = "Medium" -}} + {{- else if (and (eq $severity "info") (eq $priority "")) -}} + {{- $priority = "Low" -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $priority -}} +{{- end -}}