diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index e6df844a3af..799f83e22fd 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -31,6 +31,7 @@ var ( ScopeProvisionersPlugins = ac.Scope("provisioners", "plugins") ScopeProvisionersDatasources = ac.Scope("provisioners", "datasources") ScopeProvisionersNotifications = ac.Scope("provisioners", "notifications") + ScopeProvisionersAlertRules = ac.Scope("provisioners", "alerting") ) // declareFixedRoles declares to the AccessControl service fixed roles and their diff --git a/pkg/api/admin_provisioning.go b/pkg/api/admin_provisioning.go index 54d68beabd2..ba9f53f31cb 100644 --- a/pkg/api/admin_provisioning.go +++ b/pkg/api/admin_provisioning.go @@ -39,3 +39,11 @@ func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext) } return response.Success("Notifications config reloaded") } + +func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *models.ReqContext) response.Response { + err := hs.ProvisioningService.ProvisionAlertRules(c.Req.Context()) + if err != nil { + return response.Error(500, "", err) + } + return response.Success("Alerting config reloaded") +} diff --git a/pkg/api/admin_provisioning_test.go b/pkg/api/admin_provisioning_test.go index ef233acceb2..50c288d7f10 100644 --- a/pkg/api/admin_provisioning_test.go +++ b/pkg/api/admin_provisioning_test.go @@ -135,6 +135,33 @@ func TestAPI_AdminProvisioningReload_AccessControl(t *testing.T) { url: "/api/admin/provisioning/plugins/reload", exit: true, }, + { + desc: "should fail for alerting with no permission", + expectedCode: http.StatusForbidden, + url: "/api/admin/provisioning/alerting/reload", + exit: true, + }, + { + desc: "should work for alert rules with specific scope", + expectedCode: http.StatusOK, + expectedBody: `{"message":"Alerting config reloaded"}`, + permissions: []accesscontrol.Permission{ + { + Action: ActionProvisioningReload, + Scope: ScopeProvisionersAlertRules, + }, + }, + url: "/api/admin/provisioning/alerting/reload", + checkCall: func(mock provisioning.ProvisioningServiceMock) { + assert.Len(t, mock.Calls.ProvisionAlertRules, 1) + }, + }, + { + desc: "should fail for alerting with no permission", + expectedCode: http.StatusForbidden, + url: "/api/admin/provisioning/alerting/reload", + exit: true, + }, } cfg := setting.NewCfg() diff --git a/pkg/api/api.go b/pkg/api/api.go index 5fbb25a1b04..5cf5e8e2c1b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -576,6 +576,7 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Post("/provisioning/plugins/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins)) adminRoute.Post("/provisioning/datasources/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources)) adminRoute.Post("/provisioning/notifications/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersNotifications)), routing.Wrap(hs.AdminProvisioningReloadNotifications)) + adminRoute.Post("/provisioning/alerting/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersAlertRules)), routing.Wrap(hs.AdminProvisioningReloadAlerting)) adminRoute.Post("/ldap/reload", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPConfigReload)), routing.Wrap(hs.ReloadLDAPCfg)) adminRoute.Post("/ldap/sync/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPUsersSync)), routing.Wrap(hs.PostSyncUserWithLDAP)) diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index a87d9c223bc..e7dda380b0d 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -133,8 +133,8 @@ func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, } // UpdateRuleGroup will update the interval for all rules in the group. -func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string, interval int64) error { - if err := models.ValidateRuleGroupInterval(interval, service.baseIntervalSeconds); err != nil { +func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string, intervalSeconds int64) error { + if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil { return err } return service.xact.InTransaction(ctx, func(ctx context.Context) error { @@ -149,11 +149,11 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int6 } updateRules := make([]store.UpdateRule, 0, len(query.Result)) for _, rule := range query.Result { - if rule.IntervalSeconds == interval { + if rule.IntervalSeconds == intervalSeconds { continue } newRule := *rule - newRule.IntervalSeconds = interval + newRule.IntervalSeconds = intervalSeconds updateRules = append(updateRules, store.UpdateRule{ Existing: rule, New: newRule, @@ -180,7 +180,6 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, rule model if err != nil { return models.AlertRule{}, err } - service.log.Info("update rule", "ID", storedRule.ID, "labels", fmt.Sprintf("%+v", rule.Labels)) err = service.xact.InTransaction(ctx, func(ctx context.Context) error { err := service.ruleStore.UpdateAlertRules(ctx, []store.UpdateRule{ { diff --git a/pkg/services/provisioning/alerting/rules/config_reader.go b/pkg/services/provisioning/alerting/rules/config_reader.go new file mode 100644 index 00000000000..2ba11ce3a23 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/config_reader.go @@ -0,0 +1,77 @@ +package rules + +import ( + "context" + "fmt" + "io/fs" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "gopkg.in/yaml.v2" +) + +type rulesConfigReader struct { + log log.Logger +} + +func newRulesConfigReader(logger log.Logger) rulesConfigReader { + return rulesConfigReader{ + log: logger, + } +} + +func (cr *rulesConfigReader) readConfig(ctx context.Context, path string) ([]*RuleFile, error) { + var alertRulesFiles []*RuleFile + cr.log.Debug("looking for alert rules provisioning files", "path", path) + + files, err := ioutil.ReadDir(path) + if err != nil { + cr.log.Error("can't read alert rules provisioning files from directory", "path", path, "error", err) + return alertRulesFiles, nil + } + + for _, file := range files { + cr.log.Debug("parsing alert rules provisioning file", "path", path, "file.Name", file.Name()) + if !cr.isYAML(file.Name()) && !cr.isJSON(file.Name()) { + return nil, fmt.Errorf("file has invalid suffix '%s' (.yaml,.yml,.json accepted)", file.Name()) + } + ruleFileV1, err := cr.parseConfig(path, file) + if err != nil { + return nil, err + } + if ruleFileV1 != nil { + ruleFile, err := ruleFileV1.MapToModel() + if err != nil { + return nil, err + } + alertRulesFiles = append(alertRulesFiles, &ruleFile) + } + } + return alertRulesFiles, nil +} + +func (cr *rulesConfigReader) isYAML(file string) bool { + return strings.HasSuffix(file, ".yaml") || strings.HasSuffix(file, ".yml") +} + +func (cr *rulesConfigReader) isJSON(file string) bool { + return strings.HasSuffix(file, ".json") +} + +func (cr *rulesConfigReader) parseConfig(path string, file fs.FileInfo) (*RuleFileV1, error) { + filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + // nolint:gosec + // We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + var cfg *RuleFileV1 + err = yaml.Unmarshal(yamlFile, &cfg) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/pkg/services/provisioning/alerting/rules/config_reader_test.go b/pkg/services/provisioning/alerting/rules/config_reader_test.go new file mode 100644 index 00000000000..da3a6a3e4e8 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/config_reader_test.go @@ -0,0 +1,66 @@ +package rules + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/stretchr/testify/require" +) + +const ( + testFileBrokenYAML = "./testdata/broken-yaml" + testFileCorrectProperties = "./testdata/correct-properties" + testFileCorrectPropertiesWithOrg = "./testdata/correct-properties-with-org" + testFileEmptyFile = "./testdata/empty-file" + testFileEmptyFolder = "./testdata/empty-folder" + testFileMultipleRules = "./testdata/multiple-rules" + testFileMultipleFiles = "./testdata/multiple-files" + testFileSupportedFiletypes = "./testdata/supported-filetypes" +) + +func TestConfigReader(t *testing.T) { + configReader := newRulesConfigReader(log.NewNopLogger()) + ctx := context.Background() + t.Run("a broken YAML file should error", func(t *testing.T) { + _, err := configReader.readConfig(ctx, testFileBrokenYAML) + require.Error(t, err) + }) + t.Run("a rule file with correct properties should not error", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileCorrectProperties) + require.NoError(t, err) + t.Run("when no organization is present it should be set to 1", func(t *testing.T) { + require.Equal(t, int64(1), ruleFiles[0].Groups[0].Rules[0].OrgID) + }) + }) + t.Run("a rule file with correct properties and specific org should not error", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileCorrectPropertiesWithOrg) + require.NoError(t, err) + t.Run("when an organization is set it should not overwrite if with the default of 1", func(t *testing.T) { + require.Equal(t, int64(1337), ruleFiles[0].Groups[0].Rules[0].OrgID) + }) + }) + t.Run("an empty rule file should not make the config reader error", func(t *testing.T) { + _, err := configReader.readConfig(ctx, testFileEmptyFile) + require.NoError(t, err) + }) + t.Run("an empty folder should not make the config reader error", func(t *testing.T) { + _, err := configReader.readConfig(ctx, testFileEmptyFolder) + require.NoError(t, err) + }) + t.Run("the config reader should be able to read multiple files in the folder", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileMultipleFiles) + require.NoError(t, err) + require.Len(t, ruleFiles, 2) + }) + t.Run("the config reader should be able to read multiple rule groups", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileMultipleRules) + require.NoError(t, err) + require.Len(t, ruleFiles[0].Groups, 2) + }) + t.Run("the config reader should support .yaml,.yml and .json files", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileSupportedFiletypes) + require.NoError(t, err) + require.Len(t, ruleFiles, 3) + }) +} diff --git a/pkg/services/provisioning/alerting/rules/provisioner.go b/pkg/services/provisioning/alerting/rules/provisioner.go new file mode 100644 index 00000000000..c14506d07ac --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/provisioner.go @@ -0,0 +1,165 @@ +package rules + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + alert_models "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/util" +) + +type AlertRuleProvisioner interface { + Provision(ctx context.Context, path string) error +} + +func NewAlertRuleProvisioner( + logger log.Logger, + dashboardService dashboards.DashboardService, + dashboardProvService dashboards.DashboardProvisioningService, + ruleService provisioning.AlertRuleService) AlertRuleProvisioner { + return &defaultAlertRuleProvisioner{ + logger: logger, + cfgReader: newRulesConfigReader(logger), + dashboardService: dashboardService, + dashboardProvService: dashboardProvService, + ruleService: ruleService, + } +} + +type defaultAlertRuleProvisioner struct { + logger log.Logger + cfgReader rulesConfigReader + dashboardService dashboards.DashboardService + dashboardProvService dashboards.DashboardProvisioningService + ruleService provisioning.AlertRuleService +} + +func Provision( + ctx context.Context, + path string, + dashboardService dashboards.DashboardService, + dashboardProvisioningService dashboards.DashboardProvisioningService, + ruleService provisioning.AlertRuleService, +) error { + ruleProvisioner := NewAlertRuleProvisioner( + log.New("provisioning.alerting"), + dashboardService, + dashboardProvisioningService, + ruleService, + ) + return ruleProvisioner.Provision(ctx, path) +} + +func (prov *defaultAlertRuleProvisioner) Provision(ctx context.Context, + path string) error { + prov.logger.Info("starting to provision the alert rules") + ruleFiles, err := prov.cfgReader.readConfig(ctx, path) + if err != nil { + return fmt.Errorf("failed to read alert rules files: %w", err) + } + prov.logger.Debug("read all alert rules files", "file_count", len(ruleFiles)) + err = prov.provsionRuleFiles(ctx, ruleFiles) + if err != nil { + return fmt.Errorf("failed to provision alert rules: %w", err) + } + prov.logger.Info("finished to provision the alert rules") + return nil +} + +func (prov *defaultAlertRuleProvisioner) provsionRuleFiles(ctx context.Context, + ruleFiles []*RuleFile) error { + for _, file := range ruleFiles { + for _, group := range file.Groups { + folderUID, err := prov.getOrCreateFolderUID(ctx, group.Folder, group.OrgID) + if err != nil { + return err + } + prov.logger.Debug("provisioning alert rule group", + "org", group.OrgID, + "folder", group.Folder, + "folderUID", folderUID, + "name", group.Name) + for _, rule := range group.Rules { + rule.NamespaceUID = folderUID + rule.RuleGroup = group.Name + err = prov.provisionRule(ctx, group.OrgID, rule, group.Folder, folderUID) + if err != nil { + return err + } + } + err = prov.ruleService.UpdateRuleGroup(ctx, group.OrgID, folderUID, group.Name, int64(group.Interval.Seconds())) + if err != nil { + return err + } + } + for _, deleteRule := range file.DeleteRules { + err := prov.ruleService.DeleteAlertRule(ctx, deleteRule.OrgID, + deleteRule.UID, alert_models.ProvenanceFile) + if err != nil { + return err + } + } + } + return nil +} + +func (prov *defaultAlertRuleProvisioner) provisionRule( + ctx context.Context, + orgID int64, + rule alert_models.AlertRule, + folder, + folderUID string) error { + prov.logger.Debug("provisioning alert rule", "uid", rule.UID, "org", rule.OrgID) + _, _, err := prov.ruleService.GetAlertRule(ctx, orgID, rule.UID) + if err != nil && !errors.Is(err, alert_models.ErrAlertRuleNotFound) { + return err + } else if err != nil { + prov.logger.Debug("creating rule", "uid", rule.UID, "org", rule.OrgID) + // 0 is passed as userID as then the quota logic will only check for + // the organization quota, as we don't have any user scope here. + _, err = prov.ruleService.CreateAlertRule(ctx, rule, alert_models.ProvenanceFile, 0) + } else { + prov.logger.Debug("updating rule", "uid", rule.UID, "org", rule.OrgID) + _, err = prov.ruleService.UpdateAlertRule(ctx, rule, alert_models.ProvenanceFile) + } + return err +} + +func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID( + ctx context.Context, folderName string, orgID int64) (string, error) { + cmd := &models.GetDashboardQuery{ + Slug: models.SlugifyTitle(folderName), + OrgId: orgID, + } + err := prov.dashboardService.GetDashboard(ctx, cmd) + if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) { + return "", err + } + + // dashboard folder not found. create one. + if errors.Is(err, dashboards.ErrDashboardNotFound) { + dash := &dashboards.SaveDashboardDTO{} + dash.Dashboard = models.NewDashboardFolder(folderName) + dash.Dashboard.IsFolder = true + dash.Overwrite = true + dash.OrgId = orgID + dash.Dashboard.SetUid(util.GenerateShortUID()) + dbDash, err := prov.dashboardProvService.SaveFolderForProvisionedDashboards(ctx, dash) + if err != nil { + return "", err + } + + return dbDash.Uid, nil + } + + if !cmd.Result.IsFolder { + return "", fmt.Errorf("got invalid response. expected folder, found dashboard") + } + + return cmd.Result.Uid, nil +} diff --git a/pkg/services/provisioning/alerting/rules/testdata/broken-yaml/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/broken-yaml/rules.yml new file mode 100644 index 00000000000..ed07ed613a6 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/broken-yaml/rules.yml @@ -0,0 +1,56 @@ +apiVersion: 1 + groups: + - name: my_group + folder: my_folder + interval: 10s + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/correct-properties-with-org/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/correct-properties-with-org/rules.yml new file mode 100644 index 00000000000..96cd866188d --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/correct-properties-with-org/rules.yml @@ -0,0 +1,57 @@ +apiVersion: 1 +groups: + - name: my_group + folder: my_folder + interval: 10s + orgId: 1337 + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/correct-properties/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/correct-properties/rules.yml new file mode 100644 index 00000000000..e98c27c9501 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/correct-properties/rules.yml @@ -0,0 +1,56 @@ +apiVersion: 1 +groups: + - name: my_group + folder: my_folder + interval: 10s + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/empty-file/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/empty-file/rules.yml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules.yml new file mode 100644 index 00000000000..e98c27c9501 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules.yml @@ -0,0 +1,56 @@ +apiVersion: 1 +groups: + - name: my_group + folder: my_folder + interval: 10s + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules2.yml b/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules2.yml new file mode 100644 index 00000000000..948c4f1a03c --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/multiple-files/rules2.yml @@ -0,0 +1,56 @@ +apiVersion: 1 +groups: + - name: my_other_group + folder: my_other_folder + interval: 10s + rules: + - title: my_other_rule + uid: my_other_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/multiple-rules/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/multiple-rules/rules.yml new file mode 100644 index 00000000000..d4a0121eda4 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/multiple-rules/rules.yml @@ -0,0 +1,110 @@ +apiVersion: 1 +groups: + - name: my_group + folder: my_folder + interval: 10s + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions + - name: my_other_group + folder: my_other_folder + interval: 10s + rules: + - title: my_other_rule + uid: my_other_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions \ No newline at end of file diff --git a/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.json b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.json new file mode 100644 index 00000000000..5a5d46118c4 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.json @@ -0,0 +1,87 @@ +{ + "apiVersion": 1, + "groups": [ + { + "name": "my_json_group", + "folder": "my_json_folder", + "interval": "10s", + "rules": [ + { + "title": "my_json_rule", + "uid": "my_json_rule", + "condition": "A", + "for": "1m", + "annotations": { + "runbook": "http://google.com/" + }, + "labels": { + "team": "infra", + "severity": "warning" + }, + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUID": "PD8C576611E62080A", + "model": { + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A" + } + }, + { + "refId": "B", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUID": "-100", + "model": { + "conditions": [ + { + "evaluator": { + "params": [ + 3 + ], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": [ + "A" + ] + }, + "reducer": { + "params": [ + + ], + "type": "last" + }, + "type": "query" + } + ], + "datasource": { + "type": "__expr__", + "uid": "-100" + }, + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "B", + "type": "classic_conditions" + } + } + ] + } + ] + } + ] + } \ No newline at end of file diff --git a/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yaml b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yaml new file mode 100644 index 00000000000..e98c27c9501 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yaml @@ -0,0 +1,56 @@ +apiVersion: 1 +groups: + - name: my_group + folder: my_folder + interval: 10s + rules: + - title: my_first_rule + uid: my_first_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yml b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yml new file mode 100644 index 00000000000..948c4f1a03c --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/testdata/supported-filetypes/rules.yml @@ -0,0 +1,56 @@ +apiVersion: 1 +groups: + - name: my_other_group + folder: my_other_folder + interval: 10s + rules: + - title: my_other_rule + uid: my_other_rule + condition: A + for: 1m + annotations: + runbook: https://grafana.com + labels: + team: infra + severity: warning + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUID: PD8C576611E62080A + model: + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + - refId: B + queryType: '' + relativeTimeRange: + from: 0 + to: 0 + datasourceUID: "-100" + model: + conditions: + - evaluator: + params: + - 3 + type: gt + operator: + type: and + query: + params: + - A + reducer: + params: [] + type: last + type: query + datasource: + type: __expr__ + uid: "-100" + hide: false + intervalMs: 1000 + maxDataPoints: 43200 + refId: B + type: classic_conditions diff --git a/pkg/services/provisioning/alerting/rules/types.go b/pkg/services/provisioning/alerting/rules/types.go new file mode 100644 index 00000000000..b98a1b186c2 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/types.go @@ -0,0 +1,210 @@ +package rules + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/provisioning/values" +) + +type configVersion struct { + APIVersion values.Int64Value `json:"apiVersion" yaml:"apiVersion"` +} + +type RuleFile struct { + configVersion + Groups []AlertRuleGroup + DeleteRules []RuleDelete +} + +type RuleFileV1 struct { + configVersion + Groups []AlertRuleGroupV1 `json:"groups" yaml:"groups"` + DeleteRules []RuleDeleteV1 `json:"deleteRules" yaml:"deleteRules"` +} + +func (ruleFileV1 *RuleFileV1) MapToModel() (RuleFile, error) { + ruleFile := RuleFile{} + ruleFile.configVersion = ruleFileV1.configVersion + for _, groupV1 := range ruleFileV1.Groups { + group, err := groupV1.mapToModel() + if err != nil { + return RuleFile{}, err + } + ruleFile.Groups = append(ruleFile.Groups, group) + } + for _, ruleDeleteV1 := range ruleFileV1.DeleteRules { + orgID := ruleDeleteV1.OrgID.Value() + if orgID < 1 { + orgID = 1 + } + ruleDelete := RuleDelete{ + UID: ruleDeleteV1.UID.Value(), + OrgID: orgID, + } + ruleFile.DeleteRules = append(ruleFile.DeleteRules, ruleDelete) + } + return ruleFile, nil +} + +type RuleDelete struct { + UID string + OrgID int64 +} + +type RuleDeleteV1 struct { + UID values.StringValue `json:"uid" yaml:"uid"` + OrgID values.Int64Value `json:"orgId" yaml:"orgId"` +} + +type AlertRuleGroupV1 struct { + OrgID values.Int64Value `json:"orgId" yaml:"orgId"` + Name values.StringValue `json:"name" yaml:"name"` + Folder values.StringValue `json:"folder" yaml:"folder"` + Interval values.StringValue `json:"interval" yaml:"interval"` + Rules []AlertRuleV1 `json:"rules" yaml:"rules"` +} + +func (ruleGroupV1 *AlertRuleGroupV1) mapToModel() (AlertRuleGroup, error) { + ruleGroup := AlertRuleGroup{} + ruleGroup.Name = ruleGroupV1.Name.Value() + if strings.TrimSpace(ruleGroup.Name) == "" { + return AlertRuleGroup{}, errors.New("rule group has no name set") + } + ruleGroup.OrgID = ruleGroupV1.OrgID.Value() + if ruleGroup.OrgID < 1 { + ruleGroup.OrgID = 1 + } + interval, err := time.ParseDuration(ruleGroupV1.Interval.Value()) + if err != nil { + return AlertRuleGroup{}, err + } + ruleGroup.Interval = interval + ruleGroup.Folder = ruleGroupV1.Folder.Value() + if strings.TrimSpace(ruleGroup.Folder) == "" { + return AlertRuleGroup{}, errors.New("rule group has no folder set") + } + for _, ruleV1 := range ruleGroupV1.Rules { + rule, err := ruleV1.mapToModel(ruleGroup.OrgID) + if err != nil { + return AlertRuleGroup{}, err + } + ruleGroup.Rules = append(ruleGroup.Rules, rule) + } + return ruleGroup, nil +} + +type AlertRuleGroup struct { + OrgID int64 + Name string + Folder string + Interval time.Duration + Rules []models.AlertRule +} + +type AlertRuleV1 struct { + UID values.StringValue `json:"uid" yaml:"uid"` + Title values.StringValue `json:"title" yaml:"title"` + Condition values.StringValue `json:"condition" yaml:"condition"` + Data []QueryV1 `json:"data" yaml:"data"` + DashboardUID values.StringValue `json:"dasboardUid" yaml:"dashboardUid"` + PanelID values.Int64Value `json:"panelId" yaml:"panelId"` + NoDataState values.StringValue `json:"noDataState" yaml:"noDataState"` + ExecErrState values.StringValue `json:"execErrState" yaml:"execErrState"` + For values.StringValue `json:"for" yaml:"for"` + Annotations values.StringMapValue `json:"annotations" yaml:"annotations"` + Labels values.StringMapValue `json:"labels" yaml:"labels"` +} + +func (rule *AlertRuleV1) mapToModel(orgID int64) (models.AlertRule, error) { + alertRule := models.AlertRule{} + alertRule.Title = rule.Title.Value() + if alertRule.Title == "" { + return models.AlertRule{}, fmt.Errorf("rule has no title set") + } + alertRule.UID = rule.UID.Value() + if alertRule.UID == "" { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: no UID set", alertRule.Title) + } + alertRule.OrgID = orgID + duration, err := time.ParseDuration(rule.For.Value()) + if err != nil { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) + } + alertRule.For = duration + dashboardUID := rule.DashboardUID.Value() + alertRule.DashboardUID = &dashboardUID + panelID := rule.PanelID.Value() + alertRule.PanelID = &panelID + execErrStateValue := strings.TrimSpace(rule.ExecErrState.Value()) + execErrState, err := models.ErrStateFromString(execErrStateValue) + if err != nil && execErrStateValue != "" { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) + } + if execErrStateValue == "" { + execErrState = models.AlertingErrState + } + alertRule.ExecErrState = execErrState + noDataStateValue := strings.TrimSpace(rule.NoDataState.Value()) + noDataState, err := models.NoDataStateFromString(noDataStateValue) + if err != nil && noDataStateValue != "" { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) + } + if noDataStateValue == "" { + noDataState = models.NoData + } + alertRule.NoDataState = noDataState + alertRule.Condition = rule.Condition.Value() + if alertRule.Condition == "" { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: no condition set", alertRule.Title) + } + alertRule.Annotations = rule.Annotations.Value() + alertRule.Labels = rule.Labels.Value() + for _, queryV1 := range rule.Data { + query, err := queryV1.mapToModel() + if err != nil { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: %w", alertRule.Title, err) + } + alertRule.Data = append(alertRule.Data, query) + } + if len(alertRule.Data) == 0 { + return models.AlertRule{}, fmt.Errorf("rule '%s' failed to parse: no data set", alertRule.Title) + } + return alertRule, nil +} + +type QueryV1 struct { + RefID values.StringValue `json:"refId" yaml:"refId"` + QueryType values.StringValue `json:"queryType" yaml:"queryType"` + RelativeTimeRange models.RelativeTimeRange `json:"relativeTimeRange" yaml:"relativeTimeRange"` + DatasourceUID values.StringValue `json:"datasourceUid" yaml:"datasourceUid"` + Model values.JSONValue `json:"model" yaml:"model"` +} + +func (queryV1 *QueryV1) mapToModel() (models.AlertQuery, error) { + // In order to get the model into the format we need, + // we marshal it back to json and unmarshal it again + // in json.RawMessage. We do this as we cannot use + // json.RawMessage with a yaml files and have to use + // JSONValue that supports both, json and yaml. + encoded, err := json.Marshal(queryV1.Model.Value()) + if err != nil { + return models.AlertQuery{}, err + } + var rawMessage json.RawMessage + err = json.Unmarshal(encoded, &rawMessage) + if err != nil { + return models.AlertQuery{}, err + } + return models.AlertQuery{ + RefID: queryV1.RefID.Value(), + QueryType: queryV1.QueryType.Value(), + DatasourceUID: queryV1.DatasourceUID.Value(), + RelativeTimeRange: queryV1.RelativeTimeRange, + Model: rawMessage, + }, nil +} diff --git a/pkg/services/provisioning/alerting/rules/types_test.go b/pkg/services/provisioning/alerting/rules/types_test.go new file mode 100644 index 00000000000..9d078ad7215 --- /dev/null +++ b/pkg/services/provisioning/alerting/rules/types_test.go @@ -0,0 +1,218 @@ +package rules + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/provisioning/values" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestRuleGroup(t *testing.T) { + t.Run("a valid rule group should not error", func(t *testing.T) { + rg := validRuleGroupV1(t) + _, err := rg.mapToModel() + require.NoError(t, err) + }) + t.Run("a rule group with out a name should error", func(t *testing.T) { + rg := validRuleGroupV1(t) + var name values.StringValue + err := yaml.Unmarshal([]byte(""), &name) + require.NoError(t, err) + rg.Name = name + _, err = rg.mapToModel() + require.Error(t, err) + }) + t.Run("a rule group with out a folder should error", func(t *testing.T) { + rg := validRuleGroupV1(t) + var folder values.StringValue + err := yaml.Unmarshal([]byte(""), &folder) + require.NoError(t, err) + rg.Folder = folder + _, err = rg.mapToModel() + require.Error(t, err) + }) + t.Run("a rule group with out an interval should error", func(t *testing.T) { + rg := validRuleGroupV1(t) + var interval values.StringValue + err := yaml.Unmarshal([]byte(""), &interval) + require.NoError(t, err) + rg.Interval = interval + _, err = rg.mapToModel() + require.Error(t, err) + }) + t.Run("a rule group with an invalid interval should error", func(t *testing.T) { + rg := validRuleGroupV1(t) + var interval values.StringValue + err := yaml.Unmarshal([]byte("10x"), &interval) + require.NoError(t, err) + rg.Interval = interval + _, err = rg.mapToModel() + require.Error(t, err) + }) + t.Run("a rule group with an empty org id should default to 1", func(t *testing.T) { + rg := validRuleGroupV1(t) + rg.OrgID = values.Int64Value{} + rgMapped, err := rg.mapToModel() + require.NoError(t, err) + require.Equal(t, int64(1), rgMapped.OrgID) + }) + t.Run("a rule group with a negative org id should default to 1", func(t *testing.T) { + rg := validRuleGroupV1(t) + orgID := values.Int64Value{} + err := yaml.Unmarshal([]byte("-1"), &orgID) + require.NoError(t, err) + rg.OrgID = orgID + rgMapped, err := rg.mapToModel() + require.NoError(t, err) + require.Equal(t, int64(1), rgMapped.OrgID) + }) +} + +func TestRules(t *testing.T) { + t.Run("a valid rule should not error", func(t *testing.T) { + rule := validRuleV1(t) + _, err := rule.mapToModel(1) + require.NoError(t, err) + }) + t.Run("a rule with out a uid should error", func(t *testing.T) { + rule := validRuleV1(t) + rule.UID = values.StringValue{} + _, err := rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with out a title should error", func(t *testing.T) { + rule := validRuleV1(t) + rule.Title = values.StringValue{} + _, err := rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with out a for duration should error", func(t *testing.T) { + rule := validRuleV1(t) + rule.For = values.StringValue{} + _, err := rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with an invalid for duration should error", func(t *testing.T) { + rule := validRuleV1(t) + forDuration := values.StringValue{} + err := yaml.Unmarshal([]byte("10x"), &forDuration) + rule.For = forDuration + require.NoError(t, err) + _, err = rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with out a condition should error", func(t *testing.T) { + rule := validRuleV1(t) + rule.Condition = values.StringValue{} + _, err := rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with out data should error", func(t *testing.T) { + rule := validRuleV1(t) + rule.Data = []QueryV1{} + _, err := rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with out execErrState should have sane defaults", func(t *testing.T) { + rule := validRuleV1(t) + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Equal(t, ruleMapped.ExecErrState, models.AlertingErrState) + }) + t.Run("a rule with invalid execErrState should error", func(t *testing.T) { + rule := validRuleV1(t) + execErrState := values.StringValue{} + err := yaml.Unmarshal([]byte("abc"), &execErrState) + require.NoError(t, err) + rule.ExecErrState = execErrState + _, err = rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with a valid execErrState should map it correctly", func(t *testing.T) { + rule := validRuleV1(t) + execErrState := values.StringValue{} + err := yaml.Unmarshal([]byte(models.OkErrState), &execErrState) + require.NoError(t, err) + rule.ExecErrState = execErrState + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Equal(t, ruleMapped.ExecErrState, models.OkErrState) + }) + t.Run("a rule with out noDataState should have sane defaults", func(t *testing.T) { + rule := validRuleV1(t) + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Equal(t, ruleMapped.NoDataState, models.NoData) + }) + t.Run("a rule with an invalid noDataState should error", func(t *testing.T) { + rule := validRuleV1(t) + noDataState := values.StringValue{} + err := yaml.Unmarshal([]byte("abc"), &noDataState) + require.NoError(t, err) + rule.NoDataState = noDataState + _, err = rule.mapToModel(1) + require.Error(t, err) + }) + t.Run("a rule with a valid noDataState should map it correctly", func(t *testing.T) { + rule := validRuleV1(t) + noDataState := values.StringValue{} + err := yaml.Unmarshal([]byte(models.NoData), &noDataState) + require.NoError(t, err) + rule.NoDataState = noDataState + ruleMapped, err := rule.mapToModel(1) + require.NoError(t, err) + require.Equal(t, ruleMapped.NoDataState, models.NoData) + }) +} + +func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 { + t.Helper() + var ( + orgID values.Int64Value + name values.StringValue + folder values.StringValue + interval values.StringValue + ) + err := yaml.Unmarshal([]byte("1"), &orgID) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("Test"), &name) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("Test"), &folder) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("10s"), &interval) + require.NoError(t, err) + return AlertRuleGroupV1{ + OrgID: orgID, + Name: name, + Folder: folder, + Interval: interval, + Rules: []AlertRuleV1{}, + } +} + +func validRuleV1(t *testing.T) AlertRuleV1 { + t.Helper() + var ( + title values.StringValue + uid values.StringValue + forDuration values.StringValue + condition values.StringValue + ) + err := yaml.Unmarshal([]byte("test"), &title) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("test_uid"), &uid) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("10s"), &forDuration) + require.NoError(t, err) + err = yaml.Unmarshal([]byte("A"), &condition) + require.NoError(t, err) + return AlertRuleV1{ + Title: title, + UID: uid, + For: forDuration, + Condition: condition, + Data: []QueryV1{{}}, + } +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index e4d7ec292a1..77534e51d34 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -9,37 +9,50 @@ import ( "github.com/grafana/grafana/pkg/infra/log" plugifaces "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" dashboardservice "github.com/grafana/grafana/pkg/services/dashboards" datasourceservice "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/pluginsettings" + "github.com/grafana/grafana/pkg/services/provisioning/alerting/rules" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/datasources" "github.com/grafana/grafana/pkg/services/provisioning/notifiers" "github.com/grafana/grafana/pkg/services/provisioning/plugins" "github.com/grafana/grafana/pkg/services/provisioning/utils" + "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store, - encryptionService encryption.Internal, notificatonService *notifications.NotificationService, +func ProvideService( + ac accesscontrol.AccessControl, + cfg *setting.Cfg, + sqlStore *sqlstore.SQLStore, + pluginStore plugifaces.Store, + encryptionService encryption.Internal, + notificatonService *notifications.NotificationService, dashboardProvisioningService dashboardservice.DashboardProvisioningService, datasourceService datasourceservice.DataSourceService, dashboardService dashboardservice.DashboardService, - alertingService *alerting.AlertNotificationService, pluginSettings pluginsettings.Service, + folderService dashboardservice.FolderService, + alertingService *alerting.AlertNotificationService, + pluginSettings pluginsettings.Service, searchService searchV2.SearchService, + quotaService *quota.QuotaService, ) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, SQLStore: sqlStore, + ac: ac, pluginStore: pluginStore, EncryptionService: encryptionService, NotificationService: notificatonService, - log: log.New("provisioning"), newDashboardProvisioner: dashboards.New, provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, @@ -50,6 +63,8 @@ func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore p alertingService: alertingService, pluginsSettings: pluginSettings, searchService: searchService, + quotaService: quotaService, + log: log.New("provisioning"), } return s, nil } @@ -61,18 +76,21 @@ type ProvisioningService interface { ProvisionPlugins(ctx context.Context) error ProvisionNotifications(ctx context.Context) error ProvisionDashboards(ctx context.Context) error + ProvisionAlertRules(ctx context.Context) error GetDashboardProvisionerResolvedPath(name string) string GetAllowUIUpdatesFromConfig(name string) bool } // Add a public constructor for overriding service to be able to instantiate OSS as fallback func NewProvisioningServiceImpl() *ProvisioningServiceImpl { + logger := log.New("provisioning") return &ProvisioningServiceImpl{ - log: log.New("provisioning"), + log: logger, newDashboardProvisioner: dashboards.New, provisionNotifiers: notifiers.Provision, provisionDatasources: datasources.Provision, provisionPlugins: plugins.Provision, + provisionRules: rules.Provision, } } @@ -95,6 +113,7 @@ func newProvisioningServiceImpl( type ProvisioningServiceImpl struct { Cfg *setting.Cfg SQLStore *sqlstore.SQLStore + ac accesscontrol.AccessControl pluginStore plugifaces.Store EncryptionService encryption.Internal NotificationService *notifications.NotificationService @@ -105,6 +124,7 @@ type ProvisioningServiceImpl struct { provisionNotifiers func(context.Context, string, notifiers.Manager, notifiers.SQLStore, encryption.Internal, *notifications.NotificationService) error provisionDatasources func(context.Context, string, datasources.Store, utils.OrgStore) error provisionPlugins func(context.Context, string, plugins.Store, plugifaces.Store, pluginsettings.Service) error + provisionRules func(context.Context, string, dashboardservice.DashboardService, dashboardservice.DashboardProvisioningService, provisioning.AlertRuleService) error mutex sync.Mutex dashboardProvisioningService dashboardservice.DashboardProvisioningService dashboardService dashboardservice.DashboardService @@ -112,6 +132,7 @@ type ProvisioningServiceImpl struct { alertingService *alerting.AlertNotificationService pluginsSettings pluginsettings.Service searchService searchV2.SearchService + quotaService quota.Service } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -130,6 +151,11 @@ func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) erro return err } + err = ps.ProvisionAlertRules(ctx) + if err != nil { + return err + } + return nil } @@ -218,6 +244,29 @@ func (ps *ProvisioningServiceImpl) ProvisionDashboards(ctx context.Context) erro return nil } +func (ps *ProvisioningServiceImpl) ProvisionAlertRules(ctx context.Context) error { + alertRulesPath := filepath.Join(ps.Cfg.ProvisioningPath, "alerting") + st := store.DBstore{ + BaseInterval: ps.Cfg.UnifiedAlerting.BaseInterval, + DefaultInterval: ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval, + SQLStore: ps.SQLStore, + Logger: ps.log, + FolderService: nil, // we don't use it yet + AccessControl: ps.ac, + DashboardService: ps.dashboardService, + } + ruleService := provisioning.NewAlertRuleService( + st, + st, + ps.quotaService, + ps.SQLStore, + int64(ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), + int64(ps.Cfg.UnifiedAlerting.BaseInterval.Seconds()), + ps.log) + return rules.Provision(ctx, alertRulesPath, ps.dashboardService, + ps.dashboardProvisioningService, *ruleService) +} + func (ps *ProvisioningServiceImpl) GetDashboardProvisionerResolvedPath(name string) string { return ps.dashboardProvisioner.GetProvisionerResolvedPath(name) } diff --git a/pkg/services/provisioning/provisioning_mock.go b/pkg/services/provisioning/provisioning_mock.go index bca4f817d1b..86601ef5ed5 100644 --- a/pkg/services/provisioning/provisioning_mock.go +++ b/pkg/services/provisioning/provisioning_mock.go @@ -8,6 +8,7 @@ type Calls struct { ProvisionPlugins []interface{} ProvisionNotifications []interface{} ProvisionDashboards []interface{} + ProvisionAlertRules []interface{} GetDashboardProvisionerResolvedPath []interface{} GetAllowUIUpdatesFromConfig []interface{} Run []interface{} @@ -71,6 +72,11 @@ func (mock *ProvisioningServiceMock) ProvisionDashboards(ctx context.Context) er return nil } +func (mock *ProvisioningServiceMock) ProvisionAlertRules(ctx context.Context) error { + mock.Calls.ProvisionAlertRules = append(mock.Calls.ProvisionAlertRules, nil) + return nil +} + func (mock *ProvisioningServiceMock) GetDashboardProvisionerResolvedPath(name string) string { mock.Calls.GetDashboardProvisionerResolvedPath = append(mock.Calls.GetDashboardProvisionerResolvedPath, name) if mock.GetDashboardProvisionerResolvedPathFunc != nil {