mirror of
https://github.com/prometheus/alertmanager.git
synced 2026-02-05 15:45:34 +01:00
add deepcopywithtemplate function to be able to use templated custom fields (#4029)
Signed-off-by: Holger Waschke <holger.waschke@dvag.com>
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user