diff --git a/notify/jira/jira.go b/notify/jira/jira.go index 5d91382fe..62f0172ca 100644 --- a/notify/jira/jira.go +++ b/notify/jira/jira.go @@ -119,7 +119,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) return n.transitionIssue(ctx, logger, existingIssue, alerts.HasFiring()) } -func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc templateFunc) (issue, error) { +func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc template.TemplateFunc) (issue, error) { summary, err := tmplTextFunc(n.conf.Summary) if err != nil { return issue{}, fmt.Errorf("summary template: %w", err) @@ -140,6 +140,13 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge return issue{}, fmt.Errorf("convertToMarshalMap: %w", err) } + for key, value := range fieldsWithStringKeys { + fieldsWithStringKeys[key], err = template.DeepCopyWithTemplate(value, tmplTextFunc) + if err != nil { + return issue{}, fmt.Errorf("fields template: %w", err) + } + } + summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes) if truncated { logger.Warn("Truncated summary", "max_runes", maxSummaryLenRunes) @@ -194,7 +201,7 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge return requestBody, nil } -func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc templateFunc) (*issue, bool, error) { +func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc template.TemplateFunc) (*issue, bool, error) { jql := strings.Builder{} if n.conf.WontFixResolution != "" { diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go index 6abc1671e..115b1d884 100644 --- a/notify/jira/jira_test.go +++ b/notify/jira/jira_test.go @@ -318,6 +318,8 @@ func TestPrepareSearchRequest(t *testing.T) { } func TestJiraTemplating(t *testing.T) { + var capturedBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/search": @@ -326,10 +328,10 @@ func TestJiraTemplating(t *testing.T) { default: dec := json.NewDecoder(r.Body) out := make(map[string]any) - err := dec.Decode(&out) - if err != nil { + if err := dec.Decode(&out); err != nil { panic(err) } + capturedBody = out } })) defer srv.Close() @@ -339,16 +341,23 @@ func TestJiraTemplating(t *testing.T) { title string cfg *config.JiraConfig - retry bool - errMsg string + retry bool + errMsg string + expectedFieldKey string + expectedFieldValue any }{ { - title: "full-blown message", + title: "full-blown message with templated custom field", cfg: &config.JiraConfig{ Summary: `{{ template "jira.default.summary" . }}`, Description: `{{ template "jira.default.description" . }}`, + Fields: map[string]any{ + "customfield_14400": `{{ template "jira.host" . }}`, + }, }, - retry: false, + retry: false, + expectedFieldKey: "customfield_14400", + expectedFieldValue: "host1.example.com", }, { title: "template project", @@ -396,19 +405,32 @@ func TestJiraTemplating(t *testing.T) { tc := tc t.Run(tc.title, func(t *testing.T) { + capturedBody = nil + 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) + // Add the jira.host template just for this test + if tc.expectedFieldKey == "customfield_14400" { + err = pd.tmpl.Parse(strings.NewReader(`{{ define "jira.host" }}{{ .CommonLabels.hostname }}{{ end }}`)) + require.NoError(t, err) + } + ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{ + "lbl1": "val1", + "hostname": "host1.example.com", + }) ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ - "lbl1": "val1", + "lbl1": "val1", + "hostname": "host1.example.com", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), @@ -422,6 +444,14 @@ func TestJiraTemplating(t *testing.T) { require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) + + // Verify that custom fields were templated correctly + if tc.expectedFieldKey != "" { + require.NotNil(t, capturedBody, "expected request body") + fields, ok := capturedBody["fields"].(map[string]any) + require.True(t, ok, "fields should be a map") + require.Equal(t, tc.expectedFieldValue, fields[tc.expectedFieldKey]) + } }) } } diff --git a/notify/jira/types.go b/notify/jira/types.go index e04eb6d4f..6e4f9625f 100644 --- a/notify/jira/types.go +++ b/notify/jira/types.go @@ -17,8 +17,6 @@ import ( "encoding/json" ) -type templateFunc func(string) (string, error) - type issue struct { Key string `json:"key,omitempty"` Fields *issueFields `json:"fields,omitempty"` diff --git a/template/template.go b/template/template.go index 319399028..ff5d6f496 100644 --- a/template/template.go +++ b/template/template.go @@ -20,6 +20,7 @@ import ( "net/url" "path" "path/filepath" + "reflect" "regexp" "sort" "strings" @@ -30,6 +31,7 @@ import ( "github.com/prometheus/common/model" "golang.org/x/text/cases" "golang.org/x/text/language" + "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/asset" "github.com/prometheus/alertmanager/types" @@ -423,3 +425,66 @@ func (t *Template) Data(recv string, groupLabels model.LabelSet, alerts ...*type return data } + +type TemplateFunc func(string) (string, error) + +// deepCopyWithTemplate returns a deep copy of a map/slice/array/string/int/bool or combination thereof, executing the +// provided template (with the provided data) on all string keys or values. All maps are connverted to +// map[string]interface{}, with all non-string keys discarded. +func DeepCopyWithTemplate(value interface{}, tmplTextFunc TemplateFunc) (interface{}, error) { + if value == nil { + return value, nil + } + + valueMeta := reflect.ValueOf(value) + switch valueMeta.Kind() { + + case reflect.String: + parsed, ok := tmplTextFunc(value.(string)) + if ok == nil { + var inlineType interface{} + err := yaml.Unmarshal([]byte(parsed), &inlineType) + if err != nil || (inlineType != nil && reflect.TypeOf(inlineType).Kind() == reflect.String) { + // ignore error, thus the string is not an interface + return parsed, ok + } + return DeepCopyWithTemplate(inlineType, tmplTextFunc) + } + return parsed, ok + + case reflect.Array, reflect.Slice: + arrayLen := valueMeta.Len() + converted := make([]interface{}, arrayLen) + for i := 0; i < arrayLen; i++ { + var err error + converted[i], err = DeepCopyWithTemplate(valueMeta.Index(i).Interface(), tmplTextFunc) + if err != nil { + return nil, err + } + } + return converted, nil + + case reflect.Map: + keys := valueMeta.MapKeys() + converted := make(map[string]interface{}, len(keys)) + + for _, keyMeta := range keys { + var err error + strKey, isString := keyMeta.Interface().(string) + if !isString { + continue + } + strKey, err = tmplTextFunc(strKey) + if err != nil { + return nil, err + } + converted[strKey], err = DeepCopyWithTemplate(valueMeta.MapIndex(keyMeta).Interface(), tmplTextFunc) + if err != nil { + return nil, err + } + } + return converted, nil + default: + return value, nil + } +} diff --git a/template/template_test.go b/template/template_test.go index acd496738..4ef7fdd35 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -583,3 +583,67 @@ func TestTemplateFuncs(t *testing.T) { }) } } + +func TestDeepCopyWithTemplate(t *testing.T) { + identity := TemplateFunc(func(s string) (string, error) { return s, nil }) + withSuffix := TemplateFunc(func(s string) (string, error) { return s + "-templated", nil }) + + for _, tc := range []struct { + title string + input any + fn TemplateFunc + want any + wantErr string + }{ + { + title: "string keeps templated value", + input: "hello", + fn: withSuffix, + want: "hello-templated", + }, + { + title: "string parsed as YAML map", + input: "foo: bar", + fn: identity, + want: map[string]any{"foo": "bar"}, + }, + { + title: "slice templating applied recursively", + input: []any{"foo", 42}, + fn: withSuffix, + want: []any{"foo-templated", 42}, + }, + { + title: "map converts keys and drops non-string", + input: map[any]any{ + "foo": "bar", + 42: "ignore", + "nested": []any{"baz"}, + }, + fn: withSuffix, + want: map[string]any{ + "foo-templated": "bar-templated", + "nested-templated": []any{"baz-templated"}, + }, + }, + { + title: "non string value returned as-is", + input: 123, + fn: identity, + want: 123, + }, + { + title: "nil input", + input: nil, + fn: identity, + want: nil, + }, + } { + tc := tc + t.Run(tc.title, func(t *testing.T) { + got, err := DeepCopyWithTemplate(tc.input, tc.fn) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +}