Skip to content

Commit c94cdfd

Browse files
add deepcopywithtemplate function to be able to use templated custom fields (#4029)
Signed-off-by: Holger Waschke <[email protected]>
1 parent 3b515b7 commit c94cdfd

File tree

5 files changed

+175
-11
lines changed

5 files changed

+175
-11
lines changed

notify/jira/jira.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
119119
return n.transitionIssue(ctx, logger, existingIssue, alerts.HasFiring())
120120
}
121121

122-
func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc templateFunc) (issue, error) {
122+
func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc template.TemplateFunc) (issue, error) {
123123
summary, err := tmplTextFunc(n.conf.Summary)
124124
if err != nil {
125125
return issue{}, fmt.Errorf("summary template: %w", err)
@@ -140,6 +140,13 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge
140140
return issue{}, fmt.Errorf("convertToMarshalMap: %w", err)
141141
}
142142

143+
for key, value := range fieldsWithStringKeys {
144+
fieldsWithStringKeys[key], err = template.DeepCopyWithTemplate(value, tmplTextFunc)
145+
if err != nil {
146+
return issue{}, fmt.Errorf("fields template: %w", err)
147+
}
148+
}
149+
143150
summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes)
144151
if truncated {
145152
logger.Warn("Truncated summary", "max_runes", maxSummaryLenRunes)
@@ -194,7 +201,7 @@ func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logge
194201
return requestBody, nil
195202
}
196203

