Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions notify/jira/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 != "" {
Expand Down
44 changes: 37 additions & 7 deletions notify/jira/jira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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()
Expand All @@ -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",
Expand Down Expand Up @@ -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),
Expand All @@ -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])
}
})
}
}
Expand Down
2 changes: 0 additions & 2 deletions notify/jira/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
65 changes: 65 additions & 0 deletions template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"net/url"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
}
64 changes: 64 additions & 0 deletions template/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
Loading