Alerting: Add file provisioning for alert rules (#51635)

This commit is contained in:
Jean-Philippe Quéméner 2022-07-14 23:53:13 +02:00 committed by GitHub
parent e5e8747ee9
commit 41790083d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1427 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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