diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 19c73030f01..cdbe810597b 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -572,6 +572,7 @@ func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExp return exportHcl(params.Download, body) } + body = escapeAlertingFileExport(body) if params.Download { r := response.JSONDownload if params.Format == "yaml" { @@ -587,6 +588,65 @@ func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExp return r(http.StatusOK, body) } +// escape all strings except: +// Alert rule annotations: groups[].rules[].annotations +// Alert rule time range: groups[].rules[].relativeTimeRange +// Alert rule query model: groups[].rules[].data.model +// Mute timings name: muteTimes[].name +// Mute timings time intervals: muteTimes[].time_intervals[] +// Notification template name: templates[].name +// Notification template content: templates[].template +func escapeAlertingFileExport(body definitions.AlertingFileExport) definitions.AlertingFileExport { + for i, group := range body.Groups { + body.Groups[i] = escapeRuleGroup(group) + } + // TODO: implement escaping for the other export fields + return body +} + +// escape all strings except: +// Alert rule annotations: groups[].rules[].annotations +// Alert rule time range: groups[].rules[].relativeTimeRange +// Alert rule query model: groups[].rules[].data.model +func escapeRuleGroup(group definitions.AlertRuleGroupExport) definitions.AlertRuleGroupExport { + group.Name = addEscapeCharactersToString(group.Name) + group.Folder = addEscapeCharactersToString(group.Folder) + for i, rule := range group.Rules { + group.Rules[i].Title = addEscapeCharactersToString(rule.Title) + if rule.Labels != nil { + group.Rules[i].Labels = escapeMapValues(*rule.Labels) + } + if rule.NotificationSettings != nil { + notificationSettings := escapeRuleNotificationSettings(*rule.NotificationSettings) + group.Rules[i].NotificationSettings = ¬ificationSettings + } + } + return group +} + +func escapeRuleNotificationSettings(ns definitions.AlertRuleNotificationSettingsExport) definitions.AlertRuleNotificationSettingsExport { + ns.Receiver = addEscapeCharactersToString(ns.Receiver) + for j := range ns.GroupBy { + ns.GroupBy[j] = addEscapeCharactersToString(ns.GroupBy[j]) + } + for k := range ns.MuteTimeIntervals { + ns.MuteTimeIntervals[k] = addEscapeCharactersToString(ns.MuteTimeIntervals[k]) + } + return ns +} + +func escapeMapValues(m map[string]string) *map[string]string { + escapedMap := make(map[string]string, len(m)) + for k, v := range m { + escapedMap[k] = addEscapeCharactersToString(v) + } + return &escapedMap +} + +func addEscapeCharactersToString(s string) string { + return strings.ReplaceAll(s, "$", "$$") +} + func exportHcl(download bool, body definitions.AlertingFileExport) response.Response { resources := make([]hcl.Resource, 0, len(body.Groups)+len(body.ContactPoints)+len(body.Policies)+len(body.MuteTimings)) convertToResources := func() error { diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 7b3acec3f4e..a2c151a53eb 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -6,6 +6,9 @@ import ( "fmt" "io" "net/http" + "os" + "path" + "path/filepath" "slices" "sort" "strings" @@ -17,6 +20,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/expr" @@ -809,3 +813,68 @@ func createTestRequest(method string, url string, user string, body string) *htt } return req } + +func TestIntegrationExportFileProvision(t *testing.T) { + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + provisioningDir := filepath.Join(dir, "conf", "provisioning") + alertingDir := filepath.Join(provisioningDir, "alerting") + err := os.MkdirAll(alertingDir, 0750) + require.NoError(t, err) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) + + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + IsAdmin: true, + }) + + apiClient.ReloadCachedPermissions(t) + t.Run("when provisioning alert rules from files", func(t *testing.T) { + // add file provisioned alert rules + fileProvisionedAlertRules, err := testData.ReadFile(path.Join("test-data", "provisioning-rules.yaml")) + require.NoError(t, err) + + var expected definitions.AlertingFileExport + require.NoError(t, yaml.Unmarshal(fileProvisionedAlertRules, &expected)) + expectedYaml, err := yaml.Marshal(expected) + require.NoError(t, err) + + // create folder + folderUID := "my_first_folder_uid" + apiClient.CreateFolder(t, folderUID, "my_first_folder_with_$escaped_symbols") + + err = os.WriteFile(filepath.Join(alertingDir, "provisioning-rules.yaml"), fileProvisionedAlertRules, 0750) + require.NoError(t, err) + + apiClient.ReloadAlertingFileProvisioning(t) + + data, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + require.Greater(t, len(data), 0) + + t.Run("exported alert rules should escape $ characters", func(t *testing.T) { + // call export endpoint + status, exportRaw := apiClient.ExportRulesWithStatus(t, &definitions.AlertRulesExportParameters{ + ExportQueryParams: definitions.ExportQueryParams{Format: "yaml"}, + FolderUID: []string{folderUID}, + GroupName: "my_rule_group", + }) + require.Equal(t, http.StatusOK, status) + var export definitions.AlertingFileExport + require.NoError(t, yaml.Unmarshal([]byte(exportRaw), &export)) + + // verify the file exported matches the file provisioned thing + require.Len(t, export.Groups, 1) + require.YAMLEq(t, string(expectedYaml), exportRaw) + }) + }) +} diff --git a/pkg/tests/api/alerting/test-data/provisioning-rules.yaml b/pkg/tests/api/alerting/test-data/provisioning-rules.yaml new file mode 100644 index 00000000000..3d916fcb7d4 --- /dev/null +++ b/pkg/tests/api/alerting/test-data/provisioning-rules.yaml @@ -0,0 +1,76 @@ +# config file version +apiVersion: 1 + +# ONLY THESE PATHS ARE NOT TEMPLATED and therefore don't need escaping: +# Alert rule annotations: groups[].rules[].annotations +# Alert rule time range: groups[].rules[].relativeTimeRange +# Alert rule query model: groups[].rules[].data.model +groups: + # organization ID, default = 1 + - orgId: 1 + # name of the rule group + name: my_rule_group + # name of the folder the rule group will be stored in + folder: my_first_folder_with_$$escaped_symbols + # interval that the rule group should evaluated at + interval: 60s + # list of rules that are part of the rule group + rules: + # unique identifier for the rule. Should not exceed 40 symbols. Only letters, numbers, - (hyphen), and _ (underscore) allowed. + - uid: my_id_1 + # title of the rule that will be displayed in the UI + title: my_first_rule_with_$$escaped_symbols + # which query should be used for the condition + condition: A + # list of query objects that should be executed on each + # evaluation - should be obtained through the API + data: + - refId: A + datasourceUid: "__expr__" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + type: last + type: query + datasource: + type: __expr__ + uid: "__expr__" + expression: 1==0 + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + type: math + # UID of a dashboard that the alert rule should be linked to + dashboardUid: my_dashboard + # ID of the panel that the alert rule should be linked to + panelId: 123 + # the state the alert rule will have when no data is returned + # possible values: "NoData", "Alerting", "OK", default = NoData + noDataState: Alerting + # the state the alert rule will have when the query execution + # failed - possible values: "Error", "Alerting", "OK" + # default = Alerting + execErrState: Alerting + # for how long should the alert fire before alerting + for: 60s + # > a map of strings to pass around any data + annotations: + some_key: some_value + $no_escaping_needed: $no_escaping_needed + # a map of strings that can be used to filter and + # route alerts + labels: + team: sre_team_1 + label_keys_not_$escaped: $$escaped_value + something: "escaped in the middle of things $$value" + templated: "{{ $$labels.team }}" + middle: "u$$ing_escaped_symbols" diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 1e13f335719..e2ec0fe3b54 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -337,6 +337,19 @@ func (a apiClient) CreateFolder(t *testing.T, uID string, title string, parentUI a.ReloadCachedPermissions(t) } +func (a apiClient) ReloadAlertingFileProvisioning(t *testing.T) { + t.Helper() + + u := fmt.Sprintf("%s/api/admin/provisioning/alerting/reload", a.url) + // nolint:gosec + resp, err := http.Post(u, "application/json", nil) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + func (a apiClient) GetOrgQuotaLimits(t *testing.T, orgID int64) (int64, int64) { t.Helper()