1
0
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:
Holger Waschke
2025-11-03 21:09:45 +01:00
committed by GitHub
parent 3b515b7dc0
commit c94cdfdf3e
5 changed files with 175 additions and 11 deletions

View File

@@ -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 != "" {

View File

@@ -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])
}
})
}
}

View File

@@ -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"`

View File

@@ -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
}
}

View File

@@ -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)
})
}
}