From c917ca63a0f2a35968a9c4be6af4cb1a9f9a2169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Fri, 25 Oct 2024 13:50:32 +0200 Subject: [PATCH] alertmanager: Replace typed fields with plain string values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jan-Otto Kröpke --- config/notifiers.go | 2 +- docs/configuration.md | 18 ++++++-- go.mod | 1 - go.sum | 2 - notify/jira/jira.go | 92 ++++++++++++++++++++++++++++++++++++---- notify/jira/jira_test.go | 41 +++++++++--------- 6 files changed, 122 insertions(+), 34 deletions(-) diff --git a/config/notifiers.go b/config/notifiers.go index fe28ca05c4..eb274e6261 100644 --- a/config/notifiers.go +++ b/config/notifiers.go @@ -906,7 +906,7 @@ type JiraConfig struct { WontFixResolution string `yaml:"wont_fix_resolution,omitempty" json:"wont_fix_resolution,omitempty"` ReopenDuration model.Duration `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty"` - Fields map[string]any `yaml:"fields,omitempty" json:"custom_fields,omitempty"` + Fields map[string]string `yaml:"fields,omitempty" json:"fields,omitempty"` } func (c *JiraConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { diff --git a/docs/configuration.md b/docs/configuration.md index 4f20a1ef68..6dab2b8993 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1071,16 +1071,28 @@ Jira issue field can have multiple types. Depends on the field type, the values must be provided differently. See https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/#setting-custom-field-data-for-other-field-types for further examples. +All values must be declared as string. Quotes around the values are important. + ```yaml fields: # Components - components: { name: "Monitoring" } + components: '{ name: "Monitoring" }' # Custom Field TextField customfield_10001: "Random text" # Custom Field SelectList - customfield_10002: {"value": "red"} + customfield_10002: '{"value": "red"}' # Custom Field MultiSelect - customfield_10003: [{"value": "red"}, {"value": "blue"}, {"value": "green"}] + customfield_10003: '[{"value": "red"}, {"value": "blue"}, {"value": "green"}]' +``` + +Alertmanager will always try to detect the correct type of the value. +This can be problematic if a numeric value has to be sent as a string type. +To explicitly declare a string, the value needs to be quoted again, for example: + +```yaml +fields: + # Example: Numeric string values + customfield_10004: '"0"' ``` ### `` diff --git a/go.mod b/go.mod index d0c810f1c5..04311981a0 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ require ( github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 github.com/stretchr/testify v1.9.0 - github.com/trivago/tgo v1.0.7 github.com/xlab/treeprint v1.2.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.6.0 diff --git a/go.sum b/go.sum index 51750b919e..c2029c7228 100644 --- a/go.sum +++ b/go.sum @@ -508,8 +508,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= -github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= diff --git a/notify/jira/jira.go b/notify/jira/jira.go index 6c92270760..a56a00c46e 100644 --- a/notify/jira/jira.go +++ b/notify/jira/jira.go @@ -28,7 +28,6 @@ import ( "github.com/go-kit/log/level" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" - "github.com/trivago/tgo/tcontainer" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" @@ -120,17 +119,25 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) return n.transitionIssue(ctx, logger, existingIssue, alerts.HasFiring()) } -func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger log.Logger, groupID string, tmplTextFunc templateFunc) (issue, error) { +func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger log.Logger, groupID string, tmplTextFunc templateFunc) (issue, error) { summary, err := tmplTextFunc(n.conf.Summary) if err != nil { return issue{}, fmt.Errorf("summary template: %w", err) } - // Recursively convert any maps to map[string]interface{}, filtering out all non-string keys, so the json encoder - // doesn't blow up when marshaling JIRA requests. - fieldsWithStringKeys, err := tcontainer.ConvertToMarshalMap(n.conf.Fields, func(v string) string { return v }) - if err != nil { - return issue{}, fmt.Errorf("convertToMarshalMap: %w", err) + fields := make(map[string]any) + for name, field := range n.conf.Fields { + templatedFieldValue, err := tmplTextFunc(field) + if err != nil { + return issue{}, fmt.Errorf("field %q template: %w", name, err) + } + + parsedValue, err := n.unmarshalField(templatedFieldValue) + if err != nil { + return issue{}, fmt.Errorf("field %q conversion: %w", name, err) + } + + fields[name] = parsedValue } summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes) @@ -143,7 +150,7 @@ func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger log.Logge Issuetype: &idNameValue{Name: n.conf.IssueType}, Summary: summary, Labels: make([]string, 0, len(n.conf.Labels)+1), - Fields: fieldsWithStringKeys, + Fields: fields, }} issueDescriptionString, err := tmplTextFunc(n.conf.Description) @@ -172,6 +179,7 @@ func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger log.Logge } requestBody.Fields.Labels = append(requestBody.Fields.Labels, label) } + requestBody.Fields.Labels = append(requestBody.Fields.Labels, fmt.Sprintf("ALERT{%s}", groupID)) sort.Strings(requestBody.Fields.Labels) @@ -187,6 +195,74 @@ func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger log.Logge return requestBody, nil } +func (n *Notifier) unmarshalField(fieldValue any) (any, error) { + // Handle type based on input directly without casting to string + switch fieldValueTyped := fieldValue.(type) { + case string: + if len(fieldValueTyped) == 0 { + return fieldValueTyped, nil + } + + // Try to parse as JSON. This includes the handling of strings, numbers, arrays, and maps. + var parsedJSON any + if err := json.Unmarshal([]byte(fieldValueTyped), &parsedJSON); err == nil { + // if the JSON is a string, return it as a string. + // Otherwise, numeric values inside strings are converted to float64. + if parsedString, ok := parsedJSON.(string); ok { + return parsedString, nil + } + + return n.unmarshalField(parsedJSON) + } + + // If no type conversion was possible, keep it as a string + return fieldValueTyped, nil + case []any: + // Handle arrays by recursively parsing each element + fieldValues := make([]any, len(fieldValueTyped)) + for i, elem := range fieldValueTyped { + // if the JSON is a string, return it as a string. + // Otherwise, numeric values inside strings are converted to float64. + if v, ok := elem.(string); ok { + fieldValues[i] = v + continue + } + + parsedElem, err := n.unmarshalField(elem) + if err != nil { + return nil, err + } + + fieldValues[i] = parsedElem + } + + return fieldValues, nil + case map[string]any: + // Handle maps by recursively parsing each value + for key, val := range fieldValueTyped { + // if the JSON is a string, return it as a string. + // Otherwise, numeric values inside strings are converted to float64. + if stringVal, ok := val.(string); ok { + fieldValueTyped[key] = stringVal + + continue + } + + parsedVal, err := n.unmarshalField(val) + if err != nil { + return nil, err + } + + fieldValueTyped[key] = parsedVal + } + + return fieldValueTyped, nil + default: + // Return the value as-is if no specific handling is required + return fieldValueTyped, nil + } +} + func (n *Notifier) searchExistingIssue(ctx context.Context, logger log.Logger, groupID string, firing bool) (*issue, bool, error) { jql := strings.Builder{} diff --git a/notify/jira/jira_test.go b/notify/jira/jira_test.go index 8e2576ca3e..b52586b6f3 100644 --- a/notify/jira/jira_test.go +++ b/notify/jira/jira_test.go @@ -218,16 +218,19 @@ func TestJiraNotify(t *testing.T) { Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, - Fields: map[string]any{ - "components": map[any]any{"name": "Monitoring"}, - "customfield_10001": "value", - "customfield_10002": 0, - "customfield_10003": []any{0}, - "customfield_10004": map[any]any{"value": "red"}, - "customfield_10005": map[any]any{"value": 0}, - "customfield_10006": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": "green"}}, - "customfield_10007": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": 0}}, - "customfield_10008": []map[any]any{{"value": 0}, {"value": 1}, {"value": 2}}, + Fields: map[string]string{ + "components": `{"name": "Monitoring"}`, + "customfield_10001": `value`, + "customfield_10002": `0`, + "customfield_10003": `"0"`, + "customfield_10004": `[0]`, + "customfield_10005": `["0"]`, + "customfield_10006": `{"value": "red"}`, + "customfield_10007": `{"value": 0}`, + "customfield_10008": `[{"value": "red"}, {"value": "blue"}, {"value": "green"}]`, + "customfield_10009": `[{"value": "red"}, {"value": "blue"}, {"value": 0}]`, + "customfield_10010": `[{"value": 0}, {"value": 1}, {"value": 2}]`, + "customfield_10011": `[{"value": {{ .Alerts.Firing | len }} }]`, }, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", @@ -261,12 +264,15 @@ func TestJiraNotify(t *testing.T) { customFieldAssetFn: func(t *testing.T, issue map[string]any) { require.Equal(t, "value", issue["customfield_10001"]) require.Equal(t, float64(0), issue["customfield_10002"]) - require.Equal(t, []any{float64(0)}, issue["customfield_10003"]) - require.Equal(t, map[string]any{"value": "red"}, issue["customfield_10004"]) - require.Equal(t, map[string]any{"value": float64(0)}, issue["customfield_10005"]) - require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": "green"}}, issue["customfield_10006"]) - require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": float64(0)}}, issue["customfield_10007"]) - require.Equal(t, []any{map[string]any{"value": float64(0)}, map[string]any{"value": float64(1)}, map[string]any{"value": float64(2)}}, issue["customfield_10008"]) + require.Equal(t, "0", issue["customfield_10003"]) + require.Equal(t, []any{float64(0)}, issue["customfield_10004"]) + require.Equal(t, []any{"0"}, issue["customfield_10005"]) + require.Equal(t, map[string]any{"value": "red"}, issue["customfield_10006"]) + require.Equal(t, map[string]any{"value": float64(0)}, issue["customfield_10007"]) + require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": "green"}}, issue["customfield_10008"]) + require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": float64(0)}}, issue["customfield_10009"]) + require.Equal(t, []any{map[string]any{"value": float64(0)}, map[string]any{"value": float64(1)}, map[string]any{"value": float64(2)}}, issue["customfield_10010"]) + require.Equal(t, []any{map[string]any{"value": float64(1)}}, issue["customfield_10011"]) }, errMsg: "", }, @@ -563,9 +569,6 @@ func TestJiraNotify(t *testing.T) { } w.WriteHeader(http.StatusCreated) - - w.WriteHeader(http.StatusCreated) - default: t.Fatalf("unexpected path %s", r.URL.Path) }