Skip to content

Commit d9cace4

Browse files
authored
Alerting: Add file provisioning for contact points (grafana#51924)
1 parent e791a4e commit d9cace4

File tree

31 files changed

+611
-184
lines changed

31 files changed

+611
-184
lines changed

pkg/api/admin_provisioning.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *models.ReqContext)
101101
}
102102

103103
func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *models.ReqContext) response.Response {
104-
err := hs.ProvisioningService.ProvisionAlertRules(c.Req.Context())
104+
err := hs.ProvisioningService.ProvisionAlerting(c.Req.Context())
105105
if err != nil {
106106
return response.Error(500, "", err)
107107
}

pkg/api/admin_provisioning_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func TestAPI_AdminProvisioningReload_AccessControl(t *testing.T) {
153153
},
154154
url: "/api/admin/provisioning/alerting/reload",
155155
checkCall: func(mock provisioning.ProvisioningServiceMock) {
156-
assert.Len(t, mock.Calls.ProvisionAlertRules, 1)
156+
assert.Len(t, mock.Calls.ProvisionAlerting, 1)
157157
},
158158
},
159159
{

pkg/services/provisioning/alerting/rules/config_reader.go renamed to pkg/services/provisioning/alerting/config_reader.go

+17-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package rules
1+
package alerting
22

33
import (
44
"context"
@@ -22,34 +22,35 @@ func newRulesConfigReader(logger log.Logger) rulesConfigReader {
2222
}
2323
}
2424

25-
func (cr *rulesConfigReader) readConfig(ctx context.Context, path string) ([]*RuleFile, error) {
26-
var alertRulesFiles []*RuleFile
27-
cr.log.Debug("looking for alert rules provisioning files", "path", path)
25+
func (cr *rulesConfigReader) readConfig(ctx context.Context, path string) ([]*AlertingFile, error) {
26+
var alertFiles []*AlertingFile
27+
cr.log.Debug("looking for alerting provisioning files", "path", path)
2828

2929
files, err := ioutil.ReadDir(path)
3030
if err != nil {
31-
cr.log.Error("can't read alert rules provisioning files from directory", "path", path, "error", err)
32-
return alertRulesFiles, nil
31+
cr.log.Error("can't read alerting provisioning files from directory", "path", path, "error", err)
32+
return alertFiles, nil
3333
}
3434

3535
for _, file := range files {
36-
cr.log.Debug("parsing alert rules provisioning file", "path", path, "file.Name", file.Name())
36+
cr.log.Debug("parsing alerting provisioning file", "path", path, "file.Name", file.Name())
3737
if !cr.isYAML(file.Name()) && !cr.isJSON(file.Name()) {
3838
return nil, fmt.Errorf("file has invalid suffix '%s' (.yaml,.yml,.json accepted)", file.Name())
3939
}
40-
ruleFileV1, err := cr.parseConfig(path, file)
40+
alertFileV1, err := cr.parseConfig(path, file)
4141
if err != nil {
42-
return nil, err
42+
return nil, fmt.Errorf("failure to parse file %s: %w", file.Name(), err)
4343
}
44-
if ruleFileV1 != nil {
45-
ruleFile, err := ruleFileV1.MapToModel()
44+
if alertFileV1 != nil {
45+
alertFileV1.Filename = file.Name()
46+
alertFile, err := alertFileV1.MapToModel()
4647
if err != nil {
47-
return nil, err
48+
return nil, fmt.Errorf("failure to map file %s: %w", alertFileV1.Filename, err)
4849
}
49-
alertRulesFiles = append(alertRulesFiles, &ruleFile)
50+
alertFiles = append(alertFiles, &alertFile)
5051
}
5152
}
52-
return alertRulesFiles, nil
53+
return alertFiles, nil
5354
}
5455

5556
func (cr *rulesConfigReader) isYAML(file string) bool {
@@ -60,15 +61,15 @@ func (cr *rulesConfigReader) isJSON(file string) bool {
6061
return strings.HasSuffix(file, ".json")
6162
}
6263

63-
func (cr *rulesConfigReader) parseConfig(path string, file fs.FileInfo) (*RuleFileV1, error) {
64+
func (cr *rulesConfigReader) parseConfig(path string, file fs.FileInfo) (*AlertingFileV1, error) {
6465
filename, _ := filepath.Abs(filepath.Join(path, file.Name()))
6566
// nolint:gosec
6667
// We can ignore the gosec G304 warning on this one because `filename` comes from ps.Cfg.ProvisioningPath
6768
yamlFile, err := ioutil.ReadFile(filename)
6869
if err != nil {
6970
return nil, err
7071
}
71-
var cfg *RuleFileV1
72+
var cfg *AlertingFileV1
7273
err = yaml.Unmarshal(yamlFile, &cfg)
7374
if err != nil {
7475
return nil, err
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package alerting
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/grafana/grafana/pkg/infra/log"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
const (
12+
testFileBrokenYAML = "./testdata/common/broken-yaml"
13+
testFileEmptyFile = "./testdata/common/empty-file"
14+
testFileEmptyFolder = "./testdata/common/empty-folder"
15+
testFileSupportedFiletypes = "./testdata/common/supported-filetypes"
16+
testFileCorrectProperties = "./testdata/alert_rules/correct-properties"
17+
testFileCorrectPropertiesWithOrg = "./testdata/alert_rules/correct-properties-with-org"
18+
testFileMultipleRules = "./testdata/alert_rules/multiple-rules"
19+
testFileMultipleFiles = "./testdata/alert_rules/multiple-files"
20+
testFileCorrectProperties_cp = "./testdata/contact_points/correct-properties"
21+
testFileCorrectPropertiesWithOrg_cp = "./testdata/contact_points/correct-properties-with-org"
22+
testFileEmptyUID = "./testdata/contact_points/empty-uid"
23+
testFileMissingUID = "./testdata/contact_points/missing-uid"
24+
testFileWhitespaceUID = "./testdata/contact_points/whitespace-uid"
25+
testFileMultipleCps = "./testdata/contact_points/multiple-contact-points"
26+
)
27+
28+
func TestConfigReader(t *testing.T) {
29+
configReader := newRulesConfigReader(log.NewNopLogger())
30+
ctx := context.Background()
31+
t.Run("a broken YAML file should error", func(t *testing.T) {
32+
_, err := configReader.readConfig(ctx, testFileBrokenYAML)
33+
require.Error(t, err)
34+
})
35+
t.Run("a rule file with correct properties should not error", func(t *testing.T) {
36+
ruleFiles, err := configReader.readConfig(ctx, testFileCorrectProperties)
37+
require.NoError(t, err)
38+
t.Run("when no organization is present it should be set to 1", func(t *testing.T) {
39+
require.Equal(t, int64(1), ruleFiles[0].Groups[0].Rules[0].OrgID)
40+
})
41+
})
42+
t.Run("a rule file with correct properties and specific org should not error", func(t *testing.T) {
43+
ruleFiles, err := configReader.readConfig(ctx, testFileCorrectPropertiesWithOrg)
44+
require.NoError(t, err)
45+
t.Run("when an organization is set it should not overwrite if with the default of 1", func(t *testing.T) {
46+
require.Equal(t, int64(1337), ruleFiles[0].Groups[0].Rules[0].OrgID)
47+
})
48+
})
49+
t.Run("an empty rule file should not make the config reader error", func(t *testing.T) {
50+
_, err := configReader.readConfig(ctx, testFileEmptyFile)
51+
require.NoError(t, err)
52+
})
53+
t.Run("an empty folder should not make the config reader error", func(t *testing.T) {
54+
_, err := configReader.readConfig(ctx, testFileEmptyFolder)
55+
require.NoError(t, err)
56+
})
57+
t.Run("the config reader should be able to read multiple files in the folder", func(t *testing.T) {
58+
ruleFiles, err := configReader.readConfig(ctx, testFileMultipleFiles)
59+
require.NoError(t, err)
60+
require.Len(t, ruleFiles, 2)
61+
})
62+
t.Run("the config reader should be able to read multiple rule groups", func(t *testing.T) {
63+
ruleFiles, err := configReader.readConfig(ctx, testFileMultipleRules)
64+
require.NoError(t, err)
65+
require.Len(t, ruleFiles[0].Groups, 2)
66+
})
67+
t.Run("the config reader should support .yaml,.yml and .json files", func(t *testing.T) {
68+
ruleFiles, err := configReader.readConfig(ctx, testFileSupportedFiletypes)
69+
require.NoError(t, err)
70+
require.Len(t, ruleFiles, 3)
71+
})
72+
t.Run("a contact point file with correct properties should not error", func(t *testing.T) {
73+
file, err := configReader.readConfig(ctx, testFileCorrectProperties_cp)
74+
require.NoError(t, err)
75+
t.Run("when no organization is present it should be set to 1", func(t *testing.T) {
76+
require.Equal(t, int64(1), file[0].ContactPoints[0].OrgID)
77+
})
78+
})
79+
t.Run("a contact point file with correct properties and specific org should not error", func(t *testing.T) {
80+
file, err := configReader.readConfig(ctx, testFileCorrectPropertiesWithOrg_cp)
81+
require.NoError(t, err)
82+
t.Run("when an organization is set it should not overwrite if with the default of 1", func(t *testing.T) {
83+
require.Equal(t, int64(1337), file[0].ContactPoints[0].OrgID)
84+
})
85+
})
86+
t.Run("a contact point file with empty UID should fail", func(t *testing.T) {
87+
_, err := configReader.readConfig(ctx, testFileEmptyUID)
88+
require.Error(t, err)
89+
})
90+
t.Run("a contact point file with missing UID should fail", func(t *testing.T) {
91+
_, err := configReader.readConfig(ctx, testFileMissingUID)
92+
require.Error(t, err)
93+
})
94+
t.Run("a contact point file with whitespace UID should fail", func(t *testing.T) {
95+
_, err := configReader.readConfig(ctx, testFileWhitespaceUID)
96+
require.Error(t, err)
97+
})
98+
t.Run("the config reader should be able to read a file with multiple contact points", func(t *testing.T) {
99+
file, err := configReader.readConfig(ctx, testFileMultipleCps)
100+
require.NoError(t, err)
101+
require.Len(t, file[0].ContactPoints, 2)
102+
})
103+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package alerting
2+
3+
import (
4+
"context"
5+
6+
"github.com/grafana/grafana/pkg/infra/log"
7+
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
8+
"github.com/grafana/grafana/pkg/services/ngalert/models"
9+
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
10+
)
11+
12+
type ContactPointProvisioner interface {
13+
Provision(ctx context.Context, files []*AlertingFile) error
14+
Unprovision(ctx context.Context, files []*AlertingFile) error
15+
}
16+
17+
type defaultContactPointProvisioner struct {
18+
logger log.Logger
19+
contactPointService provisioning.ContactPointService
20+
}
21+
22+
func NewContactPointProvisoner(logger log.Logger,
23+
contactPointService provisioning.ContactPointService) ContactPointProvisioner {
24+
return &defaultContactPointProvisioner{
25+
logger: logger,
26+
contactPointService: contactPointService,
27+
}
28+
}
29+
30+
func (c *defaultContactPointProvisioner) Provision(ctx context.Context,
31+
files []*AlertingFile) error {
32+
cpsCache := map[int64][]definitions.EmbeddedContactPoint{}
33+
for _, file := range files {
34+
for _, contactPointsConfig := range file.ContactPoints {
35+
// check if we already fetched the contact points for this org.
36+
// if not we fetch them and populate the cache.
37+
if _, exists := cpsCache[contactPointsConfig.OrgID]; !exists {
38+
cps, err := c.contactPointService.GetContactPoints(ctx, provisioning.ContactPointQuery{
39+
OrgID: contactPointsConfig.OrgID,
40+
})
41+
if err != nil {
42+
return err
43+
}
44+
cpsCache[contactPointsConfig.OrgID] = cps
45+
}
46+
outer:
47+
for _, contactPoint := range contactPointsConfig.ContactPoints {
48+
for _, fetchedCP := range cpsCache[contactPointsConfig.OrgID] {
49+
if fetchedCP.UID == contactPoint.UID {
50+
err := c.contactPointService.UpdateContactPoint(ctx,
51+
contactPointsConfig.OrgID, contactPoint, models.ProvenanceFile)
52+
if err != nil {
53+
return err
54+
}
55+
continue outer
56+
}
57+
}
58+
_, err := c.contactPointService.CreateContactPoint(ctx, contactPointsConfig.OrgID,
59+
contactPoint, models.ProvenanceFile)
60+
if err != nil {
61+
return err
62+
}
63+
}
64+
}
65+
}
66+
return nil
67+
}
68+
69+
func (c *defaultContactPointProvisioner) Unprovision(ctx context.Context,
70+
files []*AlertingFile) error {
71+
for _, file := range files {
72+
for _, cp := range file.DeleteContactPoints {
73+
err := c.contactPointService.DeleteContactPoint(ctx, cp.OrgID, cp.UID)
74+
if err != nil {
75+
return err
76+
}
77+
}
78+
}
79+
return nil
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package alerting
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/grafana/grafana/pkg/components/simplejson"
9+
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
10+
"github.com/grafana/grafana/pkg/services/ngalert/models"
11+
"github.com/grafana/grafana/pkg/services/provisioning/values"
12+
)
13+
14+
type DeleteContactPointV1 struct {
15+
OrgID values.Int64Value `json:"orgId" yaml:"orgId"`
16+
UID values.StringValue `json:"uid" yaml:"uid"`
17+
}
18+
19+
func (v1 *DeleteContactPointV1) MapToModel() DeleteContactPoint {
20+
orgID := v1.OrgID.Value()
21+
if orgID < 1 {
22+
orgID = 1
23+
}
24+
return DeleteContactPoint{
25+
OrgID: orgID,
26+
UID: v1.UID.Value(),
27+
}
28+
}
29+
30+
type DeleteContactPoint struct {
31+
OrgID int64 `json:"orgId" yaml:"orgId"`
32+
UID string `json:"uid" yaml:"uid"`
33+
}
34+
35+
type ContactPointV1 struct {
36+
OrgID values.Int64Value `json:"orgId" yaml:"orgId"`
37+
Name values.StringValue `json:"name" yaml:"name"`
38+
Receivers []ReceiverV1 `json:"receivers" yaml:"receivers"`
39+
}
40+
41+
func (cpV1 *ContactPointV1) MapToModel() (ContactPoint, error) {
42+
contactPoint := ContactPoint{}
43+
orgID := cpV1.OrgID.Value()
44+
if orgID < 1 {
45+
orgID = 1
46+
}
47+
contactPoint.OrgID = orgID
48+
name := strings.TrimSpace(cpV1.Name.Value())
49+
if name == "" {
50+
return ContactPoint{}, fmt.Errorf("no name is set")
51+
}
52+
for _, receiverV1 := range cpV1.Receivers {
53+
embeddedCP, err := receiverV1.mapToModel(name)
54+
if err != nil {
55+
return ContactPoint{}, fmt.Errorf("%s: %w", name, err)
56+
}
57+
contactPoint.ContactPoints = append(contactPoint.ContactPoints, embeddedCP)
58+
}
59+
return contactPoint, nil
60+
}
61+
62+
type ContactPoint struct {
63+
OrgID int64 `json:"orgId" yaml:"orgId"`
64+
ContactPoints []definitions.EmbeddedContactPoint `json:"configs" yaml:"configs"`
65+
}
66+
67+
type ReceiverV1 struct {
68+
UID values.StringValue `json:"uid" yaml:"uid"`
69+
Type values.StringValue `json:"type" yaml:"type"`
70+
Settings values.JSONValue `json:"settings" yaml:"settings"`
71+
DisableResolveMessage values.BoolValue `json:"disableResolveMessage"`
72+
}
73+
74+
func (config *ReceiverV1) mapToModel(name string) (definitions.EmbeddedContactPoint, error) {
75+
uid := strings.TrimSpace(config.UID.Value())
76+
if uid == "" {
77+
return definitions.EmbeddedContactPoint{}, fmt.Errorf("no uid is set")
78+
}
79+
cpType := strings.TrimSpace(config.Type.Value())
80+
if cpType == "" {
81+
return definitions.EmbeddedContactPoint{}, fmt.Errorf("no type is set")
82+
}
83+
if len(config.Settings.Value()) == 0 {
84+
return definitions.EmbeddedContactPoint{}, fmt.Errorf("no settings are set")
85+
}
86+
settings := simplejson.NewFromAny(config.Settings.Raw)
87+
cp := definitions.EmbeddedContactPoint{
88+
UID: uid,
89+
Name: name,
90+
Type: cpType,
91+
DisableResolveMessage: config.DisableResolveMessage.Value(),
92+
Provenance: string(models.ProvenanceFile),
93+
Settings: settings,
94+
}
95+
// As the values are not encrypted when coming from disk files,
96+
// we can simply return the fallback for validation.
97+
err := cp.Valid(func(_ context.Context, _ map[string][]byte, _, fallback string) string {
98+
return fallback
99+
})
100+
if err != nil {
101+
return definitions.EmbeddedContactPoint{}, err
102+
}
103+
return cp, nil
104+
}

0 commit comments

Comments
 (0)