// 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/prometheus/common/promslog" "github.com/stretchr/testify/require" "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), promslog.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), promslog.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), promslog.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) }) } }