Files
grafana/pkg/services/ngalert/migration/migration_test.go

1530 lines
60 KiB
Go
Raw Normal View History

package migration
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/alerting/models"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/tsdb/legacydata"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// TestServiceStart tests the wrapper method that decides when to run the migration based on migration status and settings.
func TestServiceStart(t *testing.T) {
tc := []struct {
name string
config *setting.Cfg
starting migrationStore.AlertingType
expectedErr bool
expected migrationStore.AlertingType
}{
{
name: "when unified alerting enabled and migration not already run, then run migration",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(true),
},
},
starting: migrationStore.Legacy,
expected: migrationStore.UnifiedAlerting,
},
{
name: "when unified alerting disabled, migration is already run and CleanUpgrade is enabled, then revert migration",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(false),
Upgrade: setting.UnifiedAlertingUpgradeSettings{
CleanUpgrade: true,
},
},
},
starting: migrationStore.UnifiedAlerting,
expected: migrationStore.Legacy,
},
{
name: "when unified alerting disabled, migration is already run and CleanUpgrade is disabled, then the migration status should set to false",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(false),
Upgrade: setting.UnifiedAlertingUpgradeSettings{
CleanUpgrade: false,
},
},
},
starting: migrationStore.UnifiedAlerting,
expected: migrationStore.Legacy,
},
{
name: "when unified alerting enabled and migration is already run, then do nothing",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(true),
},
},
starting: migrationStore.UnifiedAlerting,
expected: migrationStore.UnifiedAlerting,
},
{
name: "when unified alerting disabled and migration is not already run, then do nothing",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(false),
},
},
starting: migrationStore.Legacy,
expected: migrationStore.Legacy,
},
{
name: "when unified alerting disabled, migration is already run and force migration is enabled, then revert migration",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(false),
},
ForceMigration: true,
},
starting: migrationStore.UnifiedAlerting,
expected: migrationStore.Legacy,
},
{
name: "when unified alerting disabled, migration is already run and force migration is disabled, then the migration status should set to false",
config: &setting.Cfg{
UnifiedAlerting: setting.UnifiedAlertingSettings{
Enabled: pointer(false),
},
ForceMigration: false,
},
starting: migrationStore.UnifiedAlerting,
expected: migrationStore.Legacy,
},
}
sqlStore := db.InitTestDB(t)
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
service := NewTestMigrationService(t, sqlStore, tt.config)
require.NoError(t, service.migrationStore.SetCurrentAlertingType(ctx, tt.starting))
err := service.Run(ctx)
if tt.expectedErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
aType, err := service.migrationStore.GetCurrentAlertingType(ctx)
require.NoError(t, err)
require.Equal(t, tt.expected, aType)
})
}
}
// TestAMConfigMigration tests the execution of the migration specifically for migrations of channels and routes.
func TestAMConfigMigration(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
tc := []struct {
name string
legacyChannels []*models.AlertNotification
alerts []*models.Alert
expected map[int64]*apimodels.PostableUserConfig
expErrors []string
}{
{
name: "general multi-org, multi-alert, multi-channel migration",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false),
createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false),
createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false),
createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false),
createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default
},
alerts: []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}),
createAlert(t, 1, 1, 2, "alert2", []string{"notifier2", "notifier3"}),
createAlert(t, 1, 2, 3, "alert3", []string{"notifier3"}),
createAlert(t, 2, 3, 1, "alert4", []string{"notifier4"}),
createAlert(t, 2, 3, 2, "alert5", []string{"notifier4", "notifier5"}),
createAlert(t, 2, 4, 3, "alert6", []string{}),
},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier3", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier3"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
{Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}},
{Receiver: config.Receiver{Name: "notifier3"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier3", Type: "opsgenie"}}}},
},
},
},
int64(2): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier6", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier4", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier4"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier5", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier5"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier6"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier6", Type: "opsgenie"}}}},
{Receiver: config.Receiver{Name: "notifier4"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier4", Type: "email"}}}},
{Receiver: config.Receiver{Name: "notifier5"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier5", Type: "slack"}}}},
},
},
},
},
},
{
name: "when no default channel, create empty autogen-contact-point-default",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
},
alerts: []*models.Alert{},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
},
},
},
},
},
{
name: "when multiple default channels, they all have catch-all matchers",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, true),
createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, true),
},
alerts: []*models.Alert{},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchRegexp, Name: model.AlertNameLabel, Value: ".+"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
{Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}},
},
},
},
},
},
{
name: "when alerts share channels, only create one receiver per legacy channel",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false),
},
alerts: []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}),
createAlert(t, 1, 1, 1, "alert2", []string{"notifier1", "notifier2"}),
},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
{Receiver: "notifier2", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier2"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
{Receiver: config.Receiver{Name: "notifier2"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}},
},
},
},
},
},
{
name: "when channel not linked to any alerts, still create a receiver for it",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
},
alerts: []*models.Alert{},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
},
},
},
},
},
{
name: "when unsupported channels, do not migrate them",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "hipchat", "", false),
createAlertNotification(t, int64(1), "notifier3", "sensu", "", false),
},
alerts: []*models.Alert{},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
},
},
},
},
},
{
name: "when unsupported channel linked to alert, do not migrate only that channel",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "sensu", "", false),
},
alerts: []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{"notifier1", "notifier2"}),
},
expected: map[int64]*apimodels.PostableUserConfig{
int64(1): {
AlertmanagerConfig: apimodels.PostableApiAlertingConfig{
Config: apimodels.Config{Route: &apimodels.Route{
Receiver: "autogen-contact-point-default",
GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel},
Routes: []*apimodels.Route{
{
ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: ngModels.MigratedUseLegacyChannelsLabel, Value: "true"}},
Continue: true,
Routes: []*apimodels.Route{
{Receiver: "notifier1", ObjectMatchers: apimodels.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel("notifier1"), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)},
},
},
},
}},
Receivers: []*apimodels.PostableApiReceiver{
{Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{}},
{Receiver: config.Receiver{Name: "notifier1"}, PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}},
},
},
},
},
},
{
name: "failed channel migration fails upgrade",
legacyChannels: []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "slack", brokenSettings, false),
},
alerts: []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}),
},
expErrors: []string{"channel 'notifier2'"},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
defer teardown(t, x, service)
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
createDashboard(t, 2, 1, "dash2-1", "folder5-1", 5, nil),
createDashboard(t, 3, 2, "dash3-2", "folder6-2", 6, nil),
createDashboard(t, 4, 2, "dash4-2", "folder6-2", 6, nil),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
createFolder(t, 6, 2, "folder6-2"),
}
setupLegacyAlertsTables(t, x, tt.legacyChannels, tt.alerts, folders, dashes)
err := service.Run(context.Background())
if len(tt.expErrors) > 0 {
for _, expErr := range tt.expErrors {
require.ErrorContains(t, err, expErr)
}
return
}
require.NoError(t, err)
for orgId := range tt.expected {
amConfig := getAlertmanagerConfig(t, x, orgId)
// Order of nested GrafanaManagedReceivers is not guaranteed.
cOpt := []cmp.Option{
cmpopts.IgnoreUnexported(apimodels.PostableApiReceiver{}),
cmpopts.IgnoreFields(apimodels.PostableGrafanaReceiver{}, "UID", "Settings", "SecureSettings"),
cmpopts.SortSlices(func(a, b *apimodels.PostableGrafanaReceiver) bool { return a.Name < b.Name }),
cmpopts.SortSlices(func(a, b *apimodels.PostableApiReceiver) bool { return a.Name < b.Name }),
}
if !cmp.Equal(tt.expected[orgId].AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...) {
t.Errorf("Unexpected Receivers: %v", cmp.Diff(tt.expected[orgId].AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...))
}
// Order of routes is not guaranteed.
cOpt = []cmp.Option{
cmpopts.SortSlices(func(a, b *apimodels.Route) bool {
if a.Receiver != b.Receiver {
return a.Receiver < b.Receiver
}
return a.ObjectMatchers[0].Value < b.ObjectMatchers[0].Value
}),
cmpopts.IgnoreUnexported(apimodels.Route{}, labels.Matcher{}),
cmpopts.IgnoreFields(apimodels.Route{}, "GroupBy", "GroupByAll"),
}
if !cmp.Equal(tt.expected[orgId].AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...) {
t.Errorf("Unexpected Route: %v", cmp.Diff(tt.expected[orgId].AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...))
}
}
})
}
}
// TestDashAlertMigration tests the execution of the migration specifically for alert rules.
func TestDashAlertMigration(t *testing.T) {
withDefaults := func(lbls map[string]string) map[string]string {
lbls[ngModels.MigratedUseLegacyChannelsLabel] = "true"
return lbls
}
t.Run("when DashAlertMigration create ContactLabel on migrated AlertRules", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
legacyChannels := []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false),
createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false),
createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false),
createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false),
createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default
}
alerts := []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{"notifier1"}),
createAlert(t, 1, 1, 2, "alert2", []string{"notifier2", "notifier3"}),
createAlert(t, 1, 2, 3, "alert3", []string{"notifier3"}),
createAlert(t, 2, 3, 1, "alert4", []string{"notifier4"}),
createAlert(t, 2, 3, 2, "alert5", []string{"notifier4", "notifier5"}),
createAlert(t, 2, 4, 3, "alert6", []string{}),
}
expected := map[int64]map[string]*ngModels.AlertRule{
int64(1): {
"alert1": {Labels: withDefaults(map[string]string{contactLabel("notifier1"): "true"})},
"alert2": {Labels: withDefaults(map[string]string{contactLabel("notifier2"): "true", contactLabel("notifier3"): "true"})},
"alert3": {Labels: withDefaults(map[string]string{contactLabel("notifier3"): "true"})},
},
int64(2): {
// Don't include default channels.
"alert4": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true"})},
"alert5": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true", contactLabel("notifier5"): "true"})},
"alert6": {Labels: withDefaults(map[string]string{})},
},
}
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
createDashboard(t, 2, 1, "dash2-1", "folder5-1", 5, nil),
createDashboard(t, 3, 2, "dash3-2", "folder6-2", 6, nil),
createDashboard(t, 4, 2, "dash4-2", "folder6-2", 6, nil),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
createFolder(t, 6, 2, "folder6-2"),
}
setupLegacyAlertsTables(t, x, legacyChannels, alerts, folders, dashes)
err := service.Run(context.Background())
require.NoError(t, err)
for orgId := range expected {
rules := getAlertRules(t, x, orgId)
expectedRulesMap := expected[orgId]
require.Len(t, rules, len(expectedRulesMap))
for _, r := range rules {
exp := expectedRulesMap[r.Title].Labels
require.Lenf(t, r.Labels, len(exp), "rule doesn't have correct number of labels: %s", r.Title)
for l := range r.Labels {
require.Equal(t, exp[l], r.Labels[l])
}
}
}
})
t.Run("when folder is missing put alert in General folder", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
o := createOrg(t, 1)
folder1 := createFolder(t, 1, o.ID, "folder-1")
dash1 := createDashboard(t, 3, o.ID, "dash1", folder1.UID, folder1.ID, nil)
dash2 := createDashboard(t, 4, o.ID, "dash2", "22", 22, nil) // missing folder
a1 := createAlert(t, int(o.ID), int(dash1.ID), 1, "alert-1", []string{})
a2 := createAlert(t, int(o.ID), int(dash2.ID), 1, "alert-2", []string{})
_, err := x.Insert(o, folder1, dash1, dash2, a1, a2)
require.NoError(t, err)
err = service.Run(context.Background())
require.NoError(t, err)
rules := getAlertRules(t, x, o.ID)
require.Len(t, rules, 2)
var generalFolder dashboards.Dashboard
_, err = x.Table(&dashboards.Dashboard{}).Where("title = ? AND org_id = ?", generalAlertingFolderTitle, o.ID).Get(&generalFolder)
require.NoError(t, err)
require.NotNil(t, generalFolder)
for _, rule := range rules {
// var expectedFolder dashboards.Dashboard
// if rule.Title == a1.Name {
// expectedFolder = *folder1
// } else {
// expectedFolder = generalFolder
// }
require.NotEmpty(t, rule.NamespaceUID)
}
})
t.Run("when alert notification settings contain different combinations of id and uid", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
legacyChannels := []*models.AlertNotification{
createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, false),
createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, false),
createAlertNotification(t, int64(1), "notifier3", "opsgenie", opsgenieSettings, false),
createAlertNotification(t, int64(2), "notifier4", "email", emailSettings, false),
createAlertNotification(t, int64(2), "notifier5", "slack", slackSettings, false),
createAlertNotification(t, int64(2), "notifier6", "opsgenie", opsgenieSettings, true), // default
}
alerts := []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", nil),
createAlert(t, 1, 1, 2, "alert2", nil),
createAlert(t, 1, 2, 3, "alert3", nil),
createAlert(t, 2, 3, 1, "alert4", nil),
createAlert(t, 2, 3, 2, "alert5", nil),
createAlert(t, 2, 4, 3, "alert6", nil),
}
alerts[0].Settings.Set("notifications", []notificationKey{{UID: "notifier1"}})
alerts[1].Settings.Set("notifications", []notificationKey{{ID: 2}, {UID: "notifier3"}})
alerts[2].Settings.Set("notifications", []notificationKey{{ID: 3, UID: "notifier4"}}) // This shouldn't happen, but if it does we choose the ID.
alerts[3].Settings.Set("notifications", []notificationKey{{ID: -99}}) // Unknown ID
alerts[4].Settings.Set("notifications", []notificationKey{{UID: "unknown"}}) // Unknown UID
alerts[5].Settings.Set("notifications", []notificationKey{{ID: -99}, {UID: "unknown"}, {UID: "notifier4"}, {ID: 5}}) // Mixed unknown and known.
expected := map[int64]map[string]*ngModels.AlertRule{
int64(1): {
"alert1": {Labels: withDefaults(map[string]string{contactLabel("notifier1"): "true"})},
"alert2": {Labels: withDefaults(map[string]string{contactLabel("notifier2"): "true", contactLabel("notifier3"): "true"})},
"alert3": {Labels: withDefaults(map[string]string{contactLabel("notifier3"): "true"})},
},
int64(2): {
// Don't include default channels.
"alert4": {Labels: withDefaults(map[string]string{})},
"alert5": {Labels: withDefaults(map[string]string{})},
"alert6": {Labels: withDefaults(map[string]string{contactLabel("notifier4"): "true", contactLabel("notifier5"): "true"})},
},
}
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
createDashboard(t, 2, 1, "dash2-1", "folder5-1", 5, nil),
createDashboard(t, 3, 2, "dash3-2", "folder6-2", 6, nil),
createDashboard(t, 4, 2, "dash4-2", "folder6-2", 6, nil),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
createFolder(t, 6, 2, "folder6-2"),
}
setupLegacyAlertsTables(t, x, legacyChannels, alerts, folders, dashes)
err := service.Run(context.Background())
require.NoError(t, err)
for orgId := range expected {
rules := getAlertRules(t, x, orgId)
expectedRulesMap := expected[orgId]
require.Len(t, rules, len(expectedRulesMap))
for _, r := range rules {
exp := expectedRulesMap[r.Title].Labels
require.Lenf(t, r.Labels, len(exp), "rule doesn't have correct number of labels: %s", r.Title)
for l := range r.Labels {
require.Equal(t, exp[l], r.Labels[l])
}
}
}
})
t.Run("when migrated rules contain duplicate titles", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
alerts := []*models.Alert{
createAlert(t, 1, 1, 1, "alert1", []string{}),
createAlert(t, 1, 1, 2, "alert1", []string{}),
createAlert(t, 1, 2, 3, "alert1", []string{}),
createAlert(t, 1, 3, 4, "alert1", []string{}),
createAlert(t, 1, 3, 5, "alert1", []string{}),
createAlert(t, 1, 3, 6, "alert1", []string{}),
}
expected := map[int64]map[int64]string{
int64(1): {
1: "alert1",
2: "alert1 #2",
3: "alert1 #3",
4: "alert1",
5: "alert1 #2",
6: "alert1 #3",
},
}
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
createDashboard(t, 2, 1, "dash2-1", "folder5-1", 5, nil),
createDashboard(t, 3, 1, "dash3-1", "folder6-1", 6, nil),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
createFolder(t, 6, 1, "folder6-1"),
}
setupLegacyAlertsTables(t, x, nil, alerts, folders, dashes)
err := service.Run(context.Background())
require.NoError(t, err)
for orgId := range expected {
rules := getAlertRules(t, x, orgId)
expectedRulesMap := expected[orgId]
require.Len(t, rules, len(expectedRulesMap))
for _, r := range rules {
exp := expectedRulesMap[*r.PanelID]
assert.Equal(t, exp, r.Title)
}
}
})
t.Run("when migrated rules contain titles that are too long", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
alerts := []*models.Alert{
createAlert(t, 1, 1, 1, strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1), []string{}),
createAlert(t, 1, 1, 2, strings.Repeat("a", store.AlertDefinitionMaxTitleLength+2), []string{}),
}
expected := map[int64]map[int64]string{
int64(1): {
1: strings.Repeat("a", store.AlertDefinitionMaxTitleLength),
2: strings.Repeat("a", store.AlertDefinitionMaxTitleLength-3) + " #2",
},
}
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
}
setupLegacyAlertsTables(t, x, nil, alerts, folders, dashes)
err := service.Run(context.Background())
require.NoError(t, err)
for orgId := range expected {
rules := getAlertRules(t, x, orgId)
expectedRulesMap := expected[orgId]
require.Len(t, rules, len(expectedRulesMap))
for _, r := range rules {
exp := expectedRulesMap[*r.PanelID]
require.Equal(t, exp, r.Title)
}
}
})
}
const newQueryModel = `{"datasource":{"type":"prometheus","uid":"gdev-prometheus"},"expr":"up{job=\"fake-data-gen\"}","instant":false,"interval":"%s","intervalMs":%d,"maxDataPoints":1500,"refId":"%s"}`
func createAlertQueryWithModel(refId string, ds string, from string, to string, model string) ngModels.AlertQuery {
rel, _ := getRelativeDuration(from, to)
return ngModels.AlertQuery{
RefID: refId,
RelativeTimeRange: ngModels.RelativeTimeRange{From: rel.From, To: rel.To},
DatasourceUID: ds,
Model: []byte(model),
}
}
func createAlertQuery(refId string, ds string, from string, to string) ngModels.AlertQuery {
dur, _ := calculateInterval(legacydata.NewDataTimeRange(from, to), simplejson.New(), nil)
return createAlertQueryWithModel(refId, ds, from, to, fmt.Sprintf(newQueryModel, "", dur.Milliseconds(), refId))
}
func createClassicConditionQuery(refId string, conditions []classicCondition) ngModels.AlertQuery {
exprModel := struct {
Type string `json:"type"`
RefID string `json:"refId"`
Conditions []classicCondition `json:"conditions"`
}{
"classic_conditions",
refId,
conditions,
}
exprModelJSON, _ := json.Marshal(&exprModel)
q := ngModels.AlertQuery{
RefID: refId,
DatasourceUID: expressionDatasourceUID,
Model: exprModelJSON,
}
// IntervalMS and MaxDataPoints are created PreSave by AlertQuery. They don't appear to be necessary for expressions,
// but run PreSave here to match the expected model.
_ = q.PreSave()
return q
}
func cond(refId string, reducer string, evalType string, thresh float64) classicCondition {
return classicCondition{
Evaluator: evaluator{Params: []float64{thresh}, Type: evalType},
Operator: struct {
Type string `json:"type"`
}{Type: "and"},
Query: struct {
Params []string `json:"params"`
}{Params: []string{refId}},
Reducer: struct {
Type string `json:"type"`
}{Type: reducer},
}
}
// TestDashAlertQueryMigration tests the execution of the migration specifically for alert rule queries.
func TestDashAlertQueryMigration(t *testing.T) {
sqlStore := db.InitTestDB(t)
x := sqlStore.GetEngine()
service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
genAlert := func(mutators ...ngModels.AlertRuleMutator) *ngModels.AlertRule {
rule := &ngModels.AlertRule{
ID: 1,
OrgID: 1,
Title: "alert1",
Condition: "B",
Data: []ngModels.AlertQuery{},
IntervalSeconds: 60,
Version: 1,
NamespaceUID: "folder5-1",
DashboardUID: pointer("dash1-1"),
PanelID: pointer(int64(1)),
Alerting: Handle custom dashboard permissions in migration service (#74504) * Fix migration of custom dashboard permissions Dashboard alert permissions were determined by both its dashboard and folder scoped permissions, while UA alert rules only have folder scoped permissions. This means, when migrating an alert, we'll need to decide if the parent folder is a correct location for the newly created alert rule so that users, teams, and org roles have the same access to it as they did in legacy. To do this, we translate both the folder and dashboard resource permissions to two sets of SetResourcePermissionCommands. Each of these encapsulates a mapping of all: OrgRoles -> Viewer/Editor/Admin Teams -> Viewer/Editor/Admin Users -> Viewer/Editor/Admin When the dashboard permissions (including those inherited from the parent folder) differ from the parent folder permissions alone, we need to create a new folder to represent the access-level of the legacy dashboard. Compromises: When determining the SetResourcePermissionCommands we only take into account managed and basic roles. Fixed and custom roles introduce significant complexity and synchronicity hurdles. Instead, we log a warning they had the potential to override the newly created folder permissions. Also, we don't attempt to reconcile datasource permissions that were not necessary in legacy alerting. Users without access to the necessary datasources to edit an alert rule will need to obtain said access separate from the migration.
2023-10-12 23:12:40 +01:00
RuleGroup: "dash1-1",
RuleGroupIndex: 1,
NoDataState: ngModels.NoData,
ExecErrState: ngModels.AlertingErrState,
For: 60 * time.Second,
Annotations: map[string]string{
ngModels.MigratedMessageAnnotation: "message",
},
Labels: map[string]string{ngModels.MigratedUseLegacyChannelsLabel: "true"},
IsPaused: false,
}
for _, mutator := range mutators {
mutator(rule)
}
rule.RuleGroup = fmt.Sprintf("%s - 1m", *rule.DashboardUID)
Alerting: Handle custom dashboard permissions in migration service (#74504) * Fix migration of custom dashboard permissions Dashboard alert permissions were determined by both its dashboard and folder scoped permissions, while UA alert rules only have folder scoped permissions. This means, when migrating an alert, we'll need to decide if the parent folder is a correct location for the newly created alert rule so that users, teams, and org roles have the same access to it as they did in legacy. To do this, we translate both the folder and dashboard resource permissions to two sets of SetResourcePermissionCommands. Each of these encapsulates a mapping of all: OrgRoles -> Viewer/Editor/Admin Teams -> Viewer/Editor/Admin Users -> Viewer/Editor/Admin When the dashboard permissions (including those inherited from the parent folder) differ from the parent folder permissions alone, we need to create a new folder to represent the access-level of the legacy dashboard. Compromises: When determining the SetResourcePermissionCommands we only take into account managed and basic roles. Fixed and custom roles introduce significant complexity and synchronicity hurdles. Instead, we log a warning they had the potential to override the newly created folder permissions. Also, we don't attempt to reconcile datasource permissions that were not necessary in legacy alerting. Users without access to the necessary datasources to edit an alert rule will need to obtain said access separate from the migration.
2023-10-12 23:12:40 +01:00
rule.Annotations[ngModels.DashboardUIDAnnotation] = *rule.DashboardUID
rule.Annotations[ngModels.PanelIDAnnotation] = strconv.FormatInt(*rule.PanelID, 10)
return rule
}
type testcase struct {
name string
alerts []*models.Alert
expectedFolder *dashboards.Dashboard
expected map[int64][]*ngModels.AlertRule
expErrors []string
}
tc := []testcase{
{
name: "simple query and condition",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{createCondition("A", "max", "gt", 42, 1, "5m", "now")}),
createAlertWithCond(t, 2, 3, 1, "alert1", nil,
[]dashAlertCondition{createCondition("A", "max", "gt", 42, 3, "5m", "now")}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
}))
}),
},
int64(2): {
genAlert(func(rule *ngModels.AlertRule) {
rule.OrgID = 2
rule.DashboardUID = pointer("dash3-2")
rule.NamespaceUID = "folder6-2"
rule.Data = append(rule.Data, createAlertQuery("A", "ds3-2", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
}))
}),
},
},
},
{
name: "multiple conditions",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "avg", "gt", 42, 1, "5m", "now"),
createCondition("B", "max", "gt", 43, 2, "3m", "now"),
createCondition("C", "min", "lt", 20, 2, "3m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "D"
rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createAlertQuery("B", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createAlertQuery("C", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("D", []classicCondition{
cond("A", "avg", "gt", 42),
cond("B", "max", "gt", 43),
cond("C", "min", "lt", 20),
}))
}),
},
},
},
{
name: "multiple conditions on same query with same timerange should not create multiple queries",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "max", "gt", 42, 1, "5m", "now"),
createCondition("A", "avg", "gt", 20, 1, "5m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "B"
rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
cond("A", "avg", "gt", 20),
}))
}),
},
},
},
{
name: "multiple conditions on same query with different timeranges should create multiple queries",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "max", "gt", 42, 1, "5m", "now"),
createCondition("A", "avg", "gt", 20, 1, "3m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "C"
rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "3m", "now")) // Ordered by time range.
rule.Data = append(rule.Data, createAlertQuery("B", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("C", []classicCondition{
cond("B", "max", "gt", 42),
cond("A", "avg", "gt", 20),
}))
}),
},
},
},
{
name: "multiple conditions custom refIds",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("Q1", "avg", "gt", 42, 1, "5m", "now"),
createCondition("Q2", "max", "gt", 43, 2, "3m", "now"),
createCondition("Q3", "min", "lt", 20, 2, "3m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "A"
rule.Data = append(rule.Data, createClassicConditionQuery("A", []classicCondition{
cond("Q1", "avg", "gt", 42),
cond("Q2", "max", "gt", 43),
cond("Q3", "min", "lt", 20),
}))
rule.Data = append(rule.Data, createAlertQuery("Q1", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createAlertQuery("Q2", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createAlertQuery("Q3", "ds2-1", "3m", "now"))
}),
},
},
},
{
name: "multiple conditions out of order refIds, queries should be sorted by refId and conditions should be in original order",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("B", "avg", "gt", 42, 1, "5m", "now"),
createCondition("C", "max", "gt", 43, 2, "3m", "now"),
createCondition("A", "min", "lt", 20, 2, "3m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "D"
rule.Data = append(rule.Data, createAlertQuery("A", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createAlertQuery("B", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createAlertQuery("C", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("D", []classicCondition{
cond("B", "avg", "gt", 42),
cond("C", "max", "gt", 43),
cond("A", "min", "lt", 20),
}))
}),
},
},
},
{
name: "multiple conditions out of order with duplicate refIds",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("C", "avg", "gt", 42, 1, "5m", "now"),
createCondition("C", "max", "gt", 43, 1, "3m", "now"),
createCondition("B", "min", "lt", 20, 2, "5m", "now"),
createCondition("B", "min", "lt", 21, 2, "3m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Condition = "E"
rule.Data = append(rule.Data, createAlertQuery("A", "ds2-1", "3m", "now"))
rule.Data = append(rule.Data, createAlertQuery("B", "ds2-1", "5m", "now"))
rule.Data = append(rule.Data, createAlertQuery("C", "ds1-1", "3m", "now"))
rule.Data = append(rule.Data, createAlertQuery("D", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("E", []classicCondition{
cond("D", "avg", "gt", 42),
cond("C", "max", "gt", 43),
cond("B", "min", "lt", 20),
cond("A", "min", "lt", 21),
}))
}),
},
},
},
{
name: "alerts with unknown datasource id migrates with empty datasource uid",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{createCondition("A", "max", "gt", 42, 123, "5m", "now")}), // Unknown datasource id.
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Data = append(rule.Data, createAlertQuery("A", "", "5m", "now")) // Empty datasource UID.
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
}))
}),
},
},
},
{
name: "alerts with unknown dashboard do not migrate",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 22, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "avg", "gt", 42, 1, "5m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {},
},
},
{
name: "alerts with unknown org do not migrate",
alerts: []*models.Alert{
createAlertWithCond(t, 22, 1, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "avg", "gt", 42, 1, "5m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(22): {},
},
},
{
name: "alerts in general folder migrate to existing general alerting",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 8, 1, "alert1", nil,
[]dashAlertCondition{
createCondition("A", "avg", "gt", 42, 1, "5m", "now"),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.NamespaceUID = "General Alerting"
rule.DashboardUID = pointer("dash-in-general-1")
rule.Data = append(rule.Data, createAlertQuery("A", "ds1-1", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "avg", "gt", 42),
}))
}),
},
},
},
{
name: "alerts in general folder migrate to newly created general alerting if one doesn't exist",
alerts: []*models.Alert{
createAlertWithCond(t, 2, 9, 1, "alert1", nil, // Org 2 doesn't have general alerting folder.
[]dashAlertCondition{
createCondition("A", "avg", "gt", 42, 3, "5m", "now"),
}),
},
expectedFolder: &dashboards.Dashboard{
OrgID: 2,
Title: "General Alerting",
Slug: "general-alerting",
},
expected: map[int64][]*ngModels.AlertRule{
int64(2): {
genAlert(func(rule *ngModels.AlertRule) {
rule.OrgID = 2
rule.DashboardUID = pointer("dash-in-general-2")
rule.Data = append(rule.Data, createAlertQuery("A", "ds3-2", "5m", "now"))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "avg", "gt", 42),
}))
}),
},
},
},
{
name: "failed alert migration fails upgrade",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{{}}),
},
expErrors: []string{"migrate alert 'alert1'"},
},
{
name: "simple query with interval, calculates intervalMs using it as min interval",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
withQueryModel(
createCondition("A", "max", "gt", 42, 1, "5m", "now"),
fmt.Sprintf(queryModel, "A", "1s"),
),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Data = append(rule.Data, createAlertQueryWithModel("A", "ds1-1", "5m", "now", fmt.Sprintf(newQueryModel, "1s", 1000, "A")))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
}))
}),
},
},
},
{
name: "simple query with interval as variable, calculates intervalMs using default as min interval",
alerts: []*models.Alert{
createAlertWithCond(t, 1, 1, 1, "alert1", nil,
[]dashAlertCondition{
withQueryModel(
createCondition("A", "max", "gt", 42, 1, "5m", "now"),
fmt.Sprintf(queryModel, "A", "$min_interval"),
),
}),
},
expected: map[int64][]*ngModels.AlertRule{
int64(1): {
genAlert(func(rule *ngModels.AlertRule) {
rule.Data = append(rule.Data, createAlertQueryWithModel("A", "ds1-1", "5m", "now", fmt.Sprintf(newQueryModel, "$min_interval", 1000, "A")))
rule.Data = append(rule.Data, createClassicConditionQuery("B", []classicCondition{
cond("A", "max", "gt", 42),
}))
}),
},
},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
defer teardown(t, x, service)
dashes := []*dashboards.Dashboard{
createDashboard(t, 1, 1, "dash1-1", "folder5-1", 5, nil),
createDashboard(t, 2, 1, "dash2-1", "folder5-1", 5, nil),
createDashboard(t, 3, 2, "dash3-2", "folder6-2", 6, nil),
createDashboard(t, 4, 2, "dash4-2", "folder6-2", 6, nil),
createDashboard(t, 8, 1, "dash-in-general-1", "", 0, nil),
createDashboard(t, 9, 2, "dash-in-general-2", "", 0, nil),
createDashboard(t, 10, 1, "dash-with-acl-1", "folder5-1", 5, func(d *dashboards.Dashboard) {
d.Title = "Dashboard With ACL 1"
d.HasACL = true
}),
}
folders := []*dashboards.Dashboard{
createFolder(t, 5, 1, "folder5-1"),
createFolder(t, 6, 2, "folder6-2"),
createFolder(t, 7, 1, "General Alerting"),
}
setupLegacyAlertsTables(t, x, nil, tt.alerts, folders, dashes)
err := service.Run(context.Background())
if len(tt.expErrors) > 0 {
for _, expErr := range tt.expErrors {
require.ErrorContains(t, err, expErr)
}
return
}
require.NoError(t, err)
for orgId, expected := range tt.expected {
rules := getAlertRules(t, x, orgId)
for _, r := range rules {
// Remove generated fields.
require.NotEqual(t, r.Annotations[ngModels.MigratedAlertIdAnnotation], "")
delete(r.Annotations, ngModels.MigratedAlertIdAnnotation)
// If folder is created, we check if separately
if tt.expectedFolder != nil {
folder := getDashboard(t, x, orgId, r.NamespaceUID)
require.Equal(t, tt.expectedFolder.Title, folder.Title)
require.Equal(t, tt.expectedFolder.OrgID, folder.OrgID)
require.Equal(t, tt.expectedFolder.FolderUID, folder.FolderUID)
}
}
cOpt := []cmp.Option{
cmpopts.SortSlices(func(a, b *ngModels.AlertRule) bool {
return a.ID < b.ID
}),
cmpopts.IgnoreUnexported(ngModels.AlertRule{}, ngModels.AlertQuery{}),
cmpopts.IgnoreFields(ngModels.AlertRule{}, "Updated", "UID", "ID"),
}
if tt.expectedFolder != nil {
cOpt = append(cOpt, cmpopts.IgnoreFields(ngModels.AlertRule{}, "NamespaceUID"))
}
for i := 0; i < len(rules); i++ {
rules[i].NamespaceUID = expected[i].NamespaceUID
}
if !cmp.Equal(expected, rules, cOpt...) {
t.Errorf("Unexpected Rule: %v", cmp.Diff(expected, rules, cOpt...))
}
}
})
}
}
const (
emailSettings = `{"addresses": "test"}`
slackSettings = `{"recipient": "test", "token": "test"}`
opsgenieSettings = `{"apiKey": "test"}`
brokenSettings = `[{"unknown": 1.5}]`
)
var (
now = time.Now()
)
// createAlertNotificationWithReminder creates a legacy alert notification channel for inserting into the test database.
func createAlertNotificationWithReminder(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool, sendReminder bool, frequency time.Duration) *models.AlertNotification {
t.Helper()
settingsJson := simplejson.New()
if settings != "" {
s, err := simplejson.NewJson([]byte(settings))
if err != nil {
t.Fatalf("Failed to unmarshal alert notification json: %v", err)
}
settingsJson = s
}
return &models.AlertNotification{
OrgID: orgId,
UID: uid,
Name: uid, // Same as uid to make testing easier.
Type: channelType,
DisableResolveMessage: false,
IsDefault: defaultChannel,
Settings: settingsJson,
SecureSettings: make(map[string][]byte),
Created: now,
Updated: now,
SendReminder: sendReminder,
Frequency: frequency,
}
}
// createAlertNotification creates a legacy alert notification channel for inserting into the test database.
func createAlertNotification(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool) *models.AlertNotification {
return createAlertNotificationWithReminder(t, orgId, uid, channelType, settings, defaultChannel, false, time.Duration(0))
}
func withQueryModel(base dashAlertCondition, model string) dashAlertCondition {
base.Query.Model = []byte(model)
return base
}
var queryModel = `{"datasource":{"type":"prometheus","uid":"gdev-prometheus"},"expr":"up{job=\"fake-data-gen\"}","instant":false,"refId":"%s","interval":"%s"}`
func createCondition(refId string, reducer string, evalType string, thresh float64, datasourceId int64, from string, to string) dashAlertCondition {
return dashAlertCondition{
Evaluator: evaluator{
Params: []float64{thresh},
Type: evalType,
},
Operator: struct {
Type string `json:"type"`
}{
Type: "and",
},
Query: struct {
Params []string `json:"params"`
DatasourceID int64 `json:"datasourceId"`
Model json.RawMessage
}{
Params: []string{refId, from, to},
DatasourceID: datasourceId,
Model: []byte(fmt.Sprintf(queryModel, refId, "")),
},
Reducer: struct {
Type string `json:"type"`
}{
Type: reducer,
},
}
}
// createAlert creates a legacy alert rule for inserting into the test database.
func createAlert(t *testing.T, orgId int, dashboardId int, panelsId int, name string, notifierUids []string) *models.Alert {
return createAlertWithCond(t, orgId, dashboardId, panelsId, name, notifierUids, []dashAlertCondition{})
}
// createAlert creates a legacy alert rule for inserting into the test database.
func createAlertWithCond(t *testing.T, orgId int, dashboardId int, panelsId int, name string, notifierUids []string, cond []dashAlertCondition) *models.Alert {
t.Helper()
var settings = simplejson.New()
if len(notifierUids) != 0 {
notifiers := make([]any, 0)
for _, n := range notifierUids {
notifiers = append(notifiers, notificationKey{UID: n})
}
settings.Set("notifications", notifiers)
}
settings.Set("conditions", cond)
return &models.Alert{
OrgID: int64(orgId),
DashboardID: int64(dashboardId),
PanelID: int64(panelsId),
Name: name,
Message: "message",
Frequency: int64(60),
For: 60 * time.Second,
State: models.AlertStateOK,
Settings: settings,
NewStateDate: now,
Created: now,
Updated: now,
}
}
// createDashboard creates a folder for inserting into the test database.
func createFolder(t *testing.T, id int64, orgId int64, uid string) *dashboards.Dashboard {
// TODO this should create also the entries in the folder table
// or better call the folder service to take care of both
f := createDashboard(t, id, orgId, uid, "", 0, nil)
f.IsFolder = true
return f
}
// createDashboard creates a dashboard for inserting into the test database.
func createDashboard(t *testing.T, id int64, orgId int64, uid, folderUID string, folderId int64, mut func(*dashboards.Dashboard)) *dashboards.Dashboard {
t.Helper()
d := &dashboards.Dashboard{
ID: id,
OrgID: orgId,
UID: uid,
Created: now,
Updated: now,
Title: uid, // Not tested, needed to satisfy constraint.
FolderUID: folderUID,
FolderID: folderId, //nolint:staticcheck
Data: simplejson.New(),
Version: 1,
}
if mut != nil {
mut(d)
}
return d
}
// createDatasource creates a datasource for inserting into the test database.
func createDatasource(t *testing.T, id int64, orgId int64, uid string) *datasources.DataSource {
t.Helper()
return &datasources.DataSource{
ID: id,
OrgID: orgId,
UID: uid,
Created: now,
Updated: now,
Name: uid, // Not tested, needed to satisfy constraint.
}
}
func createOrg(t *testing.T, id int64) *org.Org {
t.Helper()
return &org.Org{
ID: id,
Version: 1,
Name: fmt.Sprintf("org_%d", id),
Created: time.Now(),
Updated: time.Now(),
}
}
// teardown cleans the input tables between test cases.
func teardown(t *testing.T, x *xorm.Engine, service *migrationService) {
_, err := x.Exec("DELETE from org")
require.NoError(t, err)
_, err = x.Exec("DELETE from alert")
require.NoError(t, err)
_, err = x.Exec("DELETE from alert_notification")
require.NoError(t, err)
_, err = x.Exec("DELETE from dashboard")
require.NoError(t, err)
_, err = x.Exec("DELETE from data_source")
require.NoError(t, err)
err = service.migrationStore.RevertAllOrgs(context.Background())
require.NoError(t, err)
}
// setupLegacyAlertsTables inserts data into the legacy alerting tables that is needed for testing the
func setupLegacyAlertsTables(t *testing.T, x *xorm.Engine, legacyChannels []*models.AlertNotification, alerts []*models.Alert, folders []*dashboards.Dashboard, dashes []*dashboards.Dashboard) {
t.Helper()
orgs := []org.Org{
*createOrg(t, 1),
*createOrg(t, 2),
}
// Setup folders.
// this loop is required because nullable it does not seem to work
// when inserting multiple rows at once
for _, f := range folders {
// if folder_uid is empty string, it will be set to NULL
_, err := x.NewSession().Nullable("folder_uid").Insert(f)
require.NoError(t, err)
}
// Setup dashboards.
// this loop is required because nullable it does not seem to work
// when inserting multiple rows at once
for _, d := range dashes {
// if folder_uid is empty string, it will be set to NULL
_, err := x.NewSession().Nullable("folder_uid").Insert(d)
require.NoError(t, err)
}
// Setup data_sources.
dataSources := []datasources.DataSource{
*createDatasource(t, 1, 1, "ds1-1"),
*createDatasource(t, 2, 1, "ds2-1"),
*createDatasource(t, 3, 2, "ds3-2"),
*createDatasource(t, 4, 2, "ds4-2"),
}
_, errOrgs := x.Insert(orgs)
require.NoError(t, errOrgs)
_, errDataSourcess := x.Insert(dataSources)
require.NoError(t, errDataSourcess)
if len(legacyChannels) > 0 {
_, channelErr := x.Insert(legacyChannels)
require.NoError(t, channelErr)
}
if len(alerts) > 0 {
_, alertErr := x.Insert(alerts)
require.NoError(t, alertErr)
}
}
// getAlertmanagerConfig retreives the Alertmanager Config from the database for a given orgId.
func getAlertmanagerConfig(t *testing.T, x *xorm.Engine, orgId int64) *apimodels.PostableUserConfig {
amConfig := ""
_, err := x.Table("alert_configuration").Where("org_id = ?", orgId).Cols("alertmanager_configuration").Get(&amConfig)
require.NoError(t, err)
config := apimodels.PostableUserConfig{}
err = json.Unmarshal([]byte(amConfig), &config)
require.NoError(t, err)
return &config
}
// getAlertmanagerConfig retreives the Alertmanager Config from the database for a given orgId.
func getAlertRules(t *testing.T, x *xorm.Engine, orgId int64) []*ngModels.AlertRule {
rules := make([]*ngModels.AlertRule, 0)
err := x.Table("alert_rule").Where("org_id = ?", orgId).Find(&rules)
require.NoError(t, err)
return rules
}
// getDashboard retrieves a dashboard from the database for a given org, uid.
func getDashboard(t *testing.T, x *xorm.Engine, orgId int64, uid string) *dashboards.Dashboard {
dashes := make([]*dashboards.Dashboard, 0)
err := x.Table("dashboard").Where("org_id = ? AND uid = ?", orgId, uid).Find(&dashes)
require.NoError(t, err)
if len(dashes) > 1 {
t.Error("Expected only one dashboard to be returned")
}
if len(dashes) == 0 {
return nil
}
return dashes[0]
}
func pointer[T any](b T) *T {
return &b
}