197-
func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc templateFunc) (*issue, bool, error) {
204+
func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc template.TemplateFunc) (*issue, bool, error) {
198205
jql := strings.Builder{}
199206

200207
if n.conf.WontFixResolution != "" {

notify/jira/jira_test.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ func TestPrepareSearchRequest(t *testing.T) {
318318
}
319319

320320
func TestJiraTemplating(t *testing.T) {
321+
var capturedBody map[string]any
322+
321323
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
322324
switch r.URL.Path {
323325
case "/search":
@@ -326,10 +328,10 @@ func TestJiraTemplating(t *testing.T) {
326328
default:
327329
dec := json.NewDecoder(r.Body)
328330
out := make(map[string]any)
329-
err := dec.Decode(&out)
330-
if err != nil {
331+
if err := dec.Decode(&out); err != nil {
331332
panic(err)
332333
}
334+
capturedBody = out
333335
}
334336
}))
335337
defer srv.Close()
@@ -339,16 +341,23 @@ func TestJiraTemplating(t *testing.T) {
339341
title string
340342
cfg *config.JiraConfig
341343

342-
retry bool
343-
errMsg string
344+
retry bool
345+
errMsg string
346+
expectedFieldKey string
347+
expectedFieldValue any
344348
}{
345349
{
346-
title: "full-blown message",
350+
title: "full-blown message with templated custom field",
347351
cfg: &config.JiraConfig{
348352
Summary: `{{ template "jira.default.summary" . }}`,
349353
Description: `{{ template "jira.default.description" . }}`,
354+
Fields: map[string]any{
355+
"customfield_14400": `{{ template "jira.host" . }}`,
356+
},
350357
},
351-
retry: false,
358+
retry: false,
359+
expectedFieldKey: "customfield_14400",
360+
expectedFieldValue: "host1.example.com",
352361
},
353362
{
354363
title: "template project",
@@ -396,19 +405,32 @@ func TestJiraTemplating(t *testing.T) {
396405
tc := tc
397406

398407
t.Run(tc.title, func(t *testing.T) {
408+
capturedBody = nil
409+
399410
tc.cfg.APIURL = &config.URL{URL: u}
400411
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
401412
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
402413
require.NoError(t, err)
403414

415+
// Add the jira.host template just for this test
416+
if tc.expectedFieldKey == "customfield_14400" {
417+
err = pd.tmpl.Parse(strings.NewReader(`{{ define "jira.host" }}{{ .CommonLabels.hostname }}{{ end }}`))
418+
require.NoError(t, err)
419+
}
420+
404421
ctx := context.Background()
405422
ctx = notify.WithGroupKey(ctx, "1")
423+
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
424+
"lbl1": "val1",
425+
"hostname": "host1.example.com",
426+
})
406427

407428
ok, err := pd.Notify(ctx, []*types.Alert{
408429
{
409430
Alert: model.Alert{
410431
Labels: model.LabelSet{
411-
"lbl1": "val1",
432+
"lbl1": "val1",
433+
"hostname": "host1.example.com",
412434
},
413435
StartsAt: time.Now(),
414436
EndsAt: time.Now().Add(time.Hour),
@@ -422,6 +444,14 @@ func TestJiraTemplating(t *testing.T) {
422444
require.Contains(t, err.Error(), tc.errMsg)
423445
}
424446
require.Equal(t, tc.retry, ok)
447+
448+
// Verify that custom fields were templated correctly
449+
if tc.expectedFieldKey != "" {
450+
require.NotNil(t, capturedBody, "expected request body")
451+
fields, ok := capturedBody["fields"].(map[string]any)
452+
require.True(t, ok, "fields should be a map")
453+
require.Equal(t, tc.expectedFieldValue, fields[tc.expectedFieldKey])
454+
}
425455
})
426456
}
427457
}

notify/jira/types.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import (
1717
"encoding/json"
1818
)
1919

20-
type templateFunc func(string) (string, error)
21-
2220
type issue struct {
2321
Key string `json:"key,omitempty"`
2422
Fields *issueFields `json:"fields,omitempty"`

template/template.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"net/url"
2121
"path"
2222
"path/filepath"
23+
"reflect"
2324
"regexp"
2425
"sort"
2526
"strings"
@@ -30,6 +31,7 @@ import (
3031
"github.com/prometheus/common/model"
3132
"golang.org/x/text/cases"
3233
"golang.org/x/text/language"
34+
"gopkg.in/yaml.v2"
3335

3436
"github.com/prometheus/alertmanager/asset"
3537
"github.com/prometheus/alertmanager/types"
@@ -423,3 +425,66 @@ func (t *Template) Data(recv string, groupLabels model.LabelSet, alerts ...*type
423425

424426
return data
425427
}
428+
429+
type TemplateFunc func(string) (string, error)
430+
431+
// deepCopyWithTemplate returns a deep copy of a map/slice/array/string/int/bool or combination thereof, executing the
432+
// provided template (with the provided data) on all string keys or values. All maps are connverted to
433+
// map[string]interface{}, with all non-string keys discarded.
434+
func DeepCopyWithTemplate(value interface{}, tmplTextFunc TemplateFunc) (interface{}, error) {
435+
if value == nil {
436+
return value, nil
437+
}
438+
439+
valueMeta := reflect.ValueOf(value)
440+
switch valueMeta.Kind() {
441+
442+
case reflect.String:
443+
parsed, ok := tmplTextFunc(value.(string))
444+
if ok == nil {
445+
var inlineType interface{}
446+
err := yaml.Unmarshal([]byte(parsed), &inlineType)
447+
if err != nil || (inlineType != nil && reflect.TypeOf(inlineType).Kind() == reflect.String) {
448+
// ignore error, thus the string is not an interface
449+
return parsed, ok
450+
}
451+
return DeepCopyWithTemplate(inlineType, tmplTextFunc)
452+
}
453+
return parsed, ok
454+
455+
case reflect.Array, reflect.Slice:
456+
arrayLen := valueMeta.Len()
457+
converted := make([]interface{}, arrayLen)
458+
for i := 0; i < arrayLen; i++ {
459+
var err error
460+
converted[i], err = DeepCopyWithTemplate(valueMeta.Index(i).Interface(), tmplTextFunc)
461+
if err != nil {
462+
return nil, err
463+
}
464+
}
465+
return converted, nil
466+
467+
case reflect.Map:
468+
keys := valueMeta.MapKeys()
469+
converted := make(map[string]interface{}, len(keys))
470+
471+
for _, keyMeta := range keys {
472+
var err error
473+
strKey, isString := keyMeta.Interface().(string)
474+
if !isString {
475+
continue
476+
}
477+
strKey, err = tmplTextFunc(strKey)
478+
if err != nil {
479+
return nil, err
480+
}
481+
converted[strKey], err = DeepCopyWithTemplate(valueMeta.MapIndex(keyMeta).Interface(), tmplTextFunc)
482+
if err != nil {
483+
return nil, err
484+
}
485+
}
486+
return converted, nil
487+
default:
488+
return value, nil
489+
}
490+
}

template/template_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,3 +583,67 @@ func TestTemplateFuncs(t *testing.T) {
583583
})
584584
}
585585
}
586+
587+
func TestDeepCopyWithTemplate(t *testing.T) {
588+
identity := TemplateFunc(func(s string) (string, error) { return s, nil })
589+
withSuffix := TemplateFunc(func(s string) (string, error) { return s + "-templated", nil })
590+
591+
for _, tc := range []struct {
592+
title string
593+
input any
594+
fn TemplateFunc
595+
want any
596+
wantErr string
597+
}{
598+
{
599+
title: "string keeps templated value",
600+
input: "hello",
601+
fn: withSuffix,
602+
want: "hello-templated",
603+
},
604+
{
605+
title: "string parsed as YAML map",
606+
input: "foo: bar",
607+
fn: identity,
608+
want: map[string]any{"foo": "bar"},
609+
},
610+
{
611+
title: "slice templating applied recursively",
612+
input: []any{"foo", 42},
613+
fn: withSuffix,
614+
want: []any{"foo-templated", 42},
615+
},
616+
{
617+
title: "map converts keys and drops non-string",
618+
input: map[any]any{
619+
"foo": "bar",
620+
42: "ignore",
621+
"nested": []any{"baz"},
622+
},
623+
fn: withSuffix,
624+
want: map[string]any{
625+
"foo-templated": "bar-templated",
626+
"nested-templated": []any{"baz-templated"},
627+
},
628+
},
629+
{
630+
title: "non string value returned as-is",
631+
input: 123,
632+
fn: identity,
633+
want: 123,
634+
},
635+
{
636+
title: "nil input",
637+
input: nil,
638+
fn: identity,
639+
want: nil,
640+
},
641+
} {
642+
tc := tc
643+
t.Run(tc.title, func(t *testing.T) {
644+
got, err := DeepCopyWithTemplate(tc.input, tc.fn)
645+
require.NoError(t, err)
646+
require.Equal(t, tc.want, got)
647+
})
648+
}
649+
}

0 commit comments

Comments
 (0)