From aa03b8f8a7e07054a1747216778712e303520970 Mon Sep 17 00:00:00 2001
From: Matthew Jacobson
Date: Fri, 5 Jan 2024 18:19:12 -0500
Subject: [PATCH] Alerting: Guided legacy alerting upgrade dry-run (#80071)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR has two steps that together create a functional dry-run capability for the migration.
By enabling the feature flag alertingPreviewUpgrade when on legacy alerting it will:
a. Allow all Grafana Alerting background services except for the scheduler to start (multiorg alertmanager, state manager, routes, …).
b. Allow the UI to show Grafana Alerting pages alongside legacy ones (with appropriate in-app warnings that UA is not actually running).
c. Show a new “Alerting Upgrade” page and register associated /api/v1/upgrade endpoints that will allow the user to upgrade their organization live without restart and present a summary of the upgrade in a table.
---
pkg/services/navtree/navtreeimpl/applinks.go | 15 +-
pkg/services/navtree/navtreeimpl/navtree.go | 13 +
pkg/services/ngalert/api/api.go | 11 +
pkg/services/ngalert/api/api_upgrade.go | 140 ++
pkg/services/ngalert/api/authorization.go | 20 +-
.../ngalert/api/authorization_test.go | 2 +-
.../ngalert/api/generated_base_api_upgrade.go | 163 ++
pkg/services/ngalert/api/tooling/api.json | 184 +-
.../api/tooling/definitions/upgrade.go | 189 ++
pkg/services/ngalert/api/tooling/post.json | 404 +++-
pkg/services/ngalert/api/tooling/spec.json | 404 +++-
pkg/services/ngalert/api/upgrade.go | 48 +
pkg/services/ngalert/limits.go | 12 +-
.../ngalert/migration/migration_test.go | 109 +-
.../ngalert/migration/models/alertmanager.go | 140 +-
pkg/services/ngalert/migration/permissions.go | 2 +-
pkg/services/ngalert/migration/persist.go | 401 +++-
pkg/services/ngalert/migration/service.go | 581 +++++-
.../ngalert/migration/service_test.go | 1761 +++++++++++++++++
.../ngalert/migration/store/database.go | 258 ++-
.../ngalert/migration/store/slim_models.go | 26 +
pkg/services/ngalert/migration/store/state.go | 29 +-
pkg/services/ngalert/migration/testing.go | 41 +
pkg/services/ngalert/ngalert.go | 15 +-
pkg/setting/setting_quota.go | 10 +-
public/api-merged.json | 164 +-
.../app/core/utils/navBarItem-translations.ts | 7 +
public/app/features/alerting/Upgrade.tsx | 1661 ++++++++++++++++
.../alerting/components/CollapsibleAlert.tsx | 73 +
.../alerting/components/DeprecationNotice.tsx | 2 +-
.../alerting/components/UAPreviewNotice.tsx | 33 +
public/app/features/alerting/routes.tsx | 12 +
.../alerting/unified/api/alertingApi.ts | 2 +-
.../alerting/unified/api/upgradeApi.ts | 444 +++++
.../components/AlertingPageWrapper.tsx | 8 +-
public/locales/de-DE/grafana.json | 4 +
public/locales/en-US/grafana.json | 4 +
public/locales/es-ES/grafana.json | 4 +
public/locales/fr-FR/grafana.json | 4 +
public/locales/pseudo-LOCALE/grafana.json | 4 +
public/locales/zh-Hans/grafana.json | 4 +
public/openapi3.json | 164 +-
42 files changed, 7403 insertions(+), 169 deletions(-)
create mode 100644 pkg/services/ngalert/api/api_upgrade.go
create mode 100644 pkg/services/ngalert/api/generated_base_api_upgrade.go
create mode 100644 pkg/services/ngalert/api/tooling/definitions/upgrade.go
create mode 100644 pkg/services/ngalert/api/upgrade.go
create mode 100644 pkg/services/ngalert/migration/store/slim_models.go
create mode 100644 public/app/features/alerting/Upgrade.tsx
create mode 100644 public/app/features/alerting/components/CollapsibleAlert.tsx
create mode 100644 public/app/features/alerting/components/UAPreviewNotice.tsx
create mode 100644 public/app/features/alerting/unified/api/upgradeApi.ts
diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go
index 3244a41271d..8d0f372c691 100644
--- a/pkg/services/navtree/navtreeimpl/applinks.go
+++ b/pkg/services/navtree/navtreeimpl/applinks.go
@@ -168,10 +168,17 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode
func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *navtree.NavTreeRoot, plugin pluginstore.Plugin, appLink *navtree.NavLink) {
// Handle moving apps into specific navtree sections
+ var alertingNodes []*navtree.NavLink
alertingNode := treeRoot.FindById(navtree.NavIDAlerting)
- if alertingNode == nil {
- // Search for legacy alerting node just in case
- alertingNode = treeRoot.FindById(navtree.NavIDAlertingLegacy)
+ if alertingNode != nil {
+ alertingNodes = append(alertingNodes, alertingNode)
+ }
+ if len(alertingNodes) == 0 || s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingPreviewUpgrade) {
+ // If FlagAlertingPreviewUpgrade is enabled, we want to add both the legacy and new alerting nodes.
+ alertingNode := treeRoot.FindById(navtree.NavIDAlertingLegacy)
+ if alertingNode != nil {
+ alertingNodes = append(alertingNodes, alertingNode)
+ }
}
sectionID := navtree.NavIDApps
@@ -235,7 +242,7 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n
})
case navtree.NavIDAlertsAndIncidents:
alertsAndIncidentsChildren := []*navtree.NavLink{}
- if alertingNode != nil {
+ for _, alertingNode := range alertingNodes {
alertsAndIncidentsChildren = append(alertsAndIncidentsChildren, alertingNode)
treeRoot.RemoveSection(alertingNode)
}
diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go
index 7145e026cac..5e1201f7fe2 100644
--- a/pkg/services/navtree/navtreeimpl/navtree.go
+++ b/pkg/services/navtree/navtreeimpl/navtree.go
@@ -138,6 +138,11 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
if legacyAlertSection := s.buildLegacyAlertNavLinks(c); legacyAlertSection != nil {
treeRoot.AddSection(legacyAlertSection)
}
+ if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingPreviewUpgrade) && !uaIsDisabledForOrg {
+ if alertingSection := s.buildAlertNavLinks(c); alertingSection != nil {
+ treeRoot.AddSection(alertingSection)
+ }
+ }
} else if uaVisibleForOrg {
if alertingSection := s.buildAlertNavLinks(c); alertingSection != nil {
treeRoot.AddSection(alertingSection)
@@ -404,6 +409,14 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *contextmodel.ReqContext) *navt
})
}
+ if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingPreviewUpgrade) && c.HasRole(org.RoleAdmin) {
+ alertChildNavs = append(alertChildNavs, &navtree.NavLink{
+ Text: "Upgrade Alerting", Id: "alerting-upgrade", Url: s.cfg.AppSubURL + "/alerting-legacy/upgrade",
+ SubTitle: "Upgrade your existing legacy alerts and notification channels to the new Grafana Alerting",
+ Icon: "cog",
+ })
+ }
+
var alertNav = navtree.NavLink{
Text: "Alerting",
SubTitle: "Learn about problems in your systems moments after they occur",
diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go
index 0d54b6234d0..dcb8aec5e48 100644
--- a/pkg/services/ngalert/api/api.go
+++ b/pkg/services/ngalert/api/api.go
@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/backtesting"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
+ "github.com/grafana/grafana/pkg/services/ngalert/migration"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
@@ -73,6 +74,7 @@ type API struct {
Historian Historian
Tracer tracing.Tracer
AppUrl *url.URL
+ UpgradeService migration.UpgradeService
// Hooks can be used to replace API handlers for specific paths.
Hooks *Hooks
@@ -149,4 +151,13 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
logger: logger,
hist: api.Historian,
}), m)
+
+ // Inject upgrade endpoints if legacy alerting is enabled and the feature flag is enabled.
+ if !api.Cfg.UnifiedAlerting.IsEnabled() && api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingPreviewUpgrade) {
+ api.RegisterUpgradeApiEndpoints(NewUpgradeApi(NewUpgradeSrc(
+ logger,
+ api.UpgradeService,
+ api.Cfg,
+ )), m)
+ }
}
diff --git a/pkg/services/ngalert/api/api_upgrade.go b/pkg/services/ngalert/api/api_upgrade.go
new file mode 100644
index 00000000000..c7f4c4120ac
--- /dev/null
+++ b/pkg/services/ngalert/api/api_upgrade.go
@@ -0,0 +1,140 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/infra/log"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/services/ngalert/migration"
+ "github.com/grafana/grafana/pkg/setting"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+type UpgradeSrv struct {
+ log log.Logger
+ upgradeService migration.UpgradeService
+ cfg *setting.Cfg
+}
+
+func NewUpgradeSrc(
+ log log.Logger,
+ upgradeService migration.UpgradeService,
+ cfg *setting.Cfg,
+) *UpgradeSrv {
+ return &UpgradeSrv{
+ log: log,
+ upgradeService: upgradeService,
+ cfg: cfg,
+ }
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeOrg(c *contextmodel.ReqContext) response.Response {
+ summary, err := srv.upgradeService.MigrateOrg(c.Req.Context(), c.OrgID, c.QueryBool("skipExisting"))
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
+
+func (srv *UpgradeSrv) RouteGetOrgUpgrade(c *contextmodel.ReqContext) response.Response {
+ state, err := srv.upgradeService.GetOrgMigrationState(c.Req.Context(), c.OrgID)
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, state)
+}
+
+func (srv *UpgradeSrv) RouteDeleteOrgUpgrade(c *contextmodel.ReqContext) response.Response {
+ err := srv.upgradeService.RevertOrg(c.Req.Context(), c.OrgID)
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, util.DynMap{"message": "Grafana Alerting resources deleted for this organization."})
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeAlert(c *contextmodel.ReqContext, dashboardIdParam string, panelIdParam string) response.Response {
+ dashboardId, err := strconv.ParseInt(dashboardIdParam, 10, 64)
+ if err != nil {
+ return ErrResp(http.StatusBadRequest, err, "failed to parse dashboardId")
+ }
+
+ panelId, err := strconv.ParseInt(panelIdParam, 10, 64)
+ if err != nil {
+ return ErrResp(http.StatusBadRequest, err, "failed to parse panelId")
+ }
+
+ summary, err := srv.upgradeService.MigrateAlert(c.Req.Context(), c.OrgID, dashboardId, panelId)
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeDashboard(c *contextmodel.ReqContext, dashboardIdParam string) response.Response {
+ dashboardId, err := strconv.ParseInt(dashboardIdParam, 10, 64)
+ if err != nil {
+ return ErrResp(http.StatusBadRequest, err, "failed to parse dashboardId")
+ }
+
+ summary, err := srv.upgradeService.MigrateDashboardAlerts(c.Req.Context(), c.OrgID, dashboardId, c.QueryBool("skipExisting"))
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeAllDashboards(c *contextmodel.ReqContext) response.Response {
+ summary, err := srv.upgradeService.MigrateAllDashboardAlerts(c.Req.Context(), c.OrgID, c.QueryBool("skipExisting"))
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeChannel(c *contextmodel.ReqContext, channelIdParam string) response.Response {
+ channelId, err := strconv.ParseInt(channelIdParam, 10, 64)
+ if err != nil {
+ return ErrResp(http.StatusBadRequest, err, "failed to parse channelId")
+ }
+
+ summary, err := srv.upgradeService.MigrateChannel(c.Req.Context(), c.OrgID, channelId)
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
+
+func (srv *UpgradeSrv) RoutePostUpgradeAllChannels(c *contextmodel.ReqContext) response.Response {
+ summary, err := srv.upgradeService.MigrateAllChannels(c.Req.Context(), c.OrgID, c.QueryBool("skipExisting"))
+ if err != nil {
+ if errors.Is(err, migration.ErrUpgradeInProgress) {
+ return response.Error(http.StatusConflict, "Upgrade already in progress", err)
+ }
+ return response.Error(http.StatusInternalServerError, "Server error", err)
+ }
+ return response.JSON(http.StatusOK, summary)
+}
diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go
index 92a131721e5..844153768f2 100644
--- a/pkg/services/ngalert/api/authorization.go
+++ b/pkg/services/ngalert/api/authorization.go
@@ -43,10 +43,28 @@ func (api *API) authorize(method, path string) web.Handler {
ac.EvalPermission(ac.ActionAlertingRuleCreate, scope),
ac.EvalPermission(ac.ActionAlertingRuleDelete, scope),
)
- // Grafana rule state history paths
+ // Grafana rule state history paths
case http.MethodGet + "/api/v1/rules/history":
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
+ // Grafana unified alerting upgrade paths
+ case http.MethodGet + "/api/v1/upgrade/org":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/org":
+ return middleware.ReqOrgAdmin
+ case http.MethodDelete + "/api/v1/upgrade/org":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/dashboards":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/dashboards/{DashboardID}":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/channels":
+ return middleware.ReqOrgAdmin
+ case http.MethodPost + "/api/v1/upgrade/channels/{ChannelID}":
+ return middleware.ReqOrgAdmin
+
// Grafana, Prometheus-compatible Paths
case http.MethodGet + "/api/prometheus/grafana/api/v1/rules":
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go
index 2502ea3e092..8b63894eba4 100644
--- a/pkg/services/ngalert/api/authorization_test.go
+++ b/pkg/services/ngalert/api/authorization_test.go
@@ -40,7 +40,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
- require.Len(t, paths, 54)
+ require.Len(t, paths, 60)
ac := acmock.New()
api := &API{AccessControl: ac}
diff --git a/pkg/services/ngalert/api/generated_base_api_upgrade.go b/pkg/services/ngalert/api/generated_base_api_upgrade.go
new file mode 100644
index 00000000000..5557f043185
--- /dev/null
+++ b/pkg/services/ngalert/api/generated_base_api_upgrade.go
@@ -0,0 +1,163 @@
+/*Package api contains base API implementation of unified alerting
+ *
+ *Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git)
+ *
+ *Do not manually edit these files, please find ngalert/api/swagger-codegen/ for commands on how to generate them.
+ */
+package api
+
+import (
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/middleware"
+ "github.com/grafana/grafana/pkg/middleware/requestmeta"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+ "github.com/grafana/grafana/pkg/services/ngalert/metrics"
+ "github.com/grafana/grafana/pkg/web"
+)
+
+type UpgradeApi interface {
+ RouteDeleteOrgUpgrade(*contextmodel.ReqContext) response.Response
+ RouteGetOrgUpgrade(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeAlert(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeAllChannels(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeAllDashboards(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeChannel(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeDashboard(*contextmodel.ReqContext) response.Response
+ RoutePostUpgradeOrg(*contextmodel.ReqContext) response.Response
+}
+
+func (f *UpgradeApiHandler) RouteDeleteOrgUpgrade(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRouteDeleteOrgUpgrade(ctx)
+}
+func (f *UpgradeApiHandler) RouteGetOrgUpgrade(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRouteGetOrgUpgrade(ctx)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeAlert(ctx *contextmodel.ReqContext) response.Response {
+ // Parse Path Parameters
+ dashboardIDParam := web.Params(ctx.Req)[":DashboardID"]
+ panelIDParam := web.Params(ctx.Req)[":PanelID"]
+ return f.handleRoutePostUpgradeAlert(ctx, dashboardIDParam, panelIDParam)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeAllChannels(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRoutePostUpgradeAllChannels(ctx)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeAllDashboards(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRoutePostUpgradeAllDashboards(ctx)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeChannel(ctx *contextmodel.ReqContext) response.Response {
+ // Parse Path Parameters
+ channelIDParam := web.Params(ctx.Req)[":ChannelID"]
+ return f.handleRoutePostUpgradeChannel(ctx, channelIDParam)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeDashboard(ctx *contextmodel.ReqContext) response.Response {
+ // Parse Path Parameters
+ dashboardIDParam := web.Params(ctx.Req)[":DashboardID"]
+ return f.handleRoutePostUpgradeDashboard(ctx, dashboardIDParam)
+}
+func (f *UpgradeApiHandler) RoutePostUpgradeOrg(ctx *contextmodel.ReqContext) response.Response {
+ return f.handleRoutePostUpgradeOrg(ctx)
+}
+
+func (api *API) RegisterUpgradeApiEndpoints(srv UpgradeApi, m *metrics.API) {
+ api.RouteRegister.Group("", func(group routing.RouteRegister) {
+ group.Delete(
+ toMacaronPath("/api/v1/upgrade/org"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodDelete, "/api/v1/upgrade/org"),
+ metrics.Instrument(
+ http.MethodDelete,
+ "/api/v1/upgrade/org",
+ api.Hooks.Wrap(srv.RouteDeleteOrgUpgrade),
+ m,
+ ),
+ )
+ group.Get(
+ toMacaronPath("/api/v1/upgrade/org"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodGet, "/api/v1/upgrade/org"),
+ metrics.Instrument(
+ http.MethodGet,
+ "/api/v1/upgrade/org",
+ api.Hooks.Wrap(srv.RouteGetOrgUpgrade),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}",
+ api.Hooks.Wrap(srv.RoutePostUpgradeAlert),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/channels"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/channels"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/channels",
+ api.Hooks.Wrap(srv.RoutePostUpgradeAllChannels),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/dashboards"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/dashboards"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/dashboards",
+ api.Hooks.Wrap(srv.RoutePostUpgradeAllDashboards),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/channels/{ChannelID}"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/channels/{ChannelID}"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/channels/{ChannelID}",
+ api.Hooks.Wrap(srv.RoutePostUpgradeChannel),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/dashboards/{DashboardID}"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/dashboards/{DashboardID}"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/dashboards/{DashboardID}",
+ api.Hooks.Wrap(srv.RoutePostUpgradeDashboard),
+ m,
+ ),
+ )
+ group.Post(
+ toMacaronPath("/api/v1/upgrade/org"),
+ requestmeta.SetOwner(requestmeta.TeamAlerting),
+ requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
+ api.authorize(http.MethodPost, "/api/v1/upgrade/org"),
+ metrics.Instrument(
+ http.MethodPost,
+ "/api/v1/upgrade/org",
+ api.Hooks.Wrap(srv.RoutePostUpgradeOrg),
+ m,
+ ),
+ )
+ }, middleware.ReqSignedIn)
+}
diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json
index 1242b4f33cb..fe8f94aa6d4 100644
--- a/pkg/services/ngalert/api/tooling/api.json
+++ b/pkg/services/ngalert/api/tooling/api.json
@@ -96,6 +96,20 @@
"title": "AlertManagersResult contains the result from querying the alertmanagers endpoint.",
"type": "object"
},
+ "AlertPair": {
+ "properties": {
+ "alertRule": {
+ "$ref": "#/definitions/AlertRuleUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyAlert": {
+ "$ref": "#/definitions/LegacyAlert"
+ }
+ },
+ "type": "object"
+ },
"AlertQuery": {
"properties": {
"datasourceUid": {
@@ -280,6 +294,23 @@
},
"type": "object"
},
+ "AlertRuleUpgrade": {
+ "properties": {
+ "sendsTo": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "title": {
+ "type": "string"
+ },
+ "uid": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"AlertingFileExport": {
"properties": {
"apiVersion": {
@@ -561,6 +592,20 @@
"title": "Config is the top-level configuration for Alertmanager's config files.",
"type": "object"
},
+ "ContactPair": {
+ "properties": {
+ "contactPoint": {
+ "$ref": "#/definitions/ContactPointUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyChannel": {
+ "$ref": "#/definitions/LegacyChannel"
+ }
+ },
+ "type": "object"
+ },
"ContactPointExport": {
"properties": {
"name": {
@@ -580,6 +625,20 @@
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
"type": "object"
},
+ "ContactPointUpgrade": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "routeMatchers": {
+ "$ref": "#/definitions/ObjectMatchers"
+ },
+ "type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"ContactPoints": {
"items": {
"$ref": "#/definitions/EmbeddedContactPoint"
@@ -592,6 +651,48 @@
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
+ "DashboardUpgrade": {
+ "properties": {
+ "dashboardId": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "dashboardName": {
+ "type": "string"
+ },
+ "dashboardUid": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ },
+ "folderName": {
+ "type": "string"
+ },
+ "folderUid": {
+ "type": "string"
+ },
+ "migratedAlerts": {
+ "items": {
+ "$ref": "#/definitions/AlertPair"
+ },
+ "type": "array"
+ },
+ "newFolderName": {
+ "type": "string"
+ },
+ "newFolderUid": {
+ "type": "string"
+ },
+ "provisioned": {
+ "type": "boolean"
+ },
+ "warning": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"DataLink": {
"description": "DataLink define what",
"properties": {
@@ -1833,6 +1934,41 @@
},
"type": "array"
},
+ "LegacyAlert": {
+ "properties": {
+ "dashboardId": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "id": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "panelId": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "LegacyChannel": {
+ "properties": {
+ "id": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"LinkTransformationConfig": {
"properties": {
"expression": {
@@ -2209,6 +2345,50 @@
},
"type": "object"
},
+ "OrgMigrationState": {
+ "properties": {
+ "migratedChannels": {
+ "items": {
+ "$ref": "#/definitions/ContactPair"
+ },
+ "type": "array"
+ },
+ "migratedDashboards": {
+ "items": {
+ "$ref": "#/definitions/DashboardUpgrade"
+ },
+ "type": "array"
+ },
+ "orgId": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "OrgMigrationSummary": {
+ "properties": {
+ "hasErrors": {
+ "type": "boolean"
+ },
+ "newAlerts": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "newChannels": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "newDashboards": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "removed": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"PagerdutyConfig": {
"properties": {
"class": {
@@ -4248,6 +4428,7 @@
"type": "object"
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@@ -4468,6 +4649,7 @@
"type": "array"
},
"integration": {
+ "description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@@ -4611,7 +4793,6 @@
"type": "array"
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4649,7 +4830,6 @@
"type": "object"
},
"receiver": {
- "description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
diff --git a/pkg/services/ngalert/api/tooling/definitions/upgrade.go b/pkg/services/ngalert/api/tooling/definitions/upgrade.go
new file mode 100644
index 00000000000..de89cb6e4bb
--- /dev/null
+++ b/pkg/services/ngalert/api/tooling/definitions/upgrade.go
@@ -0,0 +1,189 @@
+package definitions
+
+// swagger:route GET /api/v1/upgrade/org upgrade RouteGetOrgUpgrade
+//
+// Get existing alerting upgrade for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationState
+
+// swagger:route POST /api/v1/upgrade/org upgrade RoutePostUpgradeOrg
+//
+// Upgrade all legacy alerts for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:route DELETE /api/v1/upgrade/org upgrade RouteDeleteOrgUpgrade
+//
+// Delete existing alerting upgrade for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: Ack
+
+// swagger:route POST /api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID} upgrade RoutePostUpgradeAlert
+//
+// Upgrade single legacy dashboard alert for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:route POST /api/v1/upgrade/dashboards/{DashboardID} upgrade RoutePostUpgradeDashboard
+//
+// Upgrade all legacy dashboard alerts on a dashboard for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:route POST /api/v1/upgrade/dashboards upgrade RoutePostUpgradeAllDashboards
+//
+// Upgrade all legacy dashboard alerts for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:route POST /api/v1/upgrade/channels upgrade RoutePostUpgradeAllChannels
+//
+// Upgrade all legacy notification channels for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:route POST /api/v1/upgrade/channels/{ChannelID} upgrade RoutePostUpgradeChannel
+//
+// Upgrade a single legacy notification channel for the current organization.
+//
+// Produces:
+// - application/json
+//
+// Responses:
+// 200: OrgMigrationSummary
+
+// swagger:parameters RoutePostUpgradeOrg RoutePostUpgradeDashboard RoutePostUpgradeAllChannels
+type SkipExistingQueryParam struct {
+ // If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.
+ // in:query
+ // required:false
+ // default:false
+ SkipExisting bool
+}
+
+// swagger:parameters RoutePostUpgradeAlert RoutePostUpgradeDashboard
+type DashboardParam struct {
+ // Dashboard ID of dashboard alert.
+ // in:path
+ // required:true
+ DashboardID string
+}
+
+// swagger:parameters RoutePostUpgradeAlert
+type PanelParam struct {
+ // Panel ID of dashboard alert.
+ // in:path
+ // required:true
+ PanelID string
+}
+
+// swagger:parameters RoutePostUpgradeChannel
+type ChannelParam struct {
+ // Channel ID of legacy notification channel.
+ // in:path
+ // required:true
+ ChannelID string
+}
+
+// swagger:model
+type OrgMigrationSummary struct {
+ NewDashboards int `json:"newDashboards"`
+ NewAlerts int `json:"newAlerts"`
+ NewChannels int `json:"newChannels"`
+ Removed bool `json:"removed"`
+ HasErrors bool `json:"hasErrors"`
+}
+
+func (s *OrgMigrationSummary) Add(other OrgMigrationSummary) {
+ s.NewDashboards += other.NewDashboards
+ s.NewAlerts += other.NewAlerts
+ s.NewChannels += other.NewChannels
+ s.Removed = s.Removed || other.Removed
+ s.HasErrors = s.HasErrors || other.HasErrors
+}
+
+// swagger:model
+type OrgMigrationState struct {
+ OrgID int64 `json:"orgId"`
+ MigratedDashboards []*DashboardUpgrade `json:"migratedDashboards"`
+ MigratedChannels []*ContactPair `json:"migratedChannels"`
+}
+
+type DashboardUpgrade struct {
+ MigratedAlerts []*AlertPair `json:"migratedAlerts"`
+ DashboardID int64 `json:"dashboardId"`
+ DashboardUID string `json:"dashboardUid"`
+ DashboardName string `json:"dashboardName"`
+ FolderUID string `json:"folderUid"`
+ FolderName string `json:"folderName"`
+ NewFolderUID string `json:"newFolderUid,omitempty"`
+ NewFolderName string `json:"newFolderName,omitempty"`
+ Provisioned bool `json:"provisioned"`
+ Warning string `json:"warning"`
+ Error string `json:"error,omitempty"`
+}
+
+type AlertPair struct {
+ LegacyAlert *LegacyAlert `json:"legacyAlert"`
+ AlertRule *AlertRuleUpgrade `json:"alertRule"`
+ Error string `json:"error,omitempty"`
+}
+
+type ContactPair struct {
+ LegacyChannel *LegacyChannel `json:"legacyChannel"`
+ ContactPointUpgrade *ContactPointUpgrade `json:"contactPoint"`
+ Error string `json:"error,omitempty"`
+}
+
+type LegacyAlert struct {
+ ID int64 `json:"id"`
+ DashboardID int64 `json:"dashboardId"`
+ PanelID int64 `json:"panelId"`
+ Name string `json:"name"`
+}
+
+type AlertRuleUpgrade struct {
+ UID string `json:"uid"`
+ Title string `json:"title"`
+ SendsTo []string `json:"sendsTo"`
+}
+
+type LegacyChannel struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+type ContactPointUpgrade struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ RouteMatchers ObjectMatchers `json:"routeMatchers"`
+}
diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json
index fd465f7d7eb..cb11730da23 100644
--- a/pkg/services/ngalert/api/tooling/post.json
+++ b/pkg/services/ngalert/api/tooling/post.json
@@ -96,6 +96,20 @@
"title": "AlertManagersResult contains the result from querying the alertmanagers endpoint.",
"type": "object"
},
+ "AlertPair": {
+ "properties": {
+ "alertRule": {
+ "$ref": "#/definitions/AlertRuleUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyAlert": {
+ "$ref": "#/definitions/LegacyAlert"
+ }
+ },
+ "type": "object"
+ },
"AlertQuery": {
"properties": {
"datasourceUid": {
@@ -280,6 +294,23 @@
},
"type": "object"
},
+ "AlertRuleUpgrade": {
+ "properties": {
+ "sendsTo": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "title": {
+ "type": "string"
+ },
+ "uid": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"AlertingFileExport": {
"properties": {
"apiVersion": {
@@ -561,6 +592,20 @@
"title": "Config is the top-level configuration for Alertmanager's config files.",
"type": "object"
},
+ "ContactPair": {
+ "properties": {
+ "contactPoint": {
+ "$ref": "#/definitions/ContactPointUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyChannel": {
+ "$ref": "#/definitions/LegacyChannel"
+ }
+ },
+ "type": "object"
+ },
"ContactPointExport": {
"properties": {
"name": {
@@ -580,6 +625,20 @@
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
"type": "object"
},
+ "ContactPointUpgrade": {
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "routeMatchers": {
+ "$ref": "#/definitions/ObjectMatchers"
+ },
+ "type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"ContactPoints": {
"items": {
"$ref": "#/definitions/EmbeddedContactPoint"
@@ -592,6 +651,48 @@
"title": "CounterResetHint contains the known information about a counter reset,",
"type": "integer"
},
+ "DashboardUpgrade": {
+ "properties": {
+ "dashboardId": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "dashboardName": {
+ "type": "string"
+ },
+ "dashboardUid": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ },
+ "folderName": {
+ "type": "string"
+ },
+ "folderUid": {
+ "type": "string"
+ },
+ "migratedAlerts": {
+ "items": {
+ "$ref": "#/definitions/AlertPair"
+ },
+ "type": "array"
+ },
+ "newFolderName": {
+ "type": "string"
+ },
+ "newFolderUid": {
+ "type": "string"
+ },
+ "provisioned": {
+ "type": "boolean"
+ },
+ "warning": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"DataLink": {
"description": "DataLink define what",
"properties": {
@@ -1833,6 +1934,41 @@
},
"type": "array"
},
+ "LegacyAlert": {
+ "properties": {
+ "dashboardId": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "id": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "panelId": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "LegacyChannel": {
+ "properties": {
+ "id": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"LinkTransformationConfig": {
"properties": {
"expression": {
@@ -2209,6 +2345,50 @@
},
"type": "object"
},
+ "OrgMigrationState": {
+ "properties": {
+ "migratedChannels": {
+ "items": {
+ "$ref": "#/definitions/ContactPair"
+ },
+ "type": "array"
+ },
+ "migratedDashboards": {
+ "items": {
+ "$ref": "#/definitions/DashboardUpgrade"
+ },
+ "type": "array"
+ },
+ "orgId": {
+ "format": "int64",
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "OrgMigrationSummary": {
+ "properties": {
+ "hasErrors": {
+ "type": "boolean"
+ },
+ "newAlerts": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "newChannels": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "newDashboards": {
+ "format": "int64",
+ "type": "integer"
+ },
+ "removed": {
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"PagerdutyConfig": {
"properties": {
"class": {
@@ -3984,7 +4164,6 @@
"type": "object"
},
"URL": {
- "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4020,7 +4199,7 @@
"$ref": "#/definitions/Userinfo"
}
},
- "title": "A URL represents a parsed URL (technically, a URI reference).",
+ "title": "URL is a custom URL type that allows validation at configuration load time.",
"type": "object"
},
"UpdateRuleGroupResponse": {
@@ -4250,6 +4429,7 @@
"type": "object"
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@@ -4354,6 +4534,7 @@
"type": "object"
},
"gettableAlert": {
+ "description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@@ -4464,12 +4645,14 @@
"type": "object"
},
"gettableSilences": {
+ "description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
},
"integration": {
+ "description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@@ -4613,7 +4796,6 @@
"type": "array"
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",
@@ -4651,6 +4833,7 @@
"type": "object"
},
"receiver": {
+ "description": "Receiver receiver",
"properties": {
"active": {
"description": "active",
@@ -7913,6 +8096,221 @@
"history"
]
}
+ },
+ "/api/v1/upgrade/channels": {
+ "post": {
+ "operationId": "RoutePostUpgradeAllChannels",
+ "parameters": [
+ {
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "in": "query",
+ "name": "SkipExisting",
+ "type": "boolean"
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade all legacy notification channels for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
+ },
+ "/api/v1/upgrade/channels/{ChannelID}": {
+ "post": {
+ "operationId": "RoutePostUpgradeChannel",
+ "parameters": [
+ {
+ "description": "Channel ID of legacy notification channel.",
+ "in": "path",
+ "name": "ChannelID",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade a single legacy notification channel for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
+ },
+ "/api/v1/upgrade/dashboards": {
+ "post": {
+ "operationId": "RoutePostUpgradeAllDashboards",
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade all legacy dashboard alerts for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
+ },
+ "/api/v1/upgrade/dashboards/{DashboardID}": {
+ "post": {
+ "operationId": "RoutePostUpgradeDashboard",
+ "parameters": [
+ {
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "in": "query",
+ "name": "SkipExisting",
+ "type": "boolean"
+ },
+ {
+ "description": "Dashboard ID of dashboard alert.",
+ "in": "path",
+ "name": "DashboardID",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade all legacy dashboard alerts on a dashboard for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
+ },
+ "/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}": {
+ "post": {
+ "operationId": "RoutePostUpgradeAlert",
+ "parameters": [
+ {
+ "description": "Dashboard ID of dashboard alert.",
+ "in": "path",
+ "name": "DashboardID",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "description": "Panel ID of dashboard alert.",
+ "in": "path",
+ "name": "PanelID",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade single legacy dashboard alert for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
+ },
+ "/api/v1/upgrade/org": {
+ "delete": {
+ "operationId": "RouteDeleteOrgUpgrade",
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "Ack",
+ "schema": {
+ "$ref": "#/definitions/Ack"
+ }
+ }
+ },
+ "summary": "Delete existing alerting upgrade for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ },
+ "get": {
+ "operationId": "RouteGetOrgUpgrade",
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationState",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationState"
+ }
+ }
+ },
+ "summary": "Get existing alerting upgrade for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ },
+ "post": {
+ "operationId": "RoutePostUpgradeOrg",
+ "parameters": [
+ {
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "in": "query",
+ "name": "SkipExisting",
+ "type": "boolean"
+ }
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ },
+ "summary": "Upgrade all legacy alerts for the current organization.",
+ "tags": [
+ "upgrade"
+ ]
+ }
}
},
"produces": [
diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json
index f295e6a3397..d1c102a044e 100644
--- a/pkg/services/ngalert/api/tooling/spec.json
+++ b/pkg/services/ngalert/api/tooling/spec.json
@@ -3190,6 +3190,221 @@
}
}
}
+ },
+ "/api/v1/upgrade/channels": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade all legacy notification channels for the current organization.",
+ "operationId": "RoutePostUpgradeAllChannels",
+ "parameters": [
+ {
+ "type": "boolean",
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "name": "SkipExisting",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upgrade/channels/{ChannelID}": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade a single legacy notification channel for the current organization.",
+ "operationId": "RoutePostUpgradeChannel",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Channel ID of legacy notification channel.",
+ "name": "ChannelID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upgrade/dashboards": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade all legacy dashboard alerts for the current organization.",
+ "operationId": "RoutePostUpgradeAllDashboards",
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upgrade/dashboards/{DashboardID}": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade all legacy dashboard alerts on a dashboard for the current organization.",
+ "operationId": "RoutePostUpgradeDashboard",
+ "parameters": [
+ {
+ "type": "boolean",
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "name": "SkipExisting",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Dashboard ID of dashboard alert.",
+ "name": "DashboardID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upgrade/dashboards/{DashboardID}/panels/{PanelID}": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade single legacy dashboard alert for the current organization.",
+ "operationId": "RoutePostUpgradeAlert",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Dashboard ID of dashboard alert.",
+ "name": "DashboardID",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "Panel ID of dashboard alert.",
+ "name": "PanelID",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/upgrade/org": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Get existing alerting upgrade for the current organization.",
+ "operationId": "RouteGetOrgUpgrade",
+ "responses": {
+ "200": {
+ "description": "OrgMigrationState",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationState"
+ }
+ }
+ }
+ },
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Upgrade all legacy alerts for the current organization.",
+ "operationId": "RoutePostUpgradeOrg",
+ "parameters": [
+ {
+ "type": "boolean",
+ "default": false,
+ "description": "If true, legacy alert and notification channel upgrades from previous runs will be skipped. Otherwise, they will be replaced.",
+ "name": "SkipExisting",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OrgMigrationSummary",
+ "schema": {
+ "$ref": "#/definitions/OrgMigrationSummary"
+ }
+ }
+ }
+ },
+ "delete": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "upgrade"
+ ],
+ "summary": "Delete existing alerting upgrade for the current organization.",
+ "operationId": "RouteDeleteOrgUpgrade",
+ "responses": {
+ "200": {
+ "description": "Ack",
+ "schema": {
+ "$ref": "#/definitions/Ack"
+ }
+ }
+ }
+ }
}
},
"definitions": {
@@ -3285,6 +3500,20 @@
}
}
},
+ "AlertPair": {
+ "type": "object",
+ "properties": {
+ "alertRule": {
+ "$ref": "#/definitions/AlertRuleUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyAlert": {
+ "$ref": "#/definitions/LegacyAlert"
+ }
+ }
+ },
"AlertQuery": {
"type": "object",
"title": "AlertQuery represents a single query associated with an alert definition.",
@@ -3469,6 +3698,23 @@
}
}
},
+ "AlertRuleUpgrade": {
+ "type": "object",
+ "properties": {
+ "sendsTo": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "string"
+ },
+ "uid": {
+ "type": "string"
+ }
+ }
+ },
"AlertingFileExport": {
"type": "object",
"title": "AlertingFileExport is the full provisioned file export.",
@@ -3750,6 +3996,20 @@
}
}
},
+ "ContactPair": {
+ "type": "object",
+ "properties": {
+ "contactPoint": {
+ "$ref": "#/definitions/ContactPointUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyChannel": {
+ "$ref": "#/definitions/LegacyChannel"
+ }
+ }
+ },
"ContactPointExport": {
"type": "object",
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
@@ -3769,6 +4029,20 @@
}
}
},
+ "ContactPointUpgrade": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "routeMatchers": {
+ "$ref": "#/definitions/ObjectMatchers"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
"ContactPoints": {
"type": "array",
"items": {
@@ -3781,6 +4055,48 @@
"format": "uint8",
"title": "CounterResetHint contains the known information about a counter reset,"
},
+ "DashboardUpgrade": {
+ "type": "object",
+ "properties": {
+ "dashboardId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "dashboardName": {
+ "type": "string"
+ },
+ "dashboardUid": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ },
+ "folderName": {
+ "type": "string"
+ },
+ "folderUid": {
+ "type": "string"
+ },
+ "migratedAlerts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/AlertPair"
+ }
+ },
+ "newFolderName": {
+ "type": "string"
+ },
+ "newFolderUid": {
+ "type": "string"
+ },
+ "provisioned": {
+ "type": "boolean"
+ },
+ "warning": {
+ "type": "string"
+ }
+ }
+ },
"DataLink": {
"description": "DataLink define what",
"type": "object",
@@ -5025,6 +5341,41 @@
"$ref": "#/definitions/Label"
}
},
+ "LegacyAlert": {
+ "type": "object",
+ "properties": {
+ "dashboardId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "panelId": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "LegacyChannel": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
"LinkTransformationConfig": {
"type": "object",
"properties": {
@@ -5402,6 +5753,50 @@
}
}
},
+ "OrgMigrationState": {
+ "type": "object",
+ "properties": {
+ "migratedChannels": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ContactPair"
+ }
+ },
+ "migratedDashboards": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DashboardUpgrade"
+ }
+ },
+ "orgId": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "OrgMigrationSummary": {
+ "type": "object",
+ "properties": {
+ "hasErrors": {
+ "type": "boolean"
+ },
+ "newAlerts": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "newChannels": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "newDashboards": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "removed": {
+ "type": "boolean"
+ }
+ }
+ },
"PagerdutyConfig": {
"type": "object",
"title": "PagerdutyConfig configures notifications via PagerDuty.",
@@ -7177,9 +7572,8 @@
}
},
"URL": {
- "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
- "title": "A URL represents a parsed URL (technically, a URI reference).",
+ "title": "URL is a custom URL type that allows validation at configuration load time.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -7444,6 +7838,7 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@@ -7549,6 +7944,7 @@
}
},
"gettableAlert": {
+ "description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@@ -7662,6 +8058,7 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
+ "description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
@@ -7669,6 +8066,7 @@
"$ref": "#/definitions/gettableSilences"
},
"integration": {
+ "description": "Integration integration",
"type": "object",
"required": [
"name",
@@ -7813,7 +8211,6 @@
}
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -7852,6 +8249,7 @@
"$ref": "#/definitions/postableSilence"
},
"receiver": {
+ "description": "Receiver receiver",
"type": "object",
"required": [
"active",
diff --git a/pkg/services/ngalert/api/upgrade.go b/pkg/services/ngalert/api/upgrade.go
new file mode 100644
index 00000000000..9449df815a2
--- /dev/null
+++ b/pkg/services/ngalert/api/upgrade.go
@@ -0,0 +1,48 @@
+package api
+
+import (
+ "github.com/grafana/grafana/pkg/api/response"
+ contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+)
+
+type UpgradeApiHandler struct {
+ svc *UpgradeSrv
+}
+
+func NewUpgradeApi(svc *UpgradeSrv) *UpgradeApiHandler {
+ return &UpgradeApiHandler{
+ svc: svc,
+ }
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeOrg(ctx *contextmodel.ReqContext) response.Response {
+ return f.svc.RoutePostUpgradeOrg(ctx)
+}
+
+func (f *UpgradeApiHandler) handleRouteGetOrgUpgrade(ctx *contextmodel.ReqContext) response.Response {
+ return f.svc.RouteGetOrgUpgrade(ctx)
+}
+
+func (f *UpgradeApiHandler) handleRouteDeleteOrgUpgrade(ctx *contextmodel.ReqContext) response.Response {
+ return f.svc.RouteDeleteOrgUpgrade(ctx)
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeAlert(ctx *contextmodel.ReqContext, dashboardIdParam string, panelIdParam string) response.Response {
+ return f.svc.RoutePostUpgradeAlert(ctx, dashboardIdParam, panelIdParam)
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeDashboard(ctx *contextmodel.ReqContext, dashboardIdParam string) response.Response {
+ return f.svc.RoutePostUpgradeDashboard(ctx, dashboardIdParam)
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeAllDashboards(ctx *contextmodel.ReqContext) response.Response {
+ return f.svc.RoutePostUpgradeAllDashboards(ctx)
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeChannel(ctx *contextmodel.ReqContext, channelIdParam string) response.Response {
+ return f.svc.RoutePostUpgradeChannel(ctx, channelIdParam)
+}
+
+func (f *UpgradeApiHandler) handleRoutePostUpgradeAllChannels(ctx *contextmodel.ReqContext) response.Response {
+ return f.svc.RoutePostUpgradeAllChannels(ctx)
+}
diff --git a/pkg/services/ngalert/limits.go b/pkg/services/ngalert/limits.go
index ec459d08673..1ba538eb66f 100644
--- a/pkg/services/ngalert/limits.go
+++ b/pkg/services/ngalert/limits.go
@@ -65,14 +65,6 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
return limits, nil
}
- var alertOrgQuota int64
- var alertGlobalQuota int64
-
- if cfg.UnifiedAlerting.IsEnabled() {
- alertOrgQuota = cfg.Quota.Org.AlertRule
- alertGlobalQuota = cfg.Quota.Global.AlertRule
- }
-
globalQuotaTag, err := quota.NewTag(models.QuotaTargetSrv, models.QuotaTarget, quota.GlobalScope)
if err != nil {
return limits, err
@@ -82,7 +74,7 @@ func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) {
return limits, err
}
- limits.Set(globalQuotaTag, alertGlobalQuota)
- limits.Set(orgQuotaTag, alertOrgQuota)
+ limits.Set(globalQuotaTag, cfg.Quota.Global.AlertRule)
+ limits.Set(orgQuotaTag, cfg.Quota.Org.AlertRule)
return limits, nil
}
diff --git a/pkg/services/ngalert/migration/migration_test.go b/pkg/services/ngalert/migration/migration_test.go
index bee2082311c..68f60863584 100644
--- a/pkg/services/ngalert/migration/migration_test.go
+++ b/pkg/services/ngalert/migration/migration_test.go
@@ -751,66 +751,67 @@ func TestDashAlertMigration(t *testing.T) {
})
}
+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{})
- newQueryModel := `{"datasource":{"type":"prometheus","uid":"gdev-prometheus"},"expr":"up{job=\"fake-data-gen\"}","instant":false,"interval":"%s","intervalMs":%d,"maxDataPoints":1500,"refId":"%s"}`
- createAlertQueryWithModel := func(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),
- }
- }
-
- createAlertQuery := func(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))
- }
-
- createClassicConditionQuery := func(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
- }
-
- cond := func(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},
- }
- }
-
genAlert := func(mutators ...ngModels.AlertRuleMutator) *ngModels.AlertRule {
rule := &ngModels.AlertRule{
ID: 1,
diff --git a/pkg/services/ngalert/migration/models/alertmanager.go b/pkg/services/ngalert/migration/models/alertmanager.go
index 5fcac6ee1e7..a3e989cb756 100644
--- a/pkg/services/ngalert/migration/models/alertmanager.go
+++ b/pkg/services/ngalert/migration/models/alertmanager.go
@@ -1,7 +1,10 @@
package models
import (
+ "strings"
+
"github.com/prometheus/alertmanager/config"
+ "github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
@@ -11,17 +14,51 @@ import (
// Alertmanager is a helper struct for creating migrated alertmanager configs.
type Alertmanager struct {
- Config *apiModels.PostableUserConfig
- legacyRoute *apiModels.Route
+ config *apiModels.PostableUserConfig
+ legacyRoute *apiModels.Route
+ legacyReceiverToRoute map[string]*apiModels.Route
+ legacyUIDToReceiver map[string]*apiModels.PostableGrafanaReceiver
+ matcherRoute *dispatch.Route
}
-// NewAlertmanager creates a new Alertmanager.
-func NewAlertmanager() *Alertmanager {
- c, r := createBaseConfig()
- return &Alertmanager{
- Config: c,
- legacyRoute: r,
+// FromPostableUserConfig creates an Alertmanager from a PostableUserConfig.
+func FromPostableUserConfig(config *apiModels.PostableUserConfig) *Alertmanager {
+ if config == nil {
+ // No existing amConfig created from a previous migration.
+ config = createBaseConfig()
}
+ if config.AlertmanagerConfig.Route == nil {
+ // No existing base route created from a previous migration.
+ config.AlertmanagerConfig.Route = createDefaultRoute()
+ }
+ am := &Alertmanager{
+ config: config,
+ legacyRoute: getOrCreateNestedLegacyRoute(config),
+ legacyReceiverToRoute: make(map[string]*apiModels.Route),
+ legacyUIDToReceiver: config.GetGrafanaReceiverMap(),
+ matcherRoute: dispatch.NewRoute(config.AlertmanagerConfig.Route.AsAMRoute(), nil),
+ }
+
+ for _, r := range am.legacyRoute.Routes {
+ am.legacyReceiverToRoute[r.Receiver] = r
+ }
+ return am
+}
+
+// CleanAlertmanager removes the nested legacy route from the base PostableUserConfig if it's empty.
+func CleanAlertmanager(am *Alertmanager) *apiModels.PostableUserConfig {
+ for i, r := range am.config.AlertmanagerConfig.Route.Routes {
+ if isNestedLegacyRoute(r) && len(r.Routes) == 0 {
+ // Remove empty nested route.
+ am.config.AlertmanagerConfig.Route.Routes = append(am.config.AlertmanagerConfig.Route.Routes[:i], am.config.AlertmanagerConfig.Route.Routes[i+1:]...)
+ return am.config
+ }
+ }
+ return am.config
+}
+
+func (am *Alertmanager) Match(lset model.LabelSet) []*dispatch.Route {
+ return dispatch.NewRoute(am.config.AlertmanagerConfig.Route.AsAMRoute(), nil).Match(lset)
}
// AddRoute adds a route to the alertmanager config.
@@ -29,7 +66,9 @@ func (am *Alertmanager) AddRoute(route *apiModels.Route) {
if route == nil {
return
}
+ am.legacyReceiverToRoute[route.Receiver] = route
am.legacyRoute.Routes = append(am.legacyRoute.Routes, route)
+ am.matcherRoute = dispatch.NewRoute(am.config.AlertmanagerConfig.Route.AsAMRoute(), nil)
}
// AddReceiver adds a receiver to the alertmanager config.
@@ -37,7 +76,7 @@ func (am *Alertmanager) AddReceiver(recv *apiModels.PostableGrafanaReceiver) {
if recv == nil {
return
}
- am.Config.AlertmanagerConfig.Receivers = append(am.Config.AlertmanagerConfig.Receivers, &apiModels.PostableApiReceiver{
+ am.config.AlertmanagerConfig.Receivers = append(am.config.AlertmanagerConfig.Receivers, &apiModels.PostableApiReceiver{
Receiver: config.Receiver{
Name: recv.Name, // Channel name is unique within an Org.
},
@@ -45,12 +84,85 @@ func (am *Alertmanager) AddReceiver(recv *apiModels.PostableGrafanaReceiver) {
GrafanaManagedReceivers: []*apiModels.PostableGrafanaReceiver{recv},
},
})
+ am.legacyUIDToReceiver[recv.UID] = recv
+}
+
+// RemoveContactPointsAndRoutes removes all receivers and routes given legacy channel name.
+func (am *Alertmanager) RemoveContactPointsAndRoutes(uid string) {
+ if recv, ok := am.legacyUIDToReceiver[uid]; ok {
+ for i, r := range am.config.AlertmanagerConfig.Receivers {
+ if r.Name == recv.Name {
+ am.config.AlertmanagerConfig.Receivers = append(am.config.AlertmanagerConfig.Receivers[:i], am.config.AlertmanagerConfig.Receivers[i+1:]...)
+ }
+ }
+
+ // Don't keep receiver and remove all nested routes that reference it.
+ // This will fail validation if the user has created other routes that reference this receiver.
+ // In that case, they must manually delete the added routes.
+ am.RemoveRoutes(recv.Name)
+ }
+}
+
+// RemoveRoutes legacy routes that send to the given receiver.
+func (am *Alertmanager) RemoveRoutes(recv string) {
+ var keptRoutes []*apiModels.Route
+ for i, route := range am.legacyRoute.Routes {
+ if route.Receiver != recv {
+ keptRoutes = append(keptRoutes, am.legacyRoute.Routes[i])
+ }
+ }
+ delete(am.legacyReceiverToRoute, recv)
+ am.legacyRoute.Routes = keptRoutes
+}
+
+// GetLegacyRoute retrieves the legacy route for a given migrated channel UID.
+func (am *Alertmanager) GetLegacyRoute(recv string) (*apiModels.Route, bool) {
+ route, ok := am.legacyReceiverToRoute[recv]
+ return route, ok
+}
+
+// GetReceiver retrieves the receiver for a given UID.
+func (am *Alertmanager) GetReceiver(uid string) (*apiModels.PostableGrafanaReceiver, bool) {
+ recv, ok := am.legacyUIDToReceiver[uid]
+ return recv, ok
+}
+
+// GetContactLabel retrieves the label used to route for a given UID.
+func (am *Alertmanager) GetContactLabel(uid string) string {
+ if recv, ok := am.GetReceiver(uid); ok {
+ if route, ok := am.GetLegacyRoute(recv.Name); ok {
+ for _, match := range route.ObjectMatchers {
+ if match.Type == labels.MatchEqual && strings.HasPrefix(match.Name, ngmodels.MigratedContactLabelPrefix) {
+ return match.Name
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// getOrCreateNestedLegacyRoute finds or creates the nested route for migrated channels.
+func getOrCreateNestedLegacyRoute(config *apiModels.PostableUserConfig) *apiModels.Route {
+ for _, r := range config.AlertmanagerConfig.Route.Routes {
+ if isNestedLegacyRoute(r) {
+ return r
+ }
+ }
+ nestedLegacyChannelRoute := createNestedLegacyRoute()
+ // Add new nested route as the first of the top-level routes.
+ config.AlertmanagerConfig.Route.Routes = append([]*apiModels.Route{nestedLegacyChannelRoute}, config.AlertmanagerConfig.Route.Routes...)
+ return nestedLegacyChannelRoute
+}
+
+// isNestedLegacyRoute checks whether a route is the nested legacy route for migrated channels.
+func isNestedLegacyRoute(r *apiModels.Route) bool {
+ return len(r.ObjectMatchers) == 1 && r.ObjectMatchers[0].Name == ngmodels.MigratedUseLegacyChannelsLabel
}
// createBaseConfig creates an alertmanager config with the root-level route, default receiver, and nested route
// for migrated channels.
-func createBaseConfig() (*apiModels.PostableUserConfig, *apiModels.Route) {
- defaultRoute, nestedRoute := createDefaultRoute()
+func createBaseConfig() *apiModels.PostableUserConfig {
+ defaultRoute := createDefaultRoute()
return &apiModels.PostableUserConfig{
AlertmanagerConfig: apiModels.PostableApiAlertingConfig{
Receivers: []*apiModels.PostableApiReceiver{
@@ -67,18 +179,18 @@ func createBaseConfig() (*apiModels.PostableUserConfig, *apiModels.Route) {
Route: defaultRoute,
},
},
- }, nestedRoute
+ }
}
// createDefaultRoute creates a default root-level route and associated nested route that will contain all the migrated channels.
-func createDefaultRoute() (*apiModels.Route, *apiModels.Route) {
+func createDefaultRoute() *apiModels.Route {
nestedRoute := createNestedLegacyRoute()
return &apiModels.Route{
Receiver: "autogen-contact-point-default",
Routes: []*apiModels.Route{nestedRoute},
GroupByStr: []string{ngmodels.FolderTitleLabel, model.AlertNameLabel}, // To keep parity with pre-migration notifications.
RepeatInterval: nil,
- }, nestedRoute
+ }
}
// createNestedLegacyRoute creates a nested route that will contain all the migrated channels.
diff --git a/pkg/services/ngalert/migration/permissions.go b/pkg/services/ngalert/migration/permissions.go
index 45424161b56..a8fa6ff6179 100644
--- a/pkg/services/ngalert/migration/permissions.go
+++ b/pkg/services/ngalert/migration/permissions.go
@@ -66,7 +66,7 @@ func (sync *sync) migratedFolder(ctx context.Context, l log.Logger, dashboardUID
dashFolder, err := sync.getFolder(ctx, folderID)
if err != nil {
// nolint:staticcheck
- l.Warn("Failed to find folder for dashboard", "missing_folder_id", folderID, "error", err)
+ l.Warn("Failed to find folder for dashboard", "missingFolderId", folderID, "error", err)
}
if dashFolder != nil {
l = l.New("folderUid", dashFolder.UID, "folderName", dashFolder.Title)
diff --git a/pkg/services/ngalert/migration/persist.go b/pkg/services/ngalert/migration/persist.go
index 297b2136737..7f2611b002e 100644
--- a/pkg/services/ngalert/migration/persist.go
+++ b/pkg/services/ngalert/migration/persist.go
@@ -3,6 +3,7 @@ package migration
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"sort"
@@ -58,115 +59,369 @@ func (sync *sync) syncAndSaveState(
ctx context.Context,
dashboardUpgrades []*migmodels.DashboardUpgrade,
contactPairs []*migmodels.ContactPair,
-) error {
- delta := createDelta(dashboardUpgrades, contactPairs)
- state, err := sync.syncDelta(ctx, delta)
+ skipExisting bool,
+) (apiModels.OrgMigrationSummary, error) {
+ oldState, err := sync.migrationStore.GetOrgMigrationState(ctx, sync.orgID)
if err != nil {
- return fmt.Errorf("sync state: %w", err)
+ return apiModels.OrgMigrationSummary{}, fmt.Errorf("get state: %w", err)
}
- err = sync.migrationStore.SetOrgMigrationState(ctx, sync.orgID, state)
+ delta := createDelta(oldState, dashboardUpgrades, contactPairs, skipExisting)
+ summary, err := sync.syncDelta(ctx, oldState, delta)
if err != nil {
- return fmt.Errorf("save state: %w", err)
+ return apiModels.OrgMigrationSummary{}, fmt.Errorf("sync state: %w", err)
}
- return nil
+ err = sync.migrationStore.SetOrgMigrationState(ctx, sync.orgID, oldState)
+ if err != nil {
+ return apiModels.OrgMigrationSummary{}, fmt.Errorf("save state: %w", err)
+ }
+
+ return summary, nil
}
// StateDelta contains the changes to be made to the database based on the difference between
// existing migration state and new migration state.
type StateDelta struct {
- DashboardsToAdd []*migmodels.DashboardUpgrade
- ChannelsToAdd []*migmodels.ContactPair
+ AlertsToAdd []*migmodels.AlertPair
+ AlertsToDelete []*migmodels.AlertPair
+ DashboardsToAdd []*migmodels.DashboardUpgrade
+ DashboardsToDelete []*migrationStore.DashboardUpgrade
+
+ ChannelsToAdd []*migmodels.ContactPair
+ ChannelsToDelete []*migrationStore.ContactPair
}
-// createDelta creates a StateDelta from the new dashboards upgrades and contact pairs.
+// createDelta creates a StateDelta based on the difference between oldState and the new dashboardUpgrades and contactPairs.
+// If skipExisting is true, existing alerts in each dashboard as well as existing channels will be not be deleted.
+// If skipExisting is false, each given dashboard will be entirely replaced with the new state and all channels will be replaced.
func createDelta(
+ oldState *migrationStore.OrgMigrationState,
dashboardUpgrades []*migmodels.DashboardUpgrade,
contactPairs []*migmodels.ContactPair,
+ skipExisting bool,
) StateDelta {
- return StateDelta{
- DashboardsToAdd: dashboardUpgrades,
- ChannelsToAdd: contactPairs,
+ delta := StateDelta{}
+ for _, du := range dashboardUpgrades {
+ oldDu, ok := oldState.MigratedDashboards[du.ID]
+ if !ok {
+ // Old state doesn't contain this dashboard, so add all alerts.
+ delta.DashboardsToAdd = append(delta.DashboardsToAdd, du)
+ continue
+ }
+
+ if !skipExisting {
+ delta.DashboardsToDelete = append(delta.DashboardsToDelete, oldDu)
+ delta.DashboardsToAdd = append(delta.DashboardsToAdd, du)
+ continue
+ }
+
+ for _, pair := range du.MigratedAlerts {
+ if _, ok := oldDu.MigratedAlerts[pair.LegacyRule.PanelID]; !ok {
+ // Only add alerts that don't already exist.
+ delta.AlertsToAdd = append(delta.AlertsToAdd, pair)
+ }
+ }
}
+
+ for _, newPair := range contactPairs {
+ oldPair, ok := oldState.MigratedChannels[newPair.Channel.ID]
+ if !ok || oldPair.NewReceiverUID == "" {
+ // Old state doesn't contain this channel, or this channel is a stub. We create a new contact point and route for it.
+ delta.ChannelsToAdd = append(delta.ChannelsToAdd, newPair)
+ continue
+ }
+
+ // Old state contains this channel, so it's either a re-migrated channel or a deleted channel.
+ if skipExisting {
+ // We're only migrating new channels, so we skip this one.
+ continue
+ }
+ delta.ChannelsToDelete = append(delta.ChannelsToDelete, oldPair)
+ delta.ChannelsToAdd = append(delta.ChannelsToAdd, newPair)
+ }
+
+ return delta
+}
+
+func summaryFromDelta(delta StateDelta) apiModels.OrgMigrationSummary {
+ summary := apiModels.OrgMigrationSummary{
+ NewDashboards: len(delta.DashboardsToAdd),
+ NewChannels: len(delta.ChannelsToAdd),
+ HasErrors: hasErrors(delta),
+ }
+
+ for _, du := range delta.DashboardsToAdd {
+ summary.NewAlerts += len(du.MigratedAlerts)
+ }
+
+ return summary
+}
+
+func hasErrors(delta StateDelta) bool {
+ for _, du := range delta.DashboardsToAdd {
+ for _, pair := range du.MigratedAlerts {
+ if pair.Error != nil {
+ return true
+ }
+ }
+ }
+ for _, pair := range delta.AlertsToAdd {
+ if pair.Error != nil {
+ return true
+ }
+ }
+ for _, pair := range delta.ChannelsToAdd {
+ if pair.Error != nil {
+ return true
+ }
+ }
+ return false
}
// syncDelta persists the given delta to the state and database.
-func (sync *sync) syncDelta(ctx context.Context, delta StateDelta) (*migrationStore.OrgMigrationState, error) {
- state := &migrationStore.OrgMigrationState{
- OrgID: sync.orgID,
- CreatedFolders: make([]string, 0),
+func (sync *sync) syncDelta(ctx context.Context, state *migrationStore.OrgMigrationState, delta StateDelta) (apiModels.OrgMigrationSummary, error) {
+ amConfig, err := sync.handleAlertmanager(ctx, state, delta)
+ if err != nil {
+ return apiModels.OrgMigrationSummary{}, err
}
- amConfig, err := sync.handleAlertmanager(ctx, delta)
+ err = sync.handleDeleteRules(ctx, state, delta)
if err != nil {
- return nil, err
+ return apiModels.OrgMigrationSummary{}, err
}
err = sync.handleAddRules(ctx, state, delta, amConfig)
if err != nil {
- return nil, err
+ return apiModels.OrgMigrationSummary{}, err
}
- return state, nil
+ return summaryFromDelta(delta), nil
}
// handleAlertmanager persists the given channel delta to the state and database.
-func (sync *sync) handleAlertmanager(ctx context.Context, delta StateDelta) (*migmodels.Alertmanager, error) {
- amConfig := migmodels.NewAlertmanager()
+func (sync *sync) handleAlertmanager(ctx context.Context, state *migrationStore.OrgMigrationState, delta StateDelta) (*migmodels.Alertmanager, error) {
+ cfg, err := sync.migrationStore.GetAlertmanagerConfig(ctx, sync.orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get alertmanager config: %w", err)
+ }
+ amConfig := migmodels.FromPostableUserConfig(cfg)
- if len(delta.ChannelsToAdd) == 0 {
+ if len(delta.ChannelsToDelete) == 0 && len(delta.ChannelsToAdd) == 0 {
return amConfig, nil
}
+ // Get the reverse relationship between rules and channels, so we can update labels on rules that reference modified channels.
+ rulesWithChannels := make(map[int64][]string)
+ for _, du := range state.MigratedDashboards {
+ for _, pair := range du.MigratedAlerts {
+ if pair.NewRuleUID != "" {
+ for _, id := range pair.ChannelIDs {
+ rulesWithChannels[id] = append(rulesWithChannels[id], pair.NewRuleUID)
+ }
+ }
+ }
+ }
+
+ // Information tracked to facilitate alert rule contact point label updates.
+ ruleToAddLabels := make(map[string][]string)
+ ruleToRemoveLabels := make(map[string][]string)
+ for _, pair := range delta.ChannelsToDelete {
+ delete(state.MigratedChannels, pair.LegacyID)
+ if pair.NewReceiverUID != "" {
+ label := amConfig.GetContactLabel(pair.NewReceiverUID)
+ for _, uid := range rulesWithChannels[pair.LegacyID] {
+ ruleToRemoveLabels[uid] = append(ruleToRemoveLabels[uid], label)
+ }
+ // Remove receivers and routes for channels that are being replaced.
+ amConfig.RemoveContactPointsAndRoutes(pair.NewReceiverUID)
+ }
+ }
+
for _, pair := range delta.ChannelsToAdd {
+ state.MigratedChannels[pair.Channel.ID] = newContactPair(pair)
amConfig.AddReceiver(pair.ContactPoint)
amConfig.AddRoute(pair.Route)
+ if pair.ContactPoint != nil {
+ for _, uid := range rulesWithChannels[pair.Channel.ID] {
+ ruleToAddLabels[uid] = append(ruleToAddLabels[uid], contactLabel(pair.ContactPoint.Name))
+ }
+ }
}
+ config := migmodels.CleanAlertmanager(amConfig)
+
// Validate the alertmanager configuration produced, this gives a chance to catch bad configuration at migration time.
// Validation between legacy and unified alerting can be different (e.g. due to bug fixes) so this would fail the migration in that case.
- if err := sync.validateAlertmanagerConfig(amConfig.Config); err != nil {
+ if err := sync.validateAlertmanagerConfig(config); err != nil {
return nil, fmt.Errorf("validate AlertmanagerConfig: %w", err)
}
- sync.log.Info("Writing alertmanager config", "receivers", len(amConfig.Config.AlertmanagerConfig.Receivers), "routes", len(amConfig.Config.AlertmanagerConfig.Route.Routes))
- if err := sync.migrationStore.SaveAlertmanagerConfiguration(ctx, sync.orgID, amConfig.Config); err != nil {
+ sync.log.Info("Writing alertmanager config", "receivers", len(config.AlertmanagerConfig.Receivers), "routes", len(config.AlertmanagerConfig.Route.Routes))
+ if err := sync.migrationStore.SaveAlertmanagerConfiguration(ctx, sync.orgID, config); err != nil {
return nil, fmt.Errorf("write AlertmanagerConfig: %w", err)
}
+ // For the channels that have been changed, we need to update the labels on the alert rules that reference them.
+ err = sync.replaceLabels(ctx, ruleToAddLabels, ruleToRemoveLabels)
+ if err != nil {
+ return nil, fmt.Errorf("replace labels: %w", err)
+ }
+
return amConfig, nil
}
-// handleAddRules persists the given add rule delta to the state and database.
-func (sync *sync) handleAddRules(ctx context.Context, state *migrationStore.OrgMigrationState, delta StateDelta, amConfig *migmodels.Alertmanager) error {
- pairs := make([]*migmodels.AlertPair, 0)
- createdFolderUIDs := make(map[string]struct{})
- for _, duToAdd := range delta.DashboardsToAdd {
- pairsWithRules := make([]*migmodels.AlertPair, 0, len(duToAdd.MigratedAlerts))
- for _, pair := range duToAdd.MigratedAlerts {
- if pair.Rule != nil {
- pairsWithRules = append(pairsWithRules, pair)
+// replaceLabels replaces labels for the given alert rule UIDs.
+func (sync *sync) replaceLabels(ctx context.Context, ruleToAddLabels map[string][]string, ruleToRemoveLabels map[string][]string) error {
+ var ruleUIDs []string
+ for uid := range ruleToAddLabels {
+ ruleUIDs = append(ruleUIDs, uid)
+ }
+ for uid := range ruleToRemoveLabels {
+ if _, ok := ruleToAddLabels[uid]; !ok {
+ ruleUIDs = append(ruleUIDs, uid)
+ }
+ }
+ ruleLabels, err := sync.migrationStore.GetRuleLabels(ctx, sync.orgID, ruleUIDs)
+ if err != nil {
+ return fmt.Errorf("get rule labels: %w", err)
+ }
+ for key, labels := range ruleLabels {
+ if labelsToRemove, ok := ruleToRemoveLabels[key.UID]; ok {
+ for _, label := range labelsToRemove {
+ delete(labels, label)
}
}
+ if labelsToAdd, ok := ruleToAddLabels[key.UID]; ok {
+ for _, label := range labelsToAdd {
+ labels[label] = "true"
+ }
+ }
+ err := sync.migrationStore.UpdateRuleLabels(ctx, key, labels)
+ if err != nil {
+ return fmt.Errorf("update rule labels: %w", err)
+ }
+ }
+ return nil
+}
- if len(pairsWithRules) > 0 {
- l := sync.log.New("dashboardTitle", duToAdd.Title, "dashboardUid", duToAdd.UID)
- migratedFolder, err := sync.migratedFolder(ctx, l, duToAdd.UID, duToAdd.FolderID)
- if err != nil {
- return err
+// handleDeleteRules persists the given delete rule delta to the state and database.
+func (sync *sync) handleDeleteRules(ctx context.Context, state *migrationStore.OrgMigrationState, delta StateDelta) error {
+ if len(delta.AlertsToDelete) == 0 && len(delta.DashboardsToDelete) == 0 {
+ return nil
+ }
+ // First we delete alerts so that empty folders can be deleted.
+ uids := make([]string, 0, len(delta.AlertsToDelete))
+ for _, pair := range delta.AlertsToDelete {
+ du, ok := state.MigratedDashboards[pair.LegacyRule.DashboardID]
+ if !ok {
+ return fmt.Errorf("dashboard '%d' not found in state", pair.LegacyRule.DashboardID)
+ }
+ delete(du.MigratedAlerts, pair.LegacyRule.PanelID)
+ if pair.Rule != nil {
+ uids = append(uids, pair.Rule.UID)
+ }
+ }
+ for _, du := range delta.DashboardsToDelete {
+ delete(state.MigratedDashboards, du.DashboardID)
+ for _, pair := range du.MigratedAlerts {
+ if pair.NewRuleUID != "" {
+ uids = append(uids, pair.NewRuleUID)
+ }
+ }
+ }
+ if len(uids) > 0 {
+ err := sync.migrationStore.DeleteAlertRules(ctx, sync.orgID, uids...)
+ if err != nil {
+ return fmt.Errorf("delete alert rules: %w", err)
+ }
+
+ // Attempt to delete folders that might be empty.
+ if len(delta.DashboardsToDelete) > 0 {
+ createdbyMigration := make(map[string]struct{}, len(state.CreatedFolders))
+ for _, uid := range state.CreatedFolders {
+ createdbyMigration[uid] = struct{}{}
}
- // Keep track of folders created by the migration.
- if _, exists := createdFolderUIDs[migratedFolder.uid]; migratedFolder.created && !exists {
- createdFolderUIDs[migratedFolder.uid] = struct{}{}
- state.CreatedFolders = append(state.CreatedFolders, migratedFolder.uid)
+ for _, du := range delta.DashboardsToDelete {
+ if _, ok := createdbyMigration[du.AlertFolderUID]; ok {
+ err := sync.migrationStore.DeleteFolders(ctx, sync.orgID, du.AlertFolderUID)
+ if err != nil {
+ if !errors.Is(err, migrationStore.ErrFolderNotDeleted) {
+ return fmt.Errorf("delete folder '%s': %w", du.AlertFolderUID, err)
+ }
+ sync.log.Info("Failed to delete folder during cleanup", "error", err)
+ } else {
+ delete(createdbyMigration, du.AlertFolderUID)
+ }
+ }
+ }
+ state.CreatedFolders = make([]string, 0, len(createdbyMigration))
+ for uid := range createdbyMigration {
+ state.CreatedFolders = append(state.CreatedFolders, uid)
+ }
+ }
+ }
+
+ return nil
+}
+
+// handleAddRules persists the given add rule delta to the state and database.
+func (sync *sync) handleAddRules(ctx context.Context, state *migrationStore.OrgMigrationState, delta StateDelta, amConfig *migmodels.Alertmanager) error {
+ pairs := make([]*migmodels.AlertPair, 0, len(delta.AlertsToAdd))
+ for _, pair := range delta.AlertsToAdd {
+ du, ok := state.MigratedDashboards[pair.LegacyRule.DashboardID]
+ if !ok {
+ return fmt.Errorf("dashboard '%d' not found in state", pair.LegacyRule.DashboardID)
+ }
+
+ if pair.Rule != nil {
+ // These individually added alerts are created in the same folder as the existing ones.
+ pair.Rule.NamespaceUID = du.AlertFolderUID
+ pairs = append(pairs, pair)
+ }
+ du.MigratedAlerts[pair.LegacyRule.PanelID] = newAlertPair(pair)
+ }
+ if len(delta.DashboardsToAdd) > 0 {
+ createdFolderUIDs := make(map[string]struct{}, len(state.CreatedFolders))
+ for _, uid := range state.CreatedFolders {
+ createdFolderUIDs[uid] = struct{}{}
+ }
+
+ for _, duToAdd := range delta.DashboardsToAdd {
+ du := &migrationStore.DashboardUpgrade{
+ DashboardID: duToAdd.ID,
+ MigratedAlerts: make(map[int64]*migrationStore.AlertPair, len(duToAdd.MigratedAlerts)),
+ }
+ pairsWithRules := make([]*migmodels.AlertPair, 0, len(duToAdd.MigratedAlerts))
+ for _, pair := range duToAdd.MigratedAlerts {
+ du.MigratedAlerts[pair.LegacyRule.PanelID] = newAlertPair(pair)
+ if pair.Rule != nil {
+ pairsWithRules = append(pairsWithRules, pair)
+ }
}
- for _, pair := range pairsWithRules {
- pair.Rule.NamespaceUID = migratedFolder.uid
- pairs = append(pairs, pair)
+ if len(pairsWithRules) > 0 {
+ l := sync.log.New("dashboardTitle", duToAdd.Title, "dashboardUid", duToAdd.UID)
+ migratedFolder, err := sync.migratedFolder(ctx, l, duToAdd.UID, duToAdd.FolderID)
+ if err != nil {
+ return err
+ }
+ du.AlertFolderUID = migratedFolder.uid
+ du.Warning = migratedFolder.warning
+
+ // Keep track of folders created by the migration.
+ if _, exists := createdFolderUIDs[migratedFolder.uid]; migratedFolder.created && !exists {
+ createdFolderUIDs[migratedFolder.uid] = struct{}{}
+ state.CreatedFolders = append(state.CreatedFolders, migratedFolder.uid)
+ }
+
+ for _, pair := range pairsWithRules {
+ pair.Rule.NamespaceUID = migratedFolder.uid
+ pairs = append(pairs, pair)
+ }
}
+ state.MigratedDashboards[du.DashboardID] = du
}
}
@@ -182,7 +437,7 @@ func (sync *sync) handleAddRules(ctx context.Context, state *migrationStore.OrgM
if err != nil {
return fmt.Errorf("deduplicate titles: %w", err)
}
- rules, err := sync.attachContactPointLabels(ctx, pairs, amConfig)
+ rules, err := sync.attachContactPointLabels(ctx, state, pairs, amConfig)
if err != nil {
return fmt.Errorf("attach contact point labels: %w", err)
}
@@ -237,18 +492,31 @@ func (sync *sync) deduplicateTitles(ctx context.Context, pairs []*migmodels.Aler
}
// attachContactPointLabels attaches contact point labels to the given alert rules.
-func (sync *sync) attachContactPointLabels(ctx context.Context, pairs []*migmodels.AlertPair, amConfig *migmodels.Alertmanager) ([]models.AlertRule, error) {
+func (sync *sync) attachContactPointLabels(ctx context.Context, state *migrationStore.OrgMigrationState, pairs []*migmodels.AlertPair, amConfig *migmodels.Alertmanager) ([]models.AlertRule, error) {
rules := make([]models.AlertRule, 0, len(pairs))
for _, pair := range pairs {
+ l := sync.log.New("legacyRuleId", pair.LegacyRule.ID, "ruleUid", pair.Rule.UID)
alertChannels, err := sync.extractChannels(ctx, pair.LegacyRule)
if err != nil {
return nil, fmt.Errorf("extract channel IDs: %w", err)
}
+ statePair := state.MigratedDashboards[pair.LegacyRule.DashboardID].MigratedAlerts[pair.LegacyRule.PanelID]
+ statePair.ChannelIDs = make([]int64, 0, len(alertChannels))
for _, c := range alertChannels {
- pair.Rule.Labels[contactLabel(c.Name)] = "true"
+ statePair.ChannelIDs = append(statePair.ChannelIDs, c.ID)
+ channelPair, ok := state.MigratedChannels[c.ID]
+ if ok {
+ label := amConfig.GetContactLabel(channelPair.NewReceiverUID)
+ if label != "" {
+ pair.Rule.Labels[label] = "true"
+ }
+ } else {
+ l.Warn("Failed to find migrated channel", "channel", c.Name)
+ // Creating stub so that when we eventually migrate the channel, we can update the labels on this rule.
+ state.MigratedChannels[c.ID] = &migrationStore.ContactPair{LegacyID: c.ID, Error: "channel not upgraded"}
+ }
}
-
rules = append(rules, *pair.Rule)
}
return rules, nil
@@ -316,3 +584,32 @@ func (sync *sync) validateAlertmanagerConfig(config *apiModels.PostableUserConfi
return nil
}
+
+func newContactPair(pair *migmodels.ContactPair) *migrationStore.ContactPair {
+ p := &migrationStore.ContactPair{
+ LegacyID: pair.Channel.ID,
+ }
+ if pair.Error != nil {
+ p.Error = pair.Error.Error()
+ }
+
+ if pair.ContactPoint != nil {
+ p.NewReceiverUID = pair.ContactPoint.UID
+ }
+ return p
+}
+
+func newAlertPair(a *migmodels.AlertPair) *migrationStore.AlertPair {
+ pair := &migrationStore.AlertPair{}
+ if a.Error != nil {
+ pair.Error = a.Error.Error()
+ }
+ if a.LegacyRule != nil {
+ pair.LegacyID = a.LegacyRule.ID
+ pair.PanelID = a.LegacyRule.PanelID
+ }
+ if a.Rule != nil {
+ pair.NewRuleUID = a.Rule.UID
+ }
+ return pair
+}
diff --git a/pkg/services/ngalert/migration/service.go b/pkg/services/ngalert/migration/service.go
index 96f0de7ab80..204a6f49900 100644
--- a/pkg/services/ngalert/migration/service.go
+++ b/pkg/services/ngalert/migration/service.go
@@ -6,11 +6,18 @@ import (
"fmt"
"time"
+ alertingModels "github.com/grafana/alerting/models"
+ v2 "github.com/prometheus/alertmanager/api/v2"
+ "github.com/prometheus/common/model"
+
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
+ legacymodels "github.com/grafana/grafana/pkg/services/alerting/models"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models"
migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store"
+ "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
)
@@ -18,8 +25,18 @@ import (
// actionName is the unique row-level lock name for serverlock.ServerLockService.
const actionName = "alerting migration"
+var ErrUpgradeInProgress = errors.New("upgrade in progress")
+
type UpgradeService interface {
Run(ctx context.Context) error
+ MigrateAlert(ctx context.Context, orgID int64, dashboardID int64, panelID int64) (definitions.OrgMigrationSummary, error)
+ MigrateDashboardAlerts(ctx context.Context, orgID int64, dashboardID int64, skipExisting bool) (definitions.OrgMigrationSummary, error)
+ MigrateAllDashboardAlerts(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error)
+ MigrateChannel(ctx context.Context, orgID int64, channelID int64) (definitions.OrgMigrationSummary, error)
+ MigrateAllChannels(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error)
+ MigrateOrg(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error)
+ GetOrgMigrationState(ctx context.Context, orgID int64) (*definitions.OrgMigrationState, error)
+ RevertOrg(ctx context.Context, orgID int64) error
}
type migrationService struct {
@@ -49,6 +66,273 @@ func ProvideService(
}, nil
}
+type operation func(ctx context.Context) (*definitions.OrgMigrationSummary, error)
+
+// verifyTry verifies that the org has been migrated, and then attempts to execute the operation. If another operation
+// is already in progress, ErrUpgradeInProgress will be returned.
+func (ms *migrationService) verifyTry(ctx context.Context, orgID int64, op operation) (definitions.OrgMigrationSummary, error) {
+ if err := ms.verifyMigrated(ctx, orgID); err != nil {
+ return definitions.OrgMigrationSummary{}, err
+ }
+ return ms.try(ctx, op)
+}
+
+// try attempts to execute the operation. If another operation is already in progress, ErrUpgradeInProgress will be returned.
+func (ms *migrationService) try(ctx context.Context, op operation) (definitions.OrgMigrationSummary, error) {
+ var summary definitions.OrgMigrationSummary
+ var errOp error
+ errLock := ms.lock.LockExecuteAndRelease(ctx, actionName, time.Minute*10, func(ctx context.Context) {
+ errOp = ms.store.InTransaction(ctx, func(ctx context.Context) error {
+ s, err := op(ctx)
+ if err != nil {
+ return err
+ }
+ if s != nil {
+ summary.Add(*s)
+ }
+ return nil
+ })
+ })
+ if errLock != nil {
+ return definitions.OrgMigrationSummary{}, ErrUpgradeInProgress
+ }
+ if errOp != nil {
+ return definitions.OrgMigrationSummary{}, errOp
+ }
+
+ return summary, nil
+}
+
+// MigrateChannel migrates a single legacy notification channel to a unified alerting contact point.
+func (ms *migrationService) MigrateChannel(ctx context.Context, orgID int64, channelID int64) (definitions.OrgMigrationSummary, error) {
+ return ms.verifyTry(ctx, orgID, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ om := ms.newOrgMigration(orgID)
+ oldState, err := om.migrationStore.GetOrgMigrationState(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get org migration state: %w", err)
+ }
+
+ channel, err := om.migrationStore.GetNotificationChannel(ctx, migrationStore.GetNotificationChannelQuery{OrgID: orgID, ID: channelID})
+ if err != nil && !errors.Is(err, migrationStore.ErrNotFound) {
+ return nil, fmt.Errorf("get notification channel: %w", err)
+ }
+
+ var delta StateDelta
+ if err != nil && errors.Is(err, migrationStore.ErrNotFound) {
+ // Notification channel no longer exists, delete this record from the state as well as delete any contacts points and routes.
+ om.log.Debug("Notification channel no longer exists", "channelId", channelID)
+ summary.Removed = true
+ pair, ok := oldState.MigratedChannels[channelID]
+ if !ok {
+ pair = &migrationStore.ContactPair{LegacyID: channelID}
+ }
+ delta = StateDelta{
+ ChannelsToDelete: []*migrationStore.ContactPair{pair},
+ }
+ } else {
+ pairs, err := om.migrateChannels([]*legacymodels.AlertNotification{channel})
+ if err != nil {
+ return nil, err
+ }
+
+ delta = createDelta(oldState, nil, pairs, false)
+ }
+
+ s, err := ms.newSync(orgID).syncDelta(ctx, oldState, delta)
+ if err != nil {
+ return nil, err
+ }
+
+ err = ms.migrationStore.SetOrgMigrationState(ctx, orgID, oldState)
+ if err != nil {
+ return nil, err
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// MigrateAllChannels migrates all legacy notification channel to unified alerting contact points.
+func (ms *migrationService) MigrateAllChannels(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error) {
+ return ms.verifyTry(ctx, orgID, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ om := ms.newOrgMigration(orgID)
+ pairs, err := om.migrateOrgChannels(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s, err := ms.newSync(orgID).syncAndSaveState(ctx, nil, pairs, skipExisting)
+ if err != nil {
+ return nil, err
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// MigrateAlert migrates a single dashboard alert from legacy alerting to unified alerting.
+func (ms *migrationService) MigrateAlert(ctx context.Context, orgID int64, dashboardID int64, panelID int64) (definitions.OrgMigrationSummary, error) {
+ return ms.verifyTry(ctx, orgID, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ om := ms.newOrgMigration(orgID)
+ oldState, err := om.migrationStore.GetOrgMigrationState(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get org migration state: %w", err)
+ }
+
+ delta := StateDelta{}
+ du, ok := oldState.MigratedDashboards[dashboardID]
+ if ok {
+ existingPair := &migmodels.AlertPair{LegacyRule: &legacymodels.Alert{PanelID: panelID, DashboardID: dashboardID}}
+ if pair, ok := du.MigratedAlerts[panelID]; ok {
+ existingPair.Rule = &models.AlertRule{UID: pair.NewRuleUID}
+ }
+ delta.AlertsToDelete = []*migmodels.AlertPair{existingPair}
+ }
+
+ alert, err := ms.migrationStore.GetDashboardAlert(ctx, orgID, dashboardID, panelID)
+ if err != nil && !errors.Is(err, migrationStore.ErrNotFound) {
+ return nil, fmt.Errorf("get alert: %w", err)
+ }
+
+ if err != nil && errors.Is(err, migrationStore.ErrNotFound) {
+ // Legacy alert no longer exists, delete this record from the state.
+ om.log.Debug("Alert no longer exists", "dashboardId", dashboardID, "panelId", panelID)
+ summary.Removed = true
+ } else {
+ newDu := om.migrateDashboard(ctx, dashboardID, []*legacymodels.Alert{alert})
+ if _, ok := oldState.MigratedDashboards[dashboardID]; !ok {
+ delta.DashboardsToAdd = []*migmodels.DashboardUpgrade{newDu}
+ } else {
+ // Replace only this alert on the dashboard.
+ for _, pair := range newDu.MigratedAlerts {
+ delta.AlertsToAdd = append(delta.AlertsToAdd, pair)
+ }
+ }
+ }
+
+ s, err := ms.newSync(orgID).syncDelta(ctx, oldState, delta)
+ if err != nil {
+ return nil, err
+ }
+
+ // We don't create new folders here, so no need to upgrade summary.CreatedFolders.
+ err = ms.migrationStore.SetOrgMigrationState(ctx, orgID, oldState)
+ if err != nil {
+ return nil, err
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// MigrateDashboardAlerts migrates all legacy dashboard alerts from a single dashboard to unified alerting.
+func (ms *migrationService) MigrateDashboardAlerts(ctx context.Context, orgID int64, dashboardID int64, skipExisting bool) (definitions.OrgMigrationSummary, error) {
+ return ms.verifyTry(ctx, orgID, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ om := ms.newOrgMigration(orgID)
+ alerts, err := ms.migrationStore.GetDashboardAlerts(ctx, orgID, dashboardID)
+ if err != nil {
+ return nil, fmt.Errorf("get alerts: %w", err)
+ }
+
+ du := om.migrateDashboard(ctx, dashboardID, alerts)
+ s, err := ms.newSync(orgID).syncAndSaveState(ctx, []*migmodels.DashboardUpgrade{du}, nil, skipExisting)
+ if err != nil {
+ return nil, err
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// MigrateAllDashboardAlerts migrates all legacy alerts to unified alerting contact points.
+func (ms *migrationService) MigrateAllDashboardAlerts(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error) {
+ return ms.verifyTry(ctx, orgID, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ om := ms.newOrgMigration(orgID)
+ dashboardUpgrades, err := om.migrateOrgAlerts(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s, err := ms.newSync(orgID).syncAndSaveState(ctx, dashboardUpgrades, nil, skipExisting)
+ if err != nil {
+ return nil, err
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// MigrateOrg executes the migration for a single org.
+func (ms *migrationService) MigrateOrg(ctx context.Context, orgID int64, skipExisting bool) (definitions.OrgMigrationSummary, error) {
+ return ms.try(ctx, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ summary := definitions.OrgMigrationSummary{}
+ ms.log.Info("Starting legacy migration for org", "orgId", orgID, "skipExisting", skipExisting)
+ om := ms.newOrgMigration(orgID)
+ dashboardUpgrades, pairs, err := om.migrateOrg(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ s, err := ms.newSync(orgID).syncAndSaveState(ctx, dashboardUpgrades, pairs, skipExisting)
+ if err != nil {
+ return nil, err
+ }
+
+ err = ms.migrationStore.SetMigrated(ctx, orgID, true)
+ if err != nil {
+ return nil, fmt.Errorf("setting migration status: %w", err)
+ }
+
+ summary.Add(s)
+ return &summary, nil
+ })
+}
+
+// GetOrgMigrationState returns the current migration state for an org. This is a potentially expensive operation as it
+// requires re-hydrating the entire migration state from the database against all current alerting resources.
+func (ms *migrationService) GetOrgMigrationState(ctx context.Context, orgID int64) (*definitions.OrgMigrationState, error) {
+ if migrated, err := ms.migrationStore.IsMigrated(ctx, orgID); err != nil || !migrated {
+ return &definitions.OrgMigrationState{OrgID: orgID}, err
+ }
+ dState, err := ms.migrationStore.GetOrgMigrationState(ctx, orgID)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg, err := ms.migrationStore.GetAlertmanagerConfig(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get alertmanager config: %w", err)
+ }
+ amConfig := migmodels.FromPostableUserConfig(cfg)
+
+ // Hydrate the slim database model.
+ migratedChannels, err := ms.fromContactPairs(ctx, orgID, dState.MigratedChannels, amConfig)
+ if err != nil {
+ return nil, fmt.Errorf("rehydrate channels: %w", err)
+ }
+
+ migratedDashboards, err := ms.fromDashboardUpgrades(ctx, orgID, dState.MigratedDashboards, amConfig)
+ if err != nil {
+ return nil, fmt.Errorf("rehydrate alerts: %w", err)
+ }
+
+ return &definitions.OrgMigrationState{
+ OrgID: dState.OrgID,
+ MigratedDashboards: migratedDashboards,
+ MigratedChannels: migratedChannels,
+ }, nil
+}
+
// Run starts the migration to transition between legacy alerting and unified alerting based on the current and desired
// alerting type as determined by the kvstore and configuration, respectively.
func (ms *migrationService) Run(ctx context.Context) error {
@@ -197,7 +481,7 @@ func (ms *migrationService) migrateAllOrgs(ctx context.Context) error {
return fmt.Errorf("migrate org %d: %w", o.ID, migrationErr)
}
- err = ms.newSync(o.ID).syncAndSaveState(ctx, dashboardUpgrades, contactPairs)
+ _, err = ms.newSync(o.ID).syncAndSaveState(ctx, dashboardUpgrades, contactPairs, false)
if err != nil {
return err
}
@@ -216,3 +500,298 @@ func (ms *migrationService) migrateAllOrgs(ctx context.Context) error {
}
return nil
}
+
+// RevertOrg reverts the migration, deleting all unified alerting resources such as alert rules, alertmanager
+// configurations, and silence files for a single organization.
+// In addition, it will delete all folders and permissions originally created by this migration.
+func (ms *migrationService) RevertOrg(ctx context.Context, orgID int64) error {
+ ms.log.Info("Reverting legacy migration for org", "orgId", orgID)
+ _, err := ms.try(ctx, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ return nil, ms.migrationStore.RevertOrg(ctx, orgID)
+ })
+ return err
+}
+
+// RevertAllOrgs reverts the migration for all orgs, deleting all unified alerting resources such as alert rules, alertmanager configurations, and silence files.
+// In addition, it will delete all folders and permissions originally created by this migration.
+func (ms *migrationService) RevertAllOrgs(ctx context.Context) error {
+ ms.log.Info("Reverting legacy migration for all orgs")
+ _, err := ms.try(ctx, func(ctx context.Context) (*definitions.OrgMigrationSummary, error) {
+ return nil, ms.migrationStore.RevertAllOrgs(ctx)
+ })
+ return err
+}
+
+// verifyMigrated returns an error if the org has not been migrated.
+func (ms *migrationService) verifyMigrated(ctx context.Context, orgID int64) error {
+ migrated, err := ms.migrationStore.IsMigrated(ctx, orgID)
+ if err != nil {
+ return fmt.Errorf("check if migrated: %w", err)
+ }
+ if !migrated {
+ return fmt.Errorf("not migrated")
+ }
+ return nil
+}
+
+// fromDashboardUpgrades converts DashboardUpgrades to their api representation. This requires rehydrating information
+// from the database for the current state of dashboards, alerts, and rules.
+func (ms *migrationService) fromDashboardUpgrades(ctx context.Context, orgID int64, migratedDashboards map[int64]*migrationStore.DashboardUpgrade, amConfig *migmodels.Alertmanager) ([]*definitions.DashboardUpgrade, error) {
+ // We preload information in bulk for performance reasons.
+ alertRules, err := ms.migrationStore.GetSlimAlertRules(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get all alert rules: %w", err)
+ }
+
+ dashboardAlerts, err := ms.migrationStore.GetSlimOrgDashboardAlerts(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get dashboard alerts: %w", err)
+ }
+
+ dashIDInfo, err := ms.migrationStore.GetSlimDashboards(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get dashboards: %w", err)
+ }
+ dashUIDInfo := make(map[string]migrationStore.SlimDashboard, len(dashIDInfo))
+ for _, info := range dashIDInfo {
+ dashUIDInfo[info.UID] = info
+ }
+
+ res := make([]*definitions.DashboardUpgrade, 0, len(dashboardAlerts))
+ existingDashboards := make(map[int64]struct{})
+ for dashboardID, alerts := range dashboardAlerts {
+ existingDashboards[dashboardID] = struct{}{}
+ mDu := &definitions.DashboardUpgrade{
+ MigratedAlerts: make([]*definitions.AlertPair, 0),
+ DashboardID: dashboardID,
+ }
+
+ dashInfo, ok := dashIDInfo[dashboardID]
+ if ok {
+ folderInfo := dashIDInfo[dashInfo.FolderID]
+ mDu.DashboardUID = dashInfo.UID
+ mDu.DashboardName = dashInfo.Title
+ mDu.FolderUID = folderInfo.UID
+ mDu.FolderName = folderInfo.Title
+ mDu.Provisioned = dashInfo.Provisioned
+ }
+
+ du, ok := migratedDashboards[dashboardID]
+ if !ok {
+ mDu.Error = "dashboard not upgraded"
+ // Empty dashboard upgrade, to simplify logic below.
+ du = &migrationStore.DashboardUpgrade{
+ DashboardID: dashboardID,
+ MigratedAlerts: make(map[int64]*migrationStore.AlertPair),
+ }
+ }
+ mDu.Warning = du.Warning
+
+ if du.AlertFolderUID != "" {
+ newFolderInfo := dashUIDInfo[du.AlertFolderUID]
+ mDu.NewFolderUID = du.AlertFolderUID
+ mDu.NewFolderName = newFolderInfo.Title
+ }
+
+ existingAlerts := make(map[int64]struct{})
+ for _, a := range alerts {
+ existingAlerts[a.ID] = struct{}{}
+ pair := &definitions.AlertPair{
+ LegacyAlert: fromSlimAlert(a),
+ Error: "alert not upgraded",
+ }
+
+ if p, ok := du.MigratedAlerts[a.PanelID]; ok {
+ pair.Error = p.Error
+ if p.NewRuleUID != "" {
+ if rule, ok := alertRules[p.NewRuleUID]; ok {
+ var sendTo = make([]string, 0)
+ for _, m := range amConfig.Match(extractLabels(rule, mDu.NewFolderName)) {
+ sendTo = append(sendTo, m.RouteOpts.Receiver)
+ }
+ pair.AlertRule = fromSlimAlertRule(rule, sendTo)
+ } else {
+ // We could potentially set an error here, but it's not really an error. It just means that the
+ // user deleted the migrated rule after the migration. This could just as easily be intentional.
+ ms.log.Info("Could not find rule for migrated alert", "alertId", a.ID, "ruleUid", p.NewRuleUID)
+ }
+ }
+ }
+
+ mDu.MigratedAlerts = append(mDu.MigratedAlerts, pair)
+ }
+
+ // Now we check the inverse, for alerts that were migrated but no longer exist.
+ for _, p := range du.MigratedAlerts {
+ if _, ok := existingAlerts[p.LegacyID]; !ok {
+ pair := &definitions.AlertPair{
+ LegacyAlert: &definitions.LegacyAlert{ID: p.LegacyID, PanelID: p.PanelID, DashboardID: du.DashboardID},
+ Error: "alert no longer exists",
+ }
+ if p.NewRuleUID != "" {
+ if rule, ok := alertRules[p.NewRuleUID]; ok {
+ var sendTo = make([]string, 0)
+ for _, m := range amConfig.Match(extractLabels(rule, mDu.NewFolderName)) {
+ sendTo = append(sendTo, m.RouteOpts.Receiver)
+ }
+ pair.AlertRule = fromSlimAlertRule(rule, sendTo)
+ }
+ }
+ mDu.MigratedAlerts = append(mDu.MigratedAlerts, pair)
+ }
+ }
+
+ res = append(res, mDu)
+ }
+
+ // Now we check the inverse, for dashboards that were migrated but no longer exist.
+ for dashboardId, du := range migratedDashboards {
+ if _, ok := existingDashboards[dashboardId]; !ok {
+ mDu := &definitions.DashboardUpgrade{
+ MigratedAlerts: make([]*definitions.AlertPair, 0),
+ DashboardID: dashboardId,
+ Error: "dashboard no longer exists",
+ Warning: du.Warning,
+ }
+
+ if du.AlertFolderUID != "" {
+ newFolderInfo := dashUIDInfo[du.AlertFolderUID]
+ mDu.NewFolderUID = du.AlertFolderUID
+ mDu.NewFolderName = newFolderInfo.Title
+ }
+
+ for _, p := range du.MigratedAlerts {
+ pair := &definitions.AlertPair{
+ LegacyAlert: &definitions.LegacyAlert{ID: p.LegacyID, PanelID: p.PanelID, DashboardID: du.DashboardID},
+ Error: "dashboard no longer exists",
+ }
+ if p.NewRuleUID != "" {
+ if rule, ok := alertRules[p.NewRuleUID]; ok {
+ var sendTo = make([]string, 0)
+ for _, m := range amConfig.Match(extractLabels(rule, mDu.NewFolderName)) {
+ sendTo = append(sendTo, m.RouteOpts.Receiver)
+ }
+ pair.AlertRule = fromSlimAlertRule(rule, sendTo)
+ }
+ }
+ mDu.MigratedAlerts = append(mDu.MigratedAlerts, pair)
+ }
+
+ res = append(res, mDu)
+ }
+ }
+
+ return res, nil
+}
+
+// fromContactPairs converts ContactPairs to their api representation. This requires rehydrating information
+// from the database for the current state of legacy channels and alertmanager configurations.
+func (ms *migrationService) fromContactPairs(ctx context.Context, orgID int64, migratedChannels map[int64]*migrationStore.ContactPair, amConfig *migmodels.Alertmanager) ([]*definitions.ContactPair, error) {
+ channels, err := ms.migrationStore.GetNotificationChannels(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("get notification channels: %w", err)
+ }
+
+ res := make([]*definitions.ContactPair, 0, len(channels))
+ existingChannels := make(map[int64]struct{})
+ for _, channel := range channels {
+ existingChannels[channel.ID] = struct{}{}
+ pair := &definitions.ContactPair{
+ LegacyChannel: fromAlertNotification(channel),
+ Error: "channel not upgraded",
+ }
+
+ if p, ok := migratedChannels[channel.ID]; ok {
+ if p.NewReceiverUID != "" {
+ if recv, ok := amConfig.GetReceiver(p.NewReceiverUID); ok {
+ if route, ok := amConfig.GetLegacyRoute(recv.Name); ok {
+ pair.ContactPointUpgrade = fromContactPointUpgrade(recv, route)
+ }
+ }
+ }
+ pair.Error = p.Error
+ }
+
+ res = append(res, pair)
+ }
+
+ // Now we check the inverse, for channels that were migrated but no longer exist.
+ for _, p := range migratedChannels {
+ if _, ok := existingChannels[p.LegacyID]; !ok {
+ pair := &definitions.ContactPair{
+ LegacyChannel: &definitions.LegacyChannel{ID: p.LegacyID},
+ Error: "channel no longer exists",
+ }
+ if p.NewReceiverUID != "" {
+ if recv, ok := amConfig.GetReceiver(p.NewReceiverUID); ok {
+ if route, ok := amConfig.GetLegacyRoute(recv.Name); ok {
+ pair.ContactPointUpgrade = fromContactPointUpgrade(recv, route)
+ }
+ }
+ }
+ res = append(res, pair)
+ }
+ }
+
+ return res, nil
+}
+
+// fromSlimAlert converts a slim alert to the api representation.
+func fromSlimAlert(alert *migrationStore.SlimAlert) *definitions.LegacyAlert {
+ if alert == nil {
+ return nil
+ }
+ return &definitions.LegacyAlert{
+ ID: alert.ID,
+ DashboardID: alert.DashboardID,
+ PanelID: alert.PanelID,
+ Name: alert.Name,
+ }
+}
+
+// fromSlimAlertRule converts a slim alert rule to the api representation.
+func fromSlimAlertRule(rule *migrationStore.SlimAlertRule, sendsTo []string) *definitions.AlertRuleUpgrade {
+ if rule == nil {
+ return nil
+ }
+ return &definitions.AlertRuleUpgrade{
+ UID: rule.UID,
+ Title: rule.Title,
+ SendsTo: sendsTo,
+ }
+}
+
+// fromAlertNotification converts an alert notification to the api representation.
+func fromAlertNotification(channel *legacymodels.AlertNotification) *definitions.LegacyChannel {
+ if channel == nil {
+ return nil
+ }
+ return &definitions.LegacyChannel{
+ ID: channel.ID,
+ Name: channel.Name,
+ Type: channel.Type,
+ }
+}
+
+// fromContactPointUpgrade converts a postable grafana receiver and route to the api representation.
+func fromContactPointUpgrade(recv *definitions.PostableGrafanaReceiver, route *definitions.Route) *definitions.ContactPointUpgrade {
+ if recv == nil || len(route.ObjectMatchers) == 0 {
+ return nil
+ }
+ return &definitions.ContactPointUpgrade{
+ Name: recv.Name,
+ Type: recv.Type,
+ RouteMatchers: route.ObjectMatchers,
+ }
+}
+
+func extractLabels(rule *migrationStore.SlimAlertRule, folderName string) model.LabelSet {
+ mls := v2.APILabelSetToModelLabelSet(rule.Labels)
+
+ mls[alertingModels.NamespaceUIDLabel] = model.LabelValue(rule.NamespaceUID)
+ mls[model.AlertNameLabel] = model.LabelValue(rule.Title)
+ mls[alertingModels.RuleUIDLabel] = model.LabelValue(rule.UID)
+ mls[models.FolderTitleLabel] = model.LabelValue(folderName)
+
+ return mls
+}
diff --git a/pkg/services/ngalert/migration/service_test.go b/pkg/services/ngalert/migration/service_test.go
index e41f21ad11b..c0fc003e952 100644
--- a/pkg/services/ngalert/migration/service_test.go
+++ b/pkg/services/ngalert/migration/service_test.go
@@ -2,13 +2,32 @@ package migration
import (
"context"
+ "encoding/json"
+ "errors"
"fmt"
+ "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/require"
+ "github.com/grafana/grafana/pkg/components/simplejson"
+ "github.com/grafana/grafana/pkg/infra/log/logtest"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
+ migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models"
migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store"
+ "github.com/grafana/grafana/pkg/services/ngalert/store"
+ "github.com/grafana/grafana/pkg/services/org"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/util"
+
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/infra/db"
@@ -371,3 +390,1745 @@ func checkAlertRulesCount(t *testing.T, x *xorm.Engine, orgID int64, count int)
require.NoError(t, err, "table alert_rule error")
require.Equal(t, int(cnt), count, "table alert_rule should have no rows")
}
+
+type testcase struct {
+ name string
+ orgToMigrate int64
+ skipExisting bool
+
+ // Common Inputs
+ folders []*dashboards.Dashboard
+ dashboards []*dashboards.Dashboard
+ dashboardPerms map[string][]accesscontrol.SetResourcePermissionCommand
+
+ initialLegacyState legacyState
+ initialUAState *uaState
+
+ operations []testOp
+}
+type legacyState struct {
+ alerts []*legacymodels.Alert
+ channels []*legacymodels.AlertNotification
+}
+type uaState struct {
+ alerts []*models.AlertRule
+ amConfig *definitions.PostableUserConfig
+ migState *migrationStore.OrgMigrationState
+ serviceState *definitions.OrgMigrationState // output only.
+}
+
+type testOp struct {
+ description string
+ newLegacyState *legacyState
+ updateLegacyState *legacyState
+ operation func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error
+ expectedUAState *uaState
+ expectedErrors []string
+}
+
+//nolint:gocyclo
+func TestCommonServicePatterns(t *testing.T) {
+ sh := newServiceHelper(t)
+ f1 := sh.genFolder()
+ f2 := sh.genFolder()
+ generalAlertingFolder := sh.genFolder()
+ generalAlertingFolder.UID = "general-alerting"
+ generalAlertingFolder.Title = "General Alerting"
+ sh.folders[generalAlertingFolder.ID] = generalAlertingFolder
+ sh.foldersByUID[generalAlertingFolder.UID] = generalAlertingFolder
+
+ generalFolder := &dashboards.Dashboard{
+ ID: 0,
+ Title: "General",
+ }
+ sh.folders[generalFolder.ID] = generalFolder
+ sh.foldersByUID[generalFolder.UID] = generalFolder
+
+ d1 := sh.genDash(f1)
+ alerts1 := sh.genAlerts(d1, 10)
+ rules1, pairs1 := sh.genAlertPairs(f1, d1, alerts1)
+
+ d2 := sh.genDash(f1)
+ alerts2 := sh.genAlerts(d2, 10)
+ rules2, pairs2 := sh.genAlertPairs(f1, d2, alerts2)
+
+ d3 := sh.genDash(f2)
+ alerts3 := sh.genAlerts(d3, 10)
+ _, pairs3 := sh.genAlertPairs(f2, d3, alerts3)
+
+ channels1 := sh.genChannels(10)
+ channels2 := sh.genChannels(10)
+
+ modifiedAlerts := func(alerts []*legacymodels.Alert, muts ...func(alert *legacymodels.Alert)) []*legacymodels.Alert {
+ newAlerts := copyAlerts(alerts...)
+ for _, alert := range newAlerts {
+ for _, mut := range muts {
+ mut(alert)
+ }
+ }
+ return newAlerts
+ }
+
+ modifiedSuffix := "-modified"
+
+ withModifiedName := func(alert *legacymodels.Alert) {
+ alert.Name = alert.Name + modifiedSuffix
+ }
+
+ withName := func(name string) func(alert *legacymodels.Alert) {
+ return func(alert *legacymodels.Alert) {
+ alert.Name = name
+ }
+ }
+
+ withNotifiers := func(alert *legacymodels.Alert) {
+ alert.Settings.Set("notifications", []notificationKey{{ID: alert.ID}})
+ }
+
+ modifiedPairs := func(pairs []*migmodels.AlertPair, muts ...func(alert *migmodels.AlertPair)) []*migmodels.AlertPair {
+ newPairs := copyPairs(pairs...)
+ for _, pair := range newPairs {
+ for _, mut := range muts {
+ mut(pair)
+ }
+ }
+ return newPairs
+ }
+
+ withModifiedTitle := func(pair *migmodels.AlertPair) {
+ pair.Rule.Title = pair.Rule.Title + modifiedSuffix
+ }
+
+ withTitle := func(name string) func(pair *migmodels.AlertPair) {
+ return func(pair *migmodels.AlertPair) {
+ pair.Rule.Title = name
+ pair.LegacyRule.Name = name
+ }
+ }
+
+ withNotifierLabels := func(pair *migmodels.AlertPair) {
+ withNotifiers(pair.LegacyRule)
+ pair.Rule.Labels[contactLabel(fmt.Sprintf("notifiername%d", pair.LegacyRule.ID))] = "true"
+ }
+
+ modifiedRules := func(alerts []*models.AlertRule, muts ...func(alert *models.AlertRule)) []*models.AlertRule {
+ newAlerts := copyRules(alerts...)
+ for _, alert := range newAlerts {
+ for _, mut := range muts {
+ mut(alert)
+ }
+ }
+ return newAlerts
+ }
+
+ withFolder := func(f *dashboards.Dashboard) func(alert *models.AlertRule) {
+ return func(alert *models.AlertRule) {
+ alert.NamespaceUID = f.UID
+ }
+ }
+
+ modifiedChannels := func(channels []*legacymodels.AlertNotification, muts ...func(c *legacymodels.AlertNotification)) []*legacymodels.AlertNotification {
+ newChannels := copyChannels(channels...)
+ for _, c := range newChannels {
+ for _, mut := range muts {
+ mut(c)
+ }
+ }
+ return newChannels
+ }
+
+ withModifiedChannelName := func(c *legacymodels.AlertNotification) {
+ c.Name = c.Name + modifiedSuffix
+ }
+
+ withType := func(t string) func(c *legacymodels.AlertNotification) {
+ return func(c *legacymodels.AlertNotification) {
+ c.Type = t
+ }
+ }
+
+ modifiedState := func(state *uaState, muts ...func(state *uaState)) *uaState {
+ for _, mut := range muts {
+ mut(state)
+ }
+ return state
+ }
+
+ for _, tt := range []testcase{
+ {
+ name: "Standard org migration",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: alerts1,
+ channels: channels1,
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, channels1, pairs1),
+ },
+ },
+ },
+ {
+ name: "Standard org migration with multiple dashboards",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1, d2, d3},
+
+ initialLegacyState: legacyState{
+ alerts: append(append(alerts1, alerts2...), alerts3...),
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, nil, pairs1, pairs2, pairs3),
+ },
+ },
+ },
+ {
+ name: "with existing alerts & channels not from migration, doesn't delete existing",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: alerts1,
+ channels: channels1,
+ },
+ initialUAState: &uaState{ // Not connected to the migration.
+ alerts: rules2,
+ amConfig: createPostableUserConfig(t, channels2...),
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ {
+ description: "all dashboards migration, no change",
+ operation: migrateAllDashboardAlertsOp(false),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ {
+ description: "all channels migration, no change",
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ {
+ description: "dashboard d1 migration, no change",
+ operation: migrateDashboardAlertsOp(false, d1.ID),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ {
+ description: "channels[0] migration, no change",
+ operation: migrateChannelOp(d1.ID, channels1[0].ID),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ {
+ description: "alerts d1[0] migration, no change",
+ operation: migrateAlertOp(d1.ID, alerts1[0].PanelID),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, pairs1), func(state *uaState) {
+ state.alerts = append(state.alerts, rules2...)
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ },
+ },
+ {
+ name: "migrateAlert with existing alerts in different folder",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+ initialLegacyState: legacyState{
+ alerts: alerts1[:5:5],
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, nil, pairs1[:5:5]),
+ },
+ {
+ description: "move dashboard d1 to folder f2",
+ operation: func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ d1Copy := *d1
+ //nolint:staticcheck
+ d1Copy.FolderID = f2.ID
+ _, err := x.ID(d1.ID).Update(d1Copy)
+ return err
+ },
+ },
+ {
+ description: "add more alerts to d1 and migrate them",
+ newLegacyState: &legacyState{
+ alerts: alerts1[5:6:6],
+ },
+ operation: migrateAlertOp(d1.ID, alerts1[5].PanelID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1[:6:6]), func(state *uaState) {
+ state.serviceState.MigratedDashboards[0].FolderUID = f2.UID
+ state.serviceState.MigratedDashboards[0].FolderName = f2.Title
+ }),
+ },
+ {
+ description: "add more alerts to d1 and migrate them using migrate dashboard skipExisting=true",
+ newLegacyState: &legacyState{
+ alerts: alerts1[6:10:10],
+ },
+ operation: migrateDashboardAlertsOp(true, d1.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1), func(state *uaState) {
+ state.serviceState.MigratedDashboards[0].FolderUID = f2.UID
+ state.serviceState.MigratedDashboards[0].FolderName = f2.Title
+ }),
+ },
+ {
+ description: "migrate with skipExisting=false should move all the alerts to f2",
+ operation: migrateDashboardAlertsOp(false, d1.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, modifiedPairs(pairs1, func(p *migmodels.AlertPair) { p.Rule.NamespaceUID = f2.UID })), func(state *uaState) {
+ state.serviceState.MigratedDashboards[0].FolderUID = f2.UID
+ state.serviceState.MigratedDashboards[0].FolderName = f2.Title
+ }),
+ },
+ },
+ },
+ {
+ name: "migrate alerts using various skipExisting",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1, d2, d3},
+
+ initialLegacyState: legacyState{
+ alerts: alerts1,
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, nil, pairs1),
+ },
+ {
+ description: "add some alerts for d2 & d3 and migrate dashboard d2",
+ newLegacyState: &legacyState{
+ alerts: append(alerts2[:5:5], alerts3[:5:5]...),
+ },
+ operation: migrateDashboardAlertsOp(false, d2.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, pairs2[:5:5]), func(state *uaState) {
+ state.serviceState.MigratedDashboards = append(state.serviceState.MigratedDashboards, &definitions.DashboardUpgrade{
+ DashboardID: d3.ID,
+ DashboardUID: d3.UID,
+ DashboardName: d3.Title,
+ FolderUID: f2.UID,
+ FolderName: f2.Title,
+ MigratedAlerts: make([]*definitions.AlertPair, 5),
+ Error: "dashboard not upgraded",
+ })
+ for i, a := range alerts3[:5:5] {
+ state.serviceState.MigratedDashboards[2].MigratedAlerts[i] = &definitions.AlertPair{
+ LegacyAlert: fromLegacyAlert(a),
+ Error: "alert not upgraded",
+ }
+ }
+ }),
+ },
+ {
+ description: "modify the alerts already migrated for d2 and add the rest, migrate dashboard d2 with skipExisting",
+ updateLegacyState: &legacyState{
+ alerts: modifiedAlerts(alerts2[:5:5], withModifiedName),
+ },
+ newLegacyState: &legacyState{
+ alerts: alerts2[5:],
+ },
+ operation: migrateDashboardAlertsOp(true, d2.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, pairs2), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ state.serviceState.MigratedDashboards = append(state.serviceState.MigratedDashboards, &definitions.DashboardUpgrade{
+ DashboardID: d3.ID,
+ DashboardUID: d3.UID,
+ DashboardName: d3.Title,
+ FolderUID: f2.UID,
+ FolderName: f2.Title,
+ MigratedAlerts: make([]*definitions.AlertPair, 5),
+ Error: "dashboard not upgraded",
+ })
+ for i, a := range alerts3[:5:5] {
+ state.serviceState.MigratedDashboards[2].MigratedAlerts[i] = &definitions.AlertPair{
+ LegacyAlert: fromLegacyAlert(a),
+ Error: "alert not upgraded",
+ }
+ }
+ }), // Because of skipExisting, expected doesn't contain the modifications.
+ },
+ {
+ description: "migrate dashboard d3 using migrate all dashboards",
+ operation: migrateAllDashboardAlertsOp(true),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, pairs2, pairs3[:5:5]), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ }),
+ },
+ {
+ description: "migrate dashboard d2 with skipExisting=false should update using the modifications",
+ operation: migrateDashboardAlertsOp(false, d2.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, append(modifiedPairs(pairs2[:5:5], withModifiedTitle), pairs2[5:]...), pairs3[:5:5]), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ }),
+ },
+ {
+ description: "modify existing d3 alerts and add the rest, migrate one new alert on dashboard d3, should not update modifications",
+ updateLegacyState: &legacyState{
+ alerts: modifiedAlerts(alerts3[:5:5], withModifiedName),
+ },
+ newLegacyState: &legacyState{
+ alerts: alerts3[5:],
+ },
+ operation: migrateAlertOp(d3.ID, alerts3[5].PanelID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, append(modifiedPairs(pairs2[:5:5], withModifiedTitle), pairs2[5:]...), pairs3[:6:6]), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ state.serviceState.MigratedDashboards[2].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ for _, a := range alerts3[6:] {
+ state.serviceState.MigratedDashboards[2].MigratedAlerts = append(state.serviceState.MigratedDashboards[2].MigratedAlerts, &definitions.AlertPair{
+ LegacyAlert: fromLegacyAlert(a),
+ Error: "alert not upgraded",
+ })
+ }
+ }),
+ },
+ {
+ description: "migrate one existing alert on dashboard d3, should update modifications",
+ operation: migrateAlertOp(d3.ID, alerts3[0].PanelID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, pairs1, append(modifiedPairs(pairs2[:5:5], withModifiedTitle), pairs2[5:]...), append(modifiedPairs(pairs3[0:1:1], withModifiedTitle), pairs3[1:6:6]...)), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ state.serviceState.MigratedDashboards[2].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ for _, a := range alerts3[6:] {
+ state.serviceState.MigratedDashboards[2].MigratedAlerts = append(state.serviceState.MigratedDashboards[2].MigratedAlerts, &definitions.AlertPair{
+ LegacyAlert: fromLegacyAlert(a),
+ Error: "alert not upgraded",
+ })
+ }
+ }),
+ },
+ {
+ description: "update d1 alerts, and re-migrate all dashboards",
+ updateLegacyState: &legacyState{
+ alerts: modifiedAlerts(alerts1, withModifiedName),
+ },
+ operation: migrateAllDashboardAlertsOp(false),
+ expectedUAState: modifiedState(sh.uaState(t, nil, modifiedPairs(pairs1, withModifiedTitle), append(modifiedPairs(pairs2[:5:5], withModifiedTitle), pairs2[5:]...), append(modifiedPairs(pairs3[0:5:5], withModifiedTitle), pairs3[5:]...)), func(state *uaState) {
+ for i := 0; i < 5; i++ {
+ state.serviceState.MigratedDashboards[1].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ state.serviceState.MigratedDashboards[2].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ for i := 0; i < 10; i++ {
+ state.serviceState.MigratedDashboards[0].MigratedAlerts[i].LegacyAlert.Name += modifiedSuffix
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "MigrateAllChannels skip=true doesn't update existing channels",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ updateLegacyState: &legacyState{channels: modifiedChannels(channels1, withModifiedChannelName)},
+ operation: migrateAllChannelsOp(true),
+ expectedUAState: modifiedState(sh.uaState(t, channels1), func(state *uaState) {
+ for _, c := range state.serviceState.MigratedChannels {
+ c.LegacyChannel.Name += modifiedSuffix
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "MigrateAllChannels skip=false updates existing channels",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ updateLegacyState: &legacyState{channels: modifiedChannels(channels1, withModifiedChannelName)},
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: sh.uaState(t, modifiedChannels(channels1, withModifiedChannelName)),
+ },
+ },
+ },
+ {
+ name: "MigrateAllChannels skip=false doesn't delete existing channels unrelated to migration",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: &uaState{
+ amConfig: createPostableUserConfig(t, channels2...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: modifiedState(sh.uaState(t, channels1), func(state *uaState) {
+ state.amConfig = createPostableUserConfig(t, append(channels1, channels2...)...)
+ }),
+ },
+ },
+ },
+ {
+ name: "MigrateAllChannels skip=true adds new channels",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ newLegacyState: &legacyState{channels: channels2},
+ operation: migrateAllChannelsOp(true),
+ expectedUAState: sh.uaState(t, append(channels1, channels2...)),
+ },
+ },
+ },
+ {
+ name: "MigrateAllChannels skip=false adds new channels",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ newLegacyState: &legacyState{channels: channels2},
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: sh.uaState(t, append(channels1, channels2...)),
+ },
+ },
+ },
+ {
+ name: "MigrateSingleChannel adds new channel and doesn't affect others",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ newLegacyState: &legacyState{channels: channels2[0:1:1]},
+ operation: migrateChannelOp(channels2[0].ID),
+ expectedUAState: sh.uaState(t, append(channels1, channels2[0])),
+ },
+ },
+ },
+ {
+ name: "MigrateSingleChannel updates existing channel and doesn't affect others",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ initialUAState: sh.uaState(t, channels1),
+ operations: []testOp{
+ {
+ updateLegacyState: &legacyState{channels: modifiedChannels(channels1, withModifiedChannelName)},
+ operation: migrateChannelOp(channels1[9].ID),
+ expectedUAState: modifiedState(sh.uaState(t, append(channels1[0:9:9], modifiedChannels(channels1[9:], withModifiedChannelName)...)), func(state *uaState) {
+ for i := 0; i < 10; i++ {
+ state.serviceState.MigratedChannels[i].LegacyChannel.Name = channels1[i].Name + modifiedSuffix
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "MigrateSingleChannel removes deleted channel and doesn't affect others",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1[0:9:9]},
+ initialUAState: sh.uaState(t, channels1), // Existing state has channels1[9].
+ operations: []testOp{
+ {
+ operation: migrateChannelOp(channels1[9].ID),
+ expectedUAState: sh.uaState(t, channels1[0:9:9]),
+ },
+ },
+ },
+ {
+ name: "MigrateSingleChannel doesn't delete existing channels unrelated to migration",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: []*legacymodels.AlertNotification{channels1[0]}},
+ initialUAState: &uaState{
+ amConfig: createPostableUserConfig(t, channels2...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateChannelOp(channels1[0].ID),
+ expectedUAState: modifiedState(sh.uaState(t, channels1[0:1:1]), func(state *uaState) {
+ state.amConfig = createPostableUserConfig(t, append(channels2, channels1[0])...)
+ }),
+ },
+ },
+ },
+ {
+ name: "alert titles should be deduplicated",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1, withName("duplicate name")),
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: modifiedState(sh.uaState(t, nil, modifiedPairs(pairs1, withTitle("duplicate name"))), func(state *uaState) {
+ state.alerts[0].Title = "duplicate name"
+ for i := 1; i < len(state.alerts); i++ { // First pair doesn't need to be deduplicated.
+ state.alerts[i].Title = fmt.Sprintf("duplicate name #%d", i+1)
+ }
+ for i := 0; i < len(state.alerts); i++ {
+ state.serviceState.MigratedDashboards[0].MigratedAlerts[i].AlertRule.Title = state.alerts[i].Title
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "alert titles should be truncated",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1[0:1:1], withName(strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1))),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: modifiedState(sh.uaState(t, nil, modifiedPairs(pairs1[0:1:1], withTitle(strings.Repeat("a", store.AlertDefinitionMaxTitleLength)))), func(state *uaState) {
+ state.serviceState.MigratedDashboards[0].MigratedAlerts[0].LegacyAlert.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1)
+ })},
+ },
+ },
+ {
+ name: "alert titles should be truncated and deduplicated",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1, withName(strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1))),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: func() *uaState {
+ pairs := modifiedPairs(pairs1, withTitle(strings.Repeat("a", store.AlertDefinitionMaxTitleLength)))
+ for i := 1; i < len(pairs); i++ { // First pair doesn't need to be deduplicated.
+ suffix := fmt.Sprintf(" #%d", i+1)
+ pairs[i].Rule.Title = fmt.Sprintf("%s%s", pairs[i].Rule.Title[:store.AlertDefinitionMaxTitleLength-len(suffix)], suffix)
+ }
+ state := sh.uaState(t, nil, pairs)
+ for i := 0; i < len(pairs); i++ {
+ state.serviceState.MigratedDashboards[0].MigratedAlerts[i].LegacyAlert.Name = strings.Repeat("a", store.AlertDefinitionMaxTitleLength+1)
+ }
+ return state
+ }(),
+ },
+ },
+ },
+ {
+ name: "alert has invalid settings, should return pair with error",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ // Break the last half of the alerts.
+ alerts: append(alerts1[:5:5], modifiedAlerts(alerts1[5:10:10], func(alert *legacymodels.Alert) { alert.Settings.Set("noDataState", 1.5) })...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: &uaState{
+ alerts: rules1[:5:5],
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: map[int64]*migrationStore.DashboardUpgrade{
+ d1.ID: sh.dashUpgrade(d1.ID, f1.UID, append(pairs1[:5:5], modifiedPairs(pairs1[5:10:10], func(pair *migmodels.AlertPair) {
+ pair.Error = errors.New("parse settings: json: cannot unmarshal number into Go struct field dashAlertSettings.noDataState of type string")
+ pair.Rule.UID = ""
+ })...), ""),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "alert has missing dashboard, should return pair with error",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ // Set dashboard to nonexisting id for the last half of the alerts.
+ alerts: append(alerts1[:5:5], modifiedAlerts(alerts1[5:10:10], func(alert *legacymodels.Alert) { alert.DashboardID = -42 })...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: &uaState{
+ alerts: rules1[:5:5],
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: map[int64]*migrationStore.DashboardUpgrade{
+ d1.ID: sh.dashUpgrade(d1.ID, f1.UID, pairs1[:5:5], ""),
+ -42: sh.dashUpgrade(-42, "", modifiedPairs(pairs1[5:10:10], func(pair *migmodels.AlertPair) {
+ pair.Error = errors.New("orphaned: missing dashboard")
+ pair.Rule.UID = ""
+ }), ""),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "alert dashboard has missing folder, should migrate to new general alerting folder",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, generalAlertingFolder},
+ //nolint:staticcheck
+ dashboards: []*dashboards.Dashboard{func(d dashboards.Dashboard) *dashboards.Dashboard { d.FolderID = 99999; return &d }(*d1), d2},
+
+ initialLegacyState: legacyState{
+ alerts: append(alerts1, alerts2...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: &uaState{
+ alerts: append(modifiedRules(rules1, withFolder(generalAlertingFolder)), rules2...),
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: map[int64]*migrationStore.DashboardUpgrade{
+ d1.ID: sh.dashUpgrade(d1.ID, generalAlertingFolder.UID, pairs1, "dashboard alerts moved to general alerting folder during upgrade: original folder not found"),
+ d2.ID: sh.dashUpgrade(d2.ID, f1.UID, pairs2, ""),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "alert dashboard in general folder, should migrate to new general alerting folder",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, generalAlertingFolder},
+ //nolint:staticcheck
+ dashboards: []*dashboards.Dashboard{func(d dashboards.Dashboard) *dashboards.Dashboard { d.FolderID = 0; return &d }(*d1), d2},
+
+ initialLegacyState: legacyState{
+ alerts: append(alerts1[0:1:1], alerts2[0]),
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: &uaState{
+ alerts: append(modifiedRules(rules1[0:1:1], withFolder(generalAlertingFolder)), rules2[0]),
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: map[int64]*migrationStore.DashboardUpgrade{
+ d1.ID: sh.dashUpgrade(d1.ID, generalAlertingFolder.UID, pairs1[0:1:1], "dashboard alerts moved to general alerting folder during upgrade: general folder not supported"),
+ d2.ID: sh.dashUpgrade(d2.ID, f1.UID, pairs2[0:1:1], ""),
+ },
+ },
+ serviceState: &definitions.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: []*definitions.DashboardUpgrade{
+ {
+ DashboardID: d1.ID,
+ DashboardUID: d1.UID,
+ DashboardName: d1.Title,
+ FolderUID: generalFolder.UID,
+ FolderName: generalFolder.Title,
+ NewFolderUID: generalAlertingFolder.UID,
+ NewFolderName: generalAlertingFolder.Title,
+ MigratedAlerts: []*definitions.AlertPair{
+ {LegacyAlert: fromLegacyAlert(alerts1[0]), AlertRule: fromAlertRuleUpgrade(rules1[0], []string{"autogen-contact-point-default"})},
+ },
+ Warning: "dashboard alerts moved to general alerting folder during upgrade: general folder not supported",
+ },
+ {
+ DashboardID: d2.ID,
+ DashboardUID: d2.UID,
+ DashboardName: d2.Title,
+ FolderUID: f1.UID,
+ FolderName: f1.Title,
+ NewFolderUID: f1.UID,
+ NewFolderName: f1.Title,
+ MigratedAlerts: []*definitions.AlertPair{
+ {LegacyAlert: fromLegacyAlert(alerts2[0]), AlertRule: fromAlertRuleUpgrade(rules2[0], []string{"autogen-contact-point-default"})},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "alert dashboard has custom permissions, should migrate to new folder",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{
+ f1,
+ func() *dashboards.Dashboard {
+ // The folder name is deterministic, so we can create the expected folder beforehand. This is so we know the uid for expected states.
+ f := createFolder(t, 100, 1, "created-folder-id")
+ f.Title = "folder1 Alerts - 787427ef800a01a544d6bae21970b4d2"
+ return f
+ }(),
+ },
+ dashboards: []*dashboards.Dashboard{d1, d2},
+ dashboardPerms: map[string][]accesscontrol.SetResourcePermissionCommand{
+ d2.UID: {{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_ADMIN.String()}}, // This permission maps to the 787427ef800a01a544d6bae21970b4d2 hash above.
+ },
+ initialLegacyState: legacyState{
+ alerts: append(alerts1, alerts2...),
+ },
+ operations: []testOp{
+ {
+ operation: migrateOrgOp,
+ expectedUAState: &uaState{
+ alerts: append(rules1, modifiedRules(rules2, withFolder(&dashboards.Dashboard{UID: "created-folder-id"}))...),
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: map[int64]*migrationStore.DashboardUpgrade{
+ d1.ID: sh.dashUpgrade(d1.ID, f1.UID, pairs1, ""),
+ d2.ID: sh.dashUpgrade(d2.ID, "created-folder-id", pairs2, "dashboard alerts moved to new folder during upgrade: folder permission changes were needed"),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "channel is discontinued, should return pair with error",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ operations: []testOp{
+ {
+ updateLegacyState: &legacyState{channels: modifiedChannels([]*legacymodels.AlertNotification{channels1[8], channels1[9]}, withType("hipchat"))},
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: &uaState{
+ amConfig: createPostableUserConfig(t, channels1[:8:8]...),
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ MigratedChannels: func() map[int64]*migrationStore.ContactPair {
+ pairs := sh.contactPairs(channels1...)
+ pairs[channels1[8].ID].Error = "'hipchat': discontinued"
+ pairs[channels1[8].ID].NewReceiverUID = ""
+ pairs[channels1[9].ID].Error = "'hipchat': discontinued"
+ pairs[channels1[9].ID].NewReceiverUID = ""
+ return pairs
+ }(),
+ },
+ },
+ },
+ },
+ },
+ {
+ name: "channel name updates are reflected in alert labels",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1, withNotifiers),
+ channels: channels1,
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, channels1, modifiedPairs(pairs1, withNotifierLabels)),
+ },
+ {
+ description: "update channel names and migrate single channel",
+ updateLegacyState: &legacyState{
+ channels: modifiedChannels(channels1, withModifiedChannelName),
+ },
+ operation: migrateChannelOp(channels1[9].ID),
+ expectedUAState: modifiedState(sh.uaState(t,
+ append(channels1[:9:9], modifiedChannels(channels1[9:10:10], withModifiedChannelName)...),
+ append(modifiedPairs(pairs1[:9:9], withNotifierLabels), modifiedPairs(pairs1[9:10:10], func(a *migmodels.AlertPair) {
+ withNotifiers(a.LegacyRule)
+ a.Rule.Labels[contactLabel(fmt.Sprintf("notifiername%d-modified", a.LegacyRule.ID))] = "true"
+ })...),
+ ), func(state *uaState) {
+ for i := range pairs1 {
+ // Service state knows the updated legacy channel names.
+ state.serviceState.MigratedChannels[i].LegacyChannel.Name = channels1[i].Name + modifiedSuffix
+ }
+ }),
+ },
+ {
+ description: "migrate the rest of the channels",
+ operation: migrateAllChannelsOp(false),
+ expectedUAState: sh.uaState(t,
+ modifiedChannels(channels1, withModifiedChannelName),
+ modifiedPairs(pairs1, func(a *migmodels.AlertPair) {
+ withNotifiers(a.LegacyRule)
+ a.Rule.Labels[contactLabel(fmt.Sprintf("notifiername%d-modified", a.LegacyRule.ID))] = "true"
+ }),
+ ),
+ },
+ },
+ },
+ {
+ name: "contact point name updates are reflected in alert labels",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1, withNotifiers),
+ channels: channels1,
+ },
+ initialUAState: modifiedState(sh.uaState(t, channels1, modifiedPairs(pairs1, withNotifierLabels)), func(state *uaState) {
+ // Update all the contact points names. Done here so we simulate it being done post-migration.
+ for i := 0; i < 10; i++ {
+ state.amConfig.AlertmanagerConfig.Receivers[i+1].Name += modifiedSuffix
+ state.amConfig.AlertmanagerConfig.Receivers[i+1].GrafanaManagedReceivers[0].Name += modifiedSuffix
+ state.amConfig.AlertmanagerConfig.Route.Routes[0].Routes[i].Receiver += modifiedSuffix
+ }
+ }),
+ operations: []testOp{
+ {
+ description: "re-migrated alerts should get the label to route to the correct contact point",
+ operation: migrateAllDashboardAlertsOp(false),
+ expectedUAState: modifiedState(sh.uaState(t, channels1, modifiedPairs(pairs1, withNotifierLabels)), func(state *uaState) {
+ for i := range pairs1 {
+ state.serviceState.MigratedDashboards[0].MigratedAlerts[i].AlertRule.SendsTo = []string{channels1[i].Name + modifiedSuffix}
+ state.serviceState.MigratedChannels[i].ContactPointUpgrade.Name += modifiedSuffix
+ }
+ for i := 0; i < 10; i++ {
+ state.amConfig.AlertmanagerConfig.Receivers[i+1].Name += modifiedSuffix
+ state.amConfig.AlertmanagerConfig.Receivers[i+1].GrafanaManagedReceivers[0].Name += modifiedSuffix
+ state.amConfig.AlertmanagerConfig.Route.Routes[0].Routes[i].Receiver += modifiedSuffix
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "alert labels are correct when when alert is migrated before channel",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+
+ initialLegacyState: legacyState{
+ alerts: modifiedAlerts(alerts1[:5:5], withNotifiers),
+ channels: channels1[:5:5],
+ },
+ operations: []testOp{
+ {
+ description: "initial migration of first 5 alerts and channels",
+ operation: migrateOrgOp,
+ expectedUAState: sh.uaState(t, channels1[:5:5], modifiedPairs(pairs1[:5:5], withNotifierLabels)),
+ },
+ {
+ description: "add the last 5 alerts and channels, migrate just the last 5 alerts",
+ newLegacyState: &legacyState{
+ alerts: modifiedAlerts(alerts1[5:10:10], withNotifiers),
+ channels: channels1[5:10:10],
+ },
+ operation: migrateAllDashboardAlertsOp(true),
+ },
+ {
+ description: "migrate the last 5 channels",
+ operation: migrateAllChannelsOp(true),
+ expectedUAState: sh.uaState(t, channels1, modifiedPairs(pairs1, withNotifierLabels)),
+ },
+ },
+ },
+ {
+ name: "empty folders previously created by migration should be deleted",
+ orgToMigrate: 1,
+ folders: []*dashboards.Dashboard{f1, f2},
+ dashboards: []*dashboards.Dashboard{d1},
+ initialLegacyState: legacyState{
+ alerts: alerts1,
+ },
+ initialUAState: &uaState{
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ CreatedFolders: []string{f1.UID},
+ },
+ },
+ operations: []testOp{
+ {
+ description: "initial migration",
+ operation: migrateOrgOp,
+ expectedUAState: func() *uaState {
+ state := sh.uaState(t, nil, pairs1)
+ state.migState.CreatedFolders = []string{f1.UID}
+ return state
+ }(),
+ },
+ {
+ description: "move dashboard d1 to folder f2",
+ operation: func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ d1Copy := *d1
+ //nolint:staticcheck
+ d1Copy.FolderID = f2.ID
+ _, err := x.ID(d1.ID).Update(d1Copy)
+ return err
+ },
+ },
+ {
+ description: "migrate with skipExisting=false should move all the alerts to f2 and cleanup f1",
+ operation: migrateDashboardAlertsOp(false, d1.ID),
+ expectedUAState: modifiedState(sh.uaState(t, nil, modifiedPairs(pairs1, func(p *migmodels.AlertPair) { p.Rule.NamespaceUID = f2.UID })), func(state *uaState) {
+ state.serviceState.MigratedDashboards[0].FolderUID = f2.UID
+ state.serviceState.MigratedDashboards[0].FolderName = f2.Title
+ }),
+ },
+ },
+ },
+ {
+ name: "unmigrated channels should show up in GetOrgMigration state",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1},
+ operations: []testOp{
+ {
+ operation: migrateChannelOp(channels1[0].ID),
+ expectedUAState: modifiedState(sh.uaState(t, channels1[0:1:1]), func(state *uaState) {
+ for _, c := range channels1[1:] {
+ state.serviceState.MigratedChannels = append(state.serviceState.MigratedChannels, &definitions.ContactPair{
+ LegacyChannel: fromLegacyChannel(c),
+ Error: "channel not upgraded",
+ })
+ }
+ }),
+ },
+ },
+ },
+ {
+ name: "channels deleted after migration should show up in GetOrgMigration state",
+ orgToMigrate: 1,
+ initialLegacyState: legacyState{channels: channels1[0:9:9]},
+ initialUAState: sh.uaState(t, channels1), // Existing state has channels1[9].
+ operations: []testOp{
+ {
+ expectedUAState: modifiedState(sh.uaState(t, channels1), func(state *uaState) {
+ for _, c := range state.serviceState.MigratedChannels {
+ if c.LegacyChannel.ID == channels1[9].ID {
+ c.LegacyChannel = &definitions.LegacyChannel{ID: c.LegacyChannel.ID}
+ c.Error = "channel no longer exists"
+ }
+ }
+ }),
+ },
+ },
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ tcRun(t, tt)
+ })
+ }
+}
+
+var migrateOrgOp = func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ _, err := service.MigrateOrg(ctx, tt.orgToMigrate, tt.skipExisting)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+var migrateAllDashboardAlertsOp = func(skipExisting bool) func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ return func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ _, err := service.MigrateAllDashboardAlerts(ctx, tt.orgToMigrate, skipExisting)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+var migrateAllChannelsOp = func(skipExisting bool) func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ return func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ _, err := service.MigrateAllChannels(ctx, tt.orgToMigrate, skipExisting)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+var migrateDashboardAlertsOp = func(skipExisting bool, ids ...int64) func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ return func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ for _, id := range ids {
+ _, err := service.MigrateDashboardAlerts(ctx, tt.orgToMigrate, id, skipExisting)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+}
+
+var migrateChannelOp = func(ids ...int64) func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ return func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ for _, id := range ids {
+ _, err := service.MigrateChannel(ctx, tt.orgToMigrate, id)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+}
+
+var migrateAlertOp = func(dashboardId int64, panelIds ...int64) func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ return func(ctx context.Context, tt testcase, service *migrationService, x *xorm.Engine) error {
+ for _, id := range panelIds {
+ _, err := service.MigrateAlert(ctx, tt.orgToMigrate, dashboardId, id)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+}
+
+func tcRun(t *testing.T, tt testcase) {
+ sqlStore := db.InitTestDB(t)
+ x := sqlStore.GetEngine()
+ store := &store.DBstore{
+ SQLStore: sqlStore,
+ Logger: &logtest.Fake{},
+ Cfg: setting.UnifiedAlertingSettings{
+ BaseInterval: 10 * time.Second,
+ DefaultRuleEvaluationInterval: time.Minute,
+ },
+ }
+ service := NewTestMigrationService(t, sqlStore, &setting.Cfg{})
+ defer teardown(t, x, service)
+ setupLegacyAlertsTables(t, x, tt.initialLegacyState.channels, tt.initialLegacyState.alerts, tt.folders, tt.dashboards)
+ if tt.initialUAState == nil {
+ tt.initialUAState = &uaState{}
+ }
+ setupUATables(t, store, tt.orgToMigrate, tt.initialUAState.alerts, tt.initialUAState.amConfig)
+ if tt.initialUAState.migState != nil {
+ require.NoError(t, service.migrationStore.SetOrgMigrationState(context.Background(), tt.orgToMigrate, tt.initialUAState.migState))
+ }
+
+ if tt.dashboardPerms != nil {
+ for uid, perms := range tt.dashboardPerms {
+ _, err := service.migrationStore.SetDashboardPermissions(context.Background(), 1, uid, perms...)
+ require.NoError(t, err)
+ }
+ }
+
+ ctx := context.Background()
+ require.NoError(t, service.migrationStore.SetMigrated(context.Background(), tt.orgToMigrate, true)) // To bypass verification.
+
+ for _, op := range tt.operations {
+ if op.description != "" {
+ t.Logf("Running operation: %s", op.description)
+ }
+ if op.newLegacyState != nil {
+ err := sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ if len(op.newLegacyState.channels) > 0 {
+ _, err := sess.Insert(op.newLegacyState.channels)
+ require.NoError(t, err)
+ }
+ if len(op.newLegacyState.alerts) > 0 {
+ _, err := sess.Insert(op.newLegacyState.alerts)
+ require.NoError(t, err)
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ }
+ if op.updateLegacyState != nil {
+ err := sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
+ for _, c := range op.updateLegacyState.channels {
+ _, err := sess.ID(c.ID).Update(c)
+ require.NoError(t, err)
+ }
+ for _, a := range op.updateLegacyState.alerts {
+ _, err := sess.ID(a.ID).Update(a)
+ require.NoError(t, err)
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ }
+
+ if op.operation != nil {
+ err := op.operation(ctx, tt, service, x)
+ if len(op.expectedErrors) > 0 {
+ for _, expErr := range op.expectedErrors {
+ require.ErrorContains(t, err, expErr)
+ }
+ return
+ }
+ require.NoError(t, err)
+ }
+
+ if op.expectedUAState != nil {
+ compareRules(t, x, tt.orgToMigrate, op.expectedUAState.alerts)
+ compareAmConfig(t, x, tt.orgToMigrate, op.expectedUAState.amConfig)
+ compareState(t, x, service, tt.orgToMigrate, op.expectedUAState.migState, op.expectedUAState.serviceState)
+ }
+ }
+}
+
+func compareRules(t *testing.T, x *xorm.Engine, orgId int64, expectedRules []*models.AlertRule) {
+ if expectedRules == nil {
+ return
+ }
+ rules := make([]*models.AlertRule, 0)
+ err := x.Table("alert_rule").Where("org_id = ?", orgId).Find(&rules)
+ require.NoError(t, err)
+
+ cOpt := []cmp.Option{
+ cmpopts.SortSlices(func(a, b models.AlertRule) bool {
+ return a.Title < b.Title
+ }),
+ cmpopts.IgnoreUnexported(models.AlertRule{}, models.AlertQuery{}),
+ cmpopts.IgnoreFields(models.AlertRule{}, "Updated", "UID", "ID", "Version"),
+ cmpopts.IgnoreMapEntries(func(k string, v string) bool { return k == "rule_uid" }),
+ }
+ if !cmp.Equal(expectedRules, rules, cOpt...) {
+ t.Errorf("Unexpected Rule: %v", cmp.Diff(expectedRules, rules, cOpt...))
+ }
+}
+
+func compareAmConfig(t *testing.T, x *xorm.Engine, orgId int64, expectedConfig *definitions.PostableUserConfig) {
+ if expectedConfig == nil {
+ return
+ }
+ amConfig := getAlertmanagerConfig(t, x, orgId)
+
+ // Order of nested GrafanaManagedReceivers is not guaranteed.
+ cOpt := []cmp.Option{
+ cmpopts.IgnoreUnexported(definitions.PostableApiReceiver{}),
+ cmpopts.IgnoreFields(definitions.PostableGrafanaReceiver{}, "UID", "SecureSettings"),
+ cmpopts.SortSlices(func(a, b *definitions.PostableGrafanaReceiver) bool { return a.Name < b.Name }),
+ cmpopts.SortSlices(func(a, b *definitions.PostableApiReceiver) bool { return a.Name < b.Name }),
+ }
+ if !cmp.Equal(expectedConfig.AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...) {
+ t.Errorf("Unexpected Receivers: %v", cmp.Diff(expectedConfig.AlertmanagerConfig.Receivers, amConfig.AlertmanagerConfig.Receivers, cOpt...))
+ }
+
+ // Order of routes is not guaranteed.
+ cOpt = []cmp.Option{
+ cmpopts.SortSlices(func(a, b *definitions.Route) bool {
+ if a.Receiver != b.Receiver {
+ return a.Receiver < b.Receiver
+ }
+ return a.ObjectMatchers[0].Value < b.ObjectMatchers[0].Value
+ }),
+ cmpopts.IgnoreUnexported(definitions.Route{}, labels.Matcher{}),
+ cmpopts.IgnoreFields(definitions.Route{}, "GroupBy", "GroupByAll"),
+ }
+ if !cmp.Equal(expectedConfig.AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...) {
+ t.Errorf("Unexpected Route: %v", cmp.Diff(expectedConfig.AlertmanagerConfig.Route, amConfig.AlertmanagerConfig.Route, cOpt...))
+ }
+}
+
+func compareState(t *testing.T, x *xorm.Engine, service *migrationService, orgId int64, expectedState *migrationStore.OrgMigrationState, expectedServiceState *definitions.OrgMigrationState) {
+ if expectedState == nil && expectedServiceState == nil {
+ return
+ }
+
+ // Assign real UIDS to expected state for comparison.
+ type ruleUid struct {
+ DashboardID int64 `xorm:"dashboard_id"`
+ PanelID int64 `xorm:"panel_id"`
+ UID string `xorm:"uid"`
+ }
+ ruleUids := make([]ruleUid, 0)
+ err := x.SQL("SELECT d.id as dashboard_id, ar.panel_id, ar.uid FROM alert_rule ar INNER JOIN dashboard d ON d.uid = ar.dashboard_uid WHERE ar.org_id = ?", orgId).Find(&ruleUids)
+ require.NoError(t, err)
+ uidMap := make(map[string]string)
+ for _, r := range ruleUids {
+ if du, ok := expectedState.MigratedDashboards[r.DashboardID]; ok {
+ if _, ok := du.MigratedAlerts[r.PanelID]; ok {
+ uidMap[du.MigratedAlerts[r.PanelID].NewRuleUID] = r.UID
+ du.MigratedAlerts[r.PanelID].NewRuleUID = r.UID
+ }
+ }
+ }
+
+ state, err := service.migrationStore.GetOrgMigrationState(context.Background(), orgId)
+ require.NoError(t, err)
+
+ cOpt := []cmp.Option{
+ cmpopts.SortSlices(func(a, b string) bool { return a < b }),
+ cmpopts.EquateEmpty(),
+ }
+ if !cmp.Equal(expectedState, state, cOpt...) {
+ t.Errorf("Unexpected OrgMigrationState: %v", cmp.Diff(expectedState, state, cOpt...))
+ }
+
+ if expectedServiceState != nil {
+ for _, du := range expectedServiceState.MigratedDashboards {
+ for _, a := range du.MigratedAlerts {
+ if a.AlertRule != nil {
+ a.AlertRule.UID = uidMap[a.AlertRule.UID]
+ }
+ }
+ }
+
+ serviceState, err := service.GetOrgMigrationState(context.Background(), orgId)
+ require.NoError(t, err)
+
+ cOpt := []cmp.Option{
+ cmpopts.SortSlices(func(a, b *definitions.DashboardUpgrade) bool { return a.DashboardID < b.DashboardID }),
+ cmpopts.SortSlices(func(a, b *definitions.AlertPair) bool { return a.LegacyAlert.ID < b.LegacyAlert.ID }),
+ cmpopts.SortSlices(func(a, b *definitions.ContactPair) bool { return a.LegacyChannel.ID < b.LegacyChannel.ID }),
+ cmpopts.IgnoreMapEntries(func(k string, v string) bool { return k == "rule_uid" }),
+ cmpopts.IgnoreUnexported(labels.Matcher{}),
+ cmpopts.EquateEmpty(),
+ }
+ if !cmp.Equal(expectedServiceState, serviceState, cOpt...) {
+ t.Errorf("Unexpected OrgMigrationState: %v", cmp.Diff(expectedServiceState, serviceState, cOpt...))
+ }
+ }
+}
+
+// setupUATables inserts data into the UA tables.
+func setupUATables(t *testing.T, store *store.DBstore, orgID int64, rules []*models.AlertRule, amConfig *definitions.PostableUserConfig) {
+ t.Helper()
+ ctx := context.Background()
+
+ rs := make([]models.AlertRule, 0, len(rules))
+ for _, r := range rules {
+ rs = append(rs, *r)
+ }
+
+ if len(rs) > 0 {
+ _, err := store.InsertAlertRules(ctx, rs)
+ require.NoError(t, err)
+ }
+
+ if amConfig != nil {
+ rawAmConfig, err := json.Marshal(amConfig)
+ require.NoError(t, err)
+ cmd := models.SaveAlertmanagerConfigurationCmd{
+ AlertmanagerConfiguration: string(rawAmConfig),
+ ConfigurationVersion: fmt.Sprintf("v%d", models.AlertConfigurationVersion),
+ Default: false,
+ OrgID: orgID,
+ LastApplied: 0,
+ }
+ err = store.SaveAlertmanagerConfiguration(ctx, &cmd)
+ require.NoError(t, err)
+ }
+}
+
+func createPostableUserConfig(t *testing.T, channels ...*legacymodels.AlertNotification) *definitions.PostableUserConfig {
+ t.Helper()
+ am := &definitions.PostableUserConfig{
+ AlertmanagerConfig: definitions.PostableApiAlertingConfig{
+ Config: definitions.Config{Route: &definitions.Route{
+ Receiver: "autogen-contact-point-default",
+ GroupByStr: []string{models.FolderTitleLabel, model.AlertNameLabel},
+ Routes: []*definitions.Route{
+ {
+ ObjectMatchers: definitions.ObjectMatchers{{Type: labels.MatchEqual, Name: models.MigratedUseLegacyChannelsLabel, Value: "true"}},
+ Continue: true,
+ Routes: []*definitions.Route{},
+ },
+ },
+ }},
+ Receivers: []*definitions.PostableApiReceiver{
+ {Receiver: config.Receiver{Name: "autogen-contact-point-default"}, PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{}},
+ },
+ },
+ }
+ for _, c := range channels {
+ settings, err := c.Settings.MarshalJSON()
+ require.NoError(t, err)
+ am.AlertmanagerConfig.Receivers = append(am.AlertmanagerConfig.Receivers, &definitions.PostableApiReceiver{Receiver: config.Receiver{Name: c.Name}, PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{{UID: c.UID, Name: c.Name, Type: c.Type, Settings: settings}}}})
+ am.AlertmanagerConfig.Route.Routes[0].Routes = append(am.AlertmanagerConfig.Route.Routes[0].Routes, &definitions.Route{Receiver: c.Name, ObjectMatchers: definitions.ObjectMatchers{{Type: labels.MatchEqual, Name: contactLabel(c.Name), Value: "true"}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(DisabledRepeatInterval)})
+ }
+ return am
+}
+
+type serviceHelper struct {
+ t *testing.T
+ dashIncr int64
+ alertIncr int64
+ ruleIncr int64
+ channelIncr int64
+
+ dashes map[int64]*dashboards.Dashboard
+ folders map[int64]*dashboards.Dashboard
+ foldersByUID map[string]*dashboards.Dashboard
+}
+
+func newServiceHelper(t *testing.T) serviceHelper {
+ return serviceHelper{
+ t: t,
+ dashIncr: int64(1),
+ alertIncr: int64(1),
+ ruleIncr: int64(1),
+ channelIncr: int64(1),
+
+ dashes: make(map[int64]*dashboards.Dashboard),
+ folders: make(map[int64]*dashboards.Dashboard),
+ foldersByUID: make(map[string]*dashboards.Dashboard),
+ }
+}
+
+func (h *serviceHelper) genAlerts(d *dashboards.Dashboard, cnt int) []*legacymodels.Alert {
+ d.Title = fmt.Sprintf("dash title%d", h.dashIncr)
+ alerts := make([]*legacymodels.Alert, 0, cnt)
+
+ for i := 0; i < cnt; i++ {
+ a := createAlertWithCond(h.t, 1, int(d.ID), int(h.alertIncr), fmt.Sprintf("alert%d", h.alertIncr), nil,
+ []dashAlertCondition{createCondition("A", "max", "gt", 42, 1, "5m", "now")})
+ a.ID = h.alertIncr
+
+ alerts = append(alerts, a)
+ h.alertIncr++
+ }
+ h.dashIncr++
+ return alerts
+}
+
+func (h *serviceHelper) genFolder() *dashboards.Dashboard {
+ f := createFolder(h.t, h.dashIncr, 1, fmt.Sprintf("folder%d", h.dashIncr))
+ h.dashIncr++
+ h.folders[f.ID] = f
+ h.foldersByUID[f.UID] = f
+ return f
+}
+
+func (h *serviceHelper) genDash(folder *dashboards.Dashboard) *dashboards.Dashboard {
+ d := createDashboard(h.t, h.dashIncr, 1, fmt.Sprintf("dash%d", h.dashIncr), folder.ID, nil)
+ d.Title = fmt.Sprintf("dash title%d", h.dashIncr)
+
+ h.dashIncr++
+ h.dashes[d.ID] = d
+ return d
+}
+
+func (h *serviceHelper) genChannels(cnt int) []*legacymodels.AlertNotification {
+ channels := make([]*legacymodels.AlertNotification, 0, cnt)
+ for i := 0; i < cnt; i++ {
+ c := createAlertNotification(h.t, int64(1), fmt.Sprintf("notifier%d", h.channelIncr), "email", emailSettings, false)
+ c.Name = fmt.Sprintf("notifiername%d", h.channelIncr)
+ c.ID = h.channelIncr
+ channels = append(channels, c)
+ h.channelIncr++
+ }
+ return channels
+}
+
+func (h *serviceHelper) genAlertPairs(f *dashboards.Dashboard, d *dashboards.Dashboard, alerts []*legacymodels.Alert) ([]*models.AlertRule, []*migmodels.AlertPair) {
+ pairs := make([]*migmodels.AlertPair, 0, len(alerts))
+ rules := make([]*models.AlertRule, 0, len(alerts))
+ for _, a := range alerts {
+ uid := util.GenerateShortUID()
+ r := &models.AlertRule{
+ UID: uid,
+ ID: h.ruleIncr,
+ OrgID: 1,
+ Title: a.Name,
+ Condition: "B",
+ Data: []models.AlertQuery{createAlertQuery("A", "ds1-1", "5m", "now"), createClassicConditionQuery("B", []classicCondition{
+ cond("A", "max", "gt", 42),
+ })},
+ IntervalSeconds: 60,
+ Version: 1,
+ NamespaceUID: f.UID,
+ DashboardUID: pointer(d.UID),
+ PanelID: pointer(a.PanelID),
+ RuleGroup: fmt.Sprintf("%s - 1m", d.Title),
+ RuleGroupIndex: 1,
+ NoDataState: models.NoData,
+ ExecErrState: models.AlertingErrState,
+ For: 60 * time.Second,
+ Annotations: map[string]string{
+ models.MigratedAlertIdAnnotation: fmt.Sprintf("%d", a.ID),
+ models.MigratedMessageAnnotation: "message",
+ models.DashboardUIDAnnotation: d.UID,
+ models.PanelIDAnnotation: fmt.Sprintf("%d", a.PanelID),
+ },
+ Labels: map[string]string{
+ models.MigratedUseLegacyChannelsLabel: "true",
+ "rule_uid": uid,
+ },
+ IsPaused: false,
+ }
+ for _, v := range extractChannelIds(h.t, a) {
+ id := v.ID
+ if id != 0 {
+ // Relies on the naming pattern.
+ r.Labels[contactLabel(fmt.Sprintf("notifiername%d", id))] = "true"
+ }
+ }
+ rules = append(rules, r)
+ pairs = append(pairs, &migmodels.AlertPair{
+ LegacyRule: a,
+ Rule: r,
+ })
+ h.ruleIncr++
+ }
+ return rules, pairs
+}
+
+func (h *serviceHelper) dashUpgrade(dashboardID int64, alertFolderUID string, migPairs []*migmodels.AlertPair, warning string) *migrationStore.DashboardUpgrade {
+ return &migrationStore.DashboardUpgrade{
+ DashboardID: dashboardID,
+ AlertFolderUID: alertFolderUID,
+ MigratedAlerts: func() map[int64]*migrationStore.AlertPair {
+ pairs := make(map[int64]*migrationStore.AlertPair, len(migPairs))
+ for _, p := range migPairs {
+ channelsIds := make([]int64, 0)
+ for _, v := range extractChannelIds(h.t, p.LegacyRule) {
+ channelsIds = append(channelsIds, v.ID)
+ }
+ pair := migrationStore.AlertPair{
+ LegacyID: p.LegacyRule.ID,
+ PanelID: p.LegacyRule.PanelID,
+ NewRuleUID: p.Rule.UID,
+ ChannelIDs: channelsIds,
+ }
+ if p.Error != nil {
+ pair.Error = p.Error.Error()
+ }
+ pairs[p.LegacyRule.PanelID] = &pair
+ }
+ return pairs
+ }(),
+ Warning: warning,
+ }
+}
+
+func (h *serviceHelper) contactPairs(c ...*legacymodels.AlertNotification) map[int64]*migrationStore.ContactPair {
+ pairs := make(map[int64]*migrationStore.ContactPair, len(c))
+ for _, ch := range c {
+ pairs[ch.ID] = &migrationStore.ContactPair{
+ LegacyID: ch.ID,
+ NewReceiverUID: ch.UID,
+ Error: "",
+ }
+ }
+ return pairs
+}
+
+func (h *serviceHelper) uaState(t *testing.T, channels []*legacymodels.AlertNotification, dashPairs ...[]*migmodels.AlertPair) *uaState {
+ s := &uaState{
+ migState: &migrationStore.OrgMigrationState{
+ OrgID: 1,
+ },
+ serviceState: h.serviceState(channels, dashPairs...),
+ }
+
+ if len(channels) > 0 {
+ s.amConfig = createPostableUserConfig(t, channels...)
+ s.migState.MigratedChannels = h.contactPairs(channels...)
+ }
+
+ if len(dashPairs) > 0 {
+ s.migState.MigratedDashboards = map[int64]*migrationStore.DashboardUpgrade{}
+ for _, pairs := range dashPairs {
+ for _, p := range pairs {
+ s.alerts = append(s.alerts, p.Rule)
+ }
+ s.migState.MigratedDashboards[pairs[0].LegacyRule.DashboardID] = h.dashUpgrade(pairs[0].LegacyRule.DashboardID, pairs[0].Rule.NamespaceUID, pairs, "")
+ }
+ }
+
+ return s
+}
+
+func (h *serviceHelper) serviceState(channels []*legacymodels.AlertNotification, dashPairs ...[]*migmodels.AlertPair) *definitions.OrgMigrationState {
+ state := &definitions.OrgMigrationState{
+ OrgID: 1,
+ MigratedDashboards: []*definitions.DashboardUpgrade{},
+ MigratedChannels: []*definitions.ContactPair{},
+ }
+ channelName := make(map[int64]string)
+ for _, c := range channels {
+ channelName[c.ID] = c.Name
+ }
+
+ for _, pairs := range dashPairs {
+ d := h.dashes[pairs[0].LegacyRule.DashboardID]
+ //nolint:staticcheck
+ f := h.folders[d.FolderID]
+ f2 := h.foldersByUID[pairs[0].Rule.NamespaceUID]
+ du := &definitions.DashboardUpgrade{
+ DashboardID: d.ID,
+ DashboardUID: d.UID,
+ DashboardName: d.Title,
+ FolderUID: f.UID,
+ FolderName: f.Title,
+ NewFolderUID: f2.UID,
+ NewFolderName: f2.Title,
+ Provisioned: false,
+ Warning: "",
+ }
+ for _, pair := range pairs {
+ var sendsTo []string
+ for _, v := range extractChannelIds(h.t, pair.LegacyRule) {
+ sendsTo = append(sendsTo, channelName[v.ID])
+ }
+ if len(sendsTo) == 0 {
+ sendsTo = []string{"autogen-contact-point-default"}
+ }
+ p := &definitions.AlertPair{
+ LegacyAlert: fromLegacyAlert(pair.LegacyRule),
+ AlertRule: fromAlertRuleUpgrade(pair.Rule, sendsTo),
+ }
+ if pair.Error != nil {
+ p.Error = pair.Error.Error()
+ }
+ du.MigratedAlerts = append(du.MigratedAlerts, p)
+ }
+ state.MigratedDashboards = append(state.MigratedDashboards, du)
+ }
+ if len(channels) > 0 {
+ for _, c := range channels {
+ route, _ := createRoute(c, c.Name)
+ state.MigratedChannels = append(state.MigratedChannels, &definitions.ContactPair{
+ LegacyChannel: fromLegacyChannel(c),
+ ContactPointUpgrade: &definitions.ContactPointUpgrade{
+ Name: c.Name,
+ Type: c.Type,
+ RouteMatchers: route.ObjectMatchers,
+ },
+ })
+ }
+ }
+ return state
+}
+
+func copyMap(m map[string]string) map[string]string {
+ c := make(map[string]string, len(m))
+ for k, v := range m {
+ c[k] = v
+ }
+ return c
+}
+
+func copyAlerts(alerts ...*legacymodels.Alert) []*legacymodels.Alert {
+ copies := make([]*legacymodels.Alert, len(alerts))
+ for i, a := range alerts {
+ c := *a
+ settingsMap := c.Settings.MustMap()
+ c.Settings = simplejson.New()
+ for k, v := range settingsMap {
+ c.Settings.Set(k, v)
+ }
+
+ copies[i] = &c
+ }
+ return copies
+}
+
+func copyRules(rules ...*models.AlertRule) []*models.AlertRule {
+ copies := make([]*models.AlertRule, len(rules))
+ for i, a := range rules {
+ c := *a
+ c.Labels = copyMap(c.Labels)
+ c.Annotations = copyMap(c.Annotations)
+ copies[i] = &c
+ }
+ return copies
+}
+
+func copyChannels(channels ...*legacymodels.AlertNotification) []*legacymodels.AlertNotification {
+ copies := make([]*legacymodels.AlertNotification, len(channels))
+ for i, a := range channels {
+ c := *a
+ copies[i] = &c
+ }
+ return copies
+}
+
+func copyPairs(pairs ...*migmodels.AlertPair) []*migmodels.AlertPair {
+ newPairs := make([]*migmodels.AlertPair, len(pairs))
+ for i, pair := range pairs {
+ clr := copyAlerts(pair.LegacyRule)[0]
+ cr := copyRules(pair.Rule)[0]
+ newPairs[i] = &migmodels.AlertPair{
+ LegacyRule: clr,
+ Rule: cr,
+ Error: pair.Error,
+ }
+ }
+ return newPairs
+}
+
+func extractChannelIds(t *testing.T, alert *legacymodels.Alert) []notificationKey {
+ b, err := alert.Settings.Get("notifications").ToDB()
+ if err == nil && b != nil {
+ require.NoError(t, err)
+ var nots []notificationKey
+ err = json.Unmarshal(b, ¬s)
+ require.NoError(t, err)
+
+ return nots
+ }
+ return nil
+}
+
+func fromLegacyAlert(alert *legacymodels.Alert) *definitions.LegacyAlert {
+ if alert == nil {
+ return nil
+ }
+ return &definitions.LegacyAlert{
+ ID: alert.ID,
+ DashboardID: alert.DashboardID,
+ PanelID: alert.PanelID,
+ Name: alert.Name,
+ }
+}
+
+func fromAlertRuleUpgrade(rule *models.AlertRule, sendsTo []string) *definitions.AlertRuleUpgrade {
+ if rule == nil {
+ return nil
+ }
+ return &definitions.AlertRuleUpgrade{
+ UID: rule.UID,
+ Title: rule.Title,
+ SendsTo: sendsTo,
+ }
+}
+
+func fromLegacyChannel(channel *legacymodels.AlertNotification) *definitions.LegacyChannel {
+ if channel == nil {
+ return nil
+ }
+ return &definitions.LegacyChannel{
+ ID: channel.ID,
+ Name: channel.Name,
+ Type: channel.Type,
+ }
+}
diff --git a/pkg/services/ngalert/migration/store/database.go b/pkg/services/ngalert/migration/store/database.go
index 1e2240e8da7..c4ff46c1089 100644
--- a/pkg/services/ngalert/migration/store/database.go
+++ b/pkg/services/ngalert/migration/store/database.go
@@ -10,6 +10,8 @@ import (
"strconv"
"strings"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
@@ -37,6 +39,8 @@ type Store interface {
// ReadStore is the database abstraction for read-only migration persistence.
type ReadStore interface {
+ GetAlertmanagerConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, error)
+
GetAllOrgs(ctx context.Context) ([]*org.OrgDTO, error)
GetDatasource(ctx context.Context, datasourceID int64, user identity.Requester) (*datasources.DataSource, error)
@@ -44,6 +48,8 @@ type ReadStore interface {
GetNotificationChannels(ctx context.Context, orgID int64) ([]*legacymodels.AlertNotification, error)
GetNotificationChannel(ctx context.Context, q GetNotificationChannelQuery) (*legacymodels.AlertNotification, error)
+ GetDashboardAlert(ctx context.Context, orgID int64, dashboardID int64, panelID int64) (*legacymodels.Alert, error)
+ GetDashboardAlerts(ctx context.Context, orgID int64, dashboardID int64) ([]*legacymodels.Alert, error)
GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*legacymodels.Alert, int, error)
GetDashboardPermissions(ctx context.Context, user identity.Requester, resourceID string) ([]accesscontrol.ResourcePermission, error)
@@ -57,9 +63,15 @@ type ReadStore interface {
GetCurrentAlertingType(ctx context.Context) (AlertingType, error)
GetOrgMigrationState(ctx context.Context, orgID int64) (*OrgMigrationState, error)
- GetAlertRuleTitles(ctx context.Context, orgID int64, namespaceUIDs ...string) (map[string][]string, error) // NamespaceUID -> Titles
+ GetAlertRuleTitles(ctx context.Context, orgID int64, namespaceUIDs ...string) (map[string][]string, error) // NamespaceUID -> Titles
+ GetRuleLabels(ctx context.Context, orgID int64, ruleUIDs []string) (map[models.AlertRuleKeyWithVersion]data.Labels, error) // Rule UID -> Labels
CaseInsensitive() bool
+
+ // Slims for performance optimization of GetOrgSummary rehydration.
+ GetSlimDashboards(ctx context.Context, orgID int64) (map[int64]SlimDashboard, error)
+ GetSlimOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*SlimAlert, error)
+ GetSlimAlertRules(ctx context.Context, orgID int64) (map[string]*SlimAlertRule, error)
}
// WriteStore is the database abstraction for write migration persistence.
@@ -77,7 +89,13 @@ type WriteStore interface {
SetCurrentAlertingType(ctx context.Context, t AlertingType) error
SetOrgMigrationState(ctx context.Context, orgID int64, summary *OrgMigrationState) error
+ RevertOrg(ctx context.Context, orgID int64) error
RevertAllOrgs(ctx context.Context) error
+
+ DeleteAlertRules(ctx context.Context, orgID int64, alertRuleUIDs ...string) error
+ DeleteFolders(ctx context.Context, orgID int64, uids ...string) error
+
+ UpdateRuleLabels(ctx context.Context, key models.AlertRuleKeyWithVersion, labels data.Labels) error
}
type migrationStore struct {
@@ -219,7 +237,11 @@ func (ms *migrationStore) GetOrgMigrationState(ctx context.Context, orgID int64)
}
if !exists {
- return &OrgMigrationState{OrgID: orgID}, nil
+ return &OrgMigrationState{
+ OrgID: orgID,
+ MigratedDashboards: make(map[int64]*DashboardUpgrade),
+ MigratedChannels: make(map[int64]*ContactPair),
+ }, nil
}
var state OrgMigrationState
@@ -228,6 +250,14 @@ func (ms *migrationStore) GetOrgMigrationState(ctx context.Context, orgID int64)
return nil, err
}
+ if state.MigratedChannels == nil {
+ state.MigratedChannels = make(map[int64]*ContactPair)
+ }
+
+ if state.MigratedDashboards == nil {
+ state.MigratedDashboards = make(map[int64]*DashboardUpgrade)
+ }
+
return &state, nil
}
@@ -272,6 +302,47 @@ func (ms *migrationStore) GetAlertRuleTitles(ctx context.Context, orgID int64, n
return res, err
}
+// GetRuleLabels returns a map of rule UID / version -> labels for all given org and rule uids. Version is needed to
+// update alert rules because of their optimistic locking.
+func (ms *migrationStore) GetRuleLabels(ctx context.Context, orgID int64, ruleUIDs []string) (map[models.AlertRuleKeyWithVersion]data.Labels, error) {
+ res := make(map[models.AlertRuleKeyWithVersion]data.Labels, len(ruleUIDs))
+ err := ms.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
+ type label struct {
+ models.AlertRuleKeyWithVersion `xorm:"extends"`
+ Labels data.Labels
+ }
+ labels := make([]label, 0)
+ err := sess.Table("alert_rule").Cols("uid", "org_id", "version", "labels").Where("org_id = ?", orgID).In("uid", ruleUIDs).Find(&labels)
+ if err != nil {
+ return err
+ }
+
+ for _, l := range labels {
+ res[l.AlertRuleKeyWithVersion] = l.Labels
+ }
+ return nil
+ })
+ return res, err
+}
+
+// UpdateRuleLabels updates the labels of an alert rule. Version is needed to update alert rules because of optimistic locking.
+func (ms *migrationStore) UpdateRuleLabels(ctx context.Context, key models.AlertRuleKeyWithVersion, labels data.Labels) error {
+ return ms.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
+ affected, err := sess.Cols("labels").Where("org_id = ? AND uid = ?", key.OrgID, key.UID).Update(&models.AlertRule{
+ Version: key.Version,
+ Labels: labels,
+ })
+
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return fmt.Errorf("rule with uid %v not found", key)
+ }
+ return nil
+ })
+}
+
// BATCHSIZE is a reasonable SQL batch size to prevent hitting placeholder limits (such as Error 1390 in MySQL) or packet size limits.
const BATCHSIZE = 1000
@@ -295,6 +366,35 @@ func (ms *migrationStore) InsertAlertRules(ctx context.Context, rules ...models.
return nil
}
+// DeleteAlertRules deletes alert rules in a given org by their UIDs.
+func (ms *migrationStore) DeleteAlertRules(ctx context.Context, orgID int64, alertRuleUIDs ...string) error {
+ batches := batchBy(alertRuleUIDs, BATCHSIZE)
+ for _, batch := range batches {
+ err := ms.alertingStore.DeleteAlertRulesByUID(ctx, orgID, batch...)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// GetAlertmanagerConfig returns the alertmanager configuration for the given org.
+func (ms *migrationStore) GetAlertmanagerConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, error) {
+ amConfig, err := ms.alertingStore.GetLatestAlertmanagerConfiguration(ctx, orgID)
+ if err != nil && !errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
+ return nil, err
+ }
+ if amConfig == nil {
+ return nil, nil
+ }
+
+ cfg, err := notifier.Load([]byte(amConfig.AlertmanagerConfiguration))
+ if err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
// SaveAlertmanagerConfiguration saves the alertmanager configuration for the given org.
func (ms *migrationStore) SaveAlertmanagerConfiguration(ctx context.Context, orgID int64, amConfig *apimodels.PostableUserConfig) error {
rawAmConfig, err := json.Marshal(amConfig)
@@ -318,6 +418,62 @@ var revertPermissions = []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
}
+// RevertOrg reverts the migration for a given org, deleting all unified alerting resources such as alert rules and alertmanager
+// configurations as well as all other database resources created during the migration, such as folders.
+func (ms *migrationStore) RevertOrg(ctx context.Context, orgID int64) error {
+ return ms.store.InTransaction(ctx, func(ctx context.Context) error {
+ return ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ if _, err := sess.Exec("DELETE FROM alert_rule WHERE org_id = ?", orgID); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_org_id = ?", orgID); err != nil {
+ return err
+ }
+
+ state, err := ms.GetOrgMigrationState(ctx, orgID)
+ if err != nil {
+ return err
+ }
+ if err := ms.DeleteFolders(ctx, orgID, state.CreatedFolders...); err != nil {
+ ms.log.Warn("Failed to delete migrated folders", "orgId", orgID, "err", err)
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_configuration WHERE org_id = ?", orgID); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM ngalert_configuration WHERE org_id = ?", orgID); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM alert_instance WHERE rule_org_id = ?", orgID); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM kv_store WHERE namespace = ? AND org_id = ?", notifier.KVNamespace, orgID); err != nil {
+ return err
+ }
+
+ if _, err := sess.Exec("DELETE FROM kv_store WHERE namespace = ? AND org_id = ?", KVNamespace, orgID); err != nil {
+ return err
+ }
+
+ files, err := filepath.Glob(filepath.Join(ms.cfg.DataPath, "alerting", strconv.FormatInt(orgID, 10), "silences"))
+ if err != nil {
+ return err
+ }
+ for _, f := range files {
+ if err := os.Remove(f); err != nil {
+ ms.log.Error("Failed to remove silence file", "file", f, "err", err)
+ }
+ }
+
+ return nil
+ })
+ })
+}
+
// RevertAllOrgs reverts the migration, deleting all unified alerting resources such as alert rules, alertmanager configurations, and silence files.
// In addition, it will delete all folders and permissions originally created by this migration, as well as the various migration statuses stored
// in kvstore, both org-specific and anyOrg.
@@ -342,7 +498,7 @@ func (ms *migrationStore) RevertAllOrgs(ctx context.Context) error {
return err
}
if err := ms.DeleteFolders(ctx, o.ID, state.CreatedFolders...); err != nil {
- ms.log.Warn("Failed to delete migrated folders", "orgID", o.ID, "err", err)
+ ms.log.Warn("Failed to delete migrated folders", "orgId", o.ID, "err", err)
continue
}
}
@@ -504,6 +660,39 @@ func (ms *migrationStore) GetNotificationChannel(ctx context.Context, q GetNotif
return &res, err
}
+// GetDashboardAlert loads a single legacy dashboard alerts for the given org and alert id.
+func (ms *migrationStore) GetDashboardAlert(ctx context.Context, orgID int64, dashboardID int64, panelID int64) (*legacymodels.Alert, error) {
+ var dashAlert legacymodels.Alert
+ err := ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ has, err := sess.SQL("select * from alert WHERE org_id = ? AND dashboard_id = ? AND panel_id = ?", orgID, dashboardID, panelID).Get(&dashAlert)
+ if err != nil {
+ return err
+ }
+ if !has {
+ return ErrNotFound
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &dashAlert, nil
+}
+
+// GetDashboardAlerts loads all legacy dashboard alerts for the given org and dashboard.
+func (ms *migrationStore) GetDashboardAlerts(ctx context.Context, orgID int64, dashboardID int64) ([]*legacymodels.Alert, error) {
+ var dashAlerts []*legacymodels.Alert
+ err := ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ return sess.SQL("select * from alert WHERE org_id = ? AND dashboard_id = ?", orgID, dashboardID).Find(&dashAlerts)
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return dashAlerts, nil
+}
+
// GetOrgDashboardAlerts loads all legacy dashboard alerts for the given org mapped by dashboard id.
func (ms *migrationStore) GetOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*legacymodels.Alert, int, error) {
var dashAlerts []*legacymodels.Alert
@@ -552,3 +741,66 @@ func (ms *migrationStore) MapActions(permission accesscontrol.ResourcePermission
func (ms *migrationStore) CaseInsensitive() bool {
return ms.store.GetDialect().SupportEngine()
}
+
+// GetSlimDashboards returns a map of dashboard id -> SlimDashboard for all dashboards in the given org. This is used as a
+// performance optimization to avoid loading the full dashboards in bulk.
+func (ms *migrationStore) GetSlimDashboards(ctx context.Context, orgID int64) (map[int64]SlimDashboard, error) {
+ res := make(map[int64]SlimDashboard)
+ err := ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ dashes := make([]SlimDashboard, 0)
+ err := sess.Table("dashboard").Alias("d").Select("d.id, d.uid, d.title, d.folder_id, dashboard_provisioning.id IS NOT NULL as provisioned").
+ Join("LEFT", "dashboard_provisioning", `d.id = dashboard_provisioning.dashboard_id`).
+ Where("org_id = ?", orgID).Find(&dashes)
+ if err != nil {
+ return err
+ }
+ for _, d := range dashes {
+ res[d.ID] = d
+ }
+ res[0] = SlimDashboard{ID: 0, Title: folder.GeneralFolder.Title}
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return res, nil
+}
+
+// GetSlimOrgDashboardAlerts returns a map of dashboard id -> SlimAlert for all alerts in the given org. This is used as a
+// performance optimization to avoid loading the full alerts in bulk.
+func (ms *migrationStore) GetSlimOrgDashboardAlerts(ctx context.Context, orgID int64) (map[int64][]*SlimAlert, error) {
+ res := make(map[int64][]*SlimAlert)
+ rules := make([]*SlimAlert, 0)
+ err := ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ err := sess.Table("alert").Cols("id", "dashboard_id", "panel_id", "name").Where("org_id = ?", orgID).Find(&rules)
+ if err != nil {
+ return err
+ }
+
+ for _, r := range rules {
+ res[r.DashboardID] = append(res[r.DashboardID], r)
+ }
+ return nil
+ })
+ return res, err
+}
+
+// GetSlimAlertRules returns a map of rule UID -> SlimAlertRule for all alert rules in the given org. This is used as a
+// performance optimization to avoid loading the full alert rules in bulk.
+func (ms *migrationStore) GetSlimAlertRules(ctx context.Context, orgID int64) (map[string]*SlimAlertRule, error) {
+ res := make(map[string]*SlimAlertRule)
+ err := ms.store.WithDbSession(ctx, func(sess *db.Session) error {
+ rules := make([]*SlimAlertRule, 0)
+ err := sess.Table("alert_rule").Cols("uid", "title", "namespace_uid", "labels").Where("org_id = ?", orgID).Find(&rules)
+ if err != nil {
+ return err
+ }
+
+ for _, r := range rules {
+ res[r.UID] = r
+ }
+ return nil
+ })
+ return res, err
+}
diff --git a/pkg/services/ngalert/migration/store/slim_models.go b/pkg/services/ngalert/migration/store/slim_models.go
new file mode 100644
index 00000000000..d884e13d079
--- /dev/null
+++ b/pkg/services/ngalert/migration/store/slim_models.go
@@ -0,0 +1,26 @@
+package store
+
+// SlimAlert is a slimmed down version of the legacy alert model.
+type SlimAlert struct {
+ ID int64 `xorm:"id"`
+ DashboardID int64 `xorm:"dashboard_id"`
+ PanelID int64 `xorm:"panel_id"`
+ Name string
+}
+
+// SlimAlertRule is a slimmed down version of the alert rule model.
+type SlimAlertRule struct {
+ UID string `xorm:"uid"`
+ Title string
+ NamespaceUID string `xorm:"namespace_uid"`
+ Labels map[string]string
+}
+
+// SlimDashboard is a slimmed down version of the dashboard model.
+type SlimDashboard struct {
+ ID int64 `xorm:"id"`
+ FolderID int64 `xorm:"folder_id"`
+ UID string `xorm:"uid"`
+ Title string
+ Provisioned bool
+}
diff --git a/pkg/services/ngalert/migration/store/state.go b/pkg/services/ngalert/migration/store/state.go
index e83a1e420b6..d7c5199c859 100644
--- a/pkg/services/ngalert/migration/store/state.go
+++ b/pkg/services/ngalert/migration/store/state.go
@@ -1,7 +1,30 @@
package store
-// OrgMigrationState contains basic information about the state of an org migration.
+// OrgMigrationState contains information about the state of an org migration.
type OrgMigrationState struct {
- OrgID int64 `json:"orgId"`
- CreatedFolders []string `json:"createdFolders"`
+ OrgID int64 `json:"orgId"`
+ MigratedDashboards map[int64]*DashboardUpgrade `json:"migratedDashboards"`
+ MigratedChannels map[int64]*ContactPair `json:"migratedChannels"`
+ CreatedFolders []string `json:"createdFolders"`
+}
+
+type DashboardUpgrade struct {
+ DashboardID int64 `json:"dashboardId"`
+ AlertFolderUID string `json:"alertFolderUid"`
+ MigratedAlerts map[int64]*AlertPair `json:"migratedAlerts"`
+ Warning string `json:"warning,omitempty"`
+}
+
+type AlertPair struct {
+ LegacyID int64 `json:"legacyId"`
+ PanelID int64 `json:"panelId"`
+ NewRuleUID string `json:"newRuleUid"`
+ ChannelIDs []int64 `json:"channelIds"`
+ Error string `json:"error,omitempty"`
+}
+
+type ContactPair struct {
+ LegacyID int64 `json:"legacyId"`
+ NewReceiverUID string `json:"newReceiverUid"`
+ Error string `json:"error,omitempty"`
}
diff --git a/pkg/services/ngalert/migration/testing.go b/pkg/services/ngalert/migration/testing.go
index 73af30ed211..6cfe5d0df1d 100644
--- a/pkg/services/ngalert/migration/testing.go
+++ b/pkg/services/ngalert/migration/testing.go
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
+ apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store"
fake_secrets "github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -40,3 +41,43 @@ func (ms *fakeMigrationService) Run(_ context.Context) error {
// Do nothing.
return nil
}
+
+func (ms *fakeMigrationService) MigrateAlert(ctx context.Context, orgID int64, dashboardID int64, panelID int64) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) MigrateDashboardAlerts(ctx context.Context, orgID int64, dashboardID int64, skipExisting bool) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) MigrateAllDashboardAlerts(ctx context.Context, orgID int64, skipExisting bool) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) MigrateChannel(ctx context.Context, orgID int64, channelID int64) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) MigrateAllChannels(ctx context.Context, orgID int64, skipExisting bool) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) MigrateOrg(ctx context.Context, orgID int64, skipExisting bool) (apimodels.OrgMigrationSummary, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) GetOrgMigrationState(ctx context.Context, orgID int64) (*apimodels.OrgMigrationState, error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (ms *fakeMigrationService) RevertOrg(ctx context.Context, orgID int64) error {
+ //TODO implement me
+ panic("implement me")
+}
diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go
index d05a5ee0d7c..6277457d377 100644
--- a/pkg/services/ngalert/ngalert.go
+++ b/pkg/services/ngalert/ngalert.go
@@ -339,6 +339,7 @@ func (ng *AlertNG) init() error {
Historian: history,
Hooks: api.NewHooks(ng.Log),
Tracer: ng.tracer,
+ UpgradeService: ng.upgradeService,
}
ng.api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
@@ -373,7 +374,16 @@ func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleSt
// shouldRun determines if AlertNG should init or run anything more than just the migration.
func (ng *AlertNG) shouldRun() bool {
- return ng.Cfg.UnifiedAlerting.IsEnabled()
+ if ng.Cfg.UnifiedAlerting.IsEnabled() {
+ return true
+ }
+
+ // Feature flag will preview UA alongside legacy, so that UA routes are registered but the scheduler remains disabled.
+ if ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingPreviewUpgrade) {
+ return true
+ }
+
+ return false
}
// Run starts the scheduler and Alertmanager.
@@ -392,7 +402,8 @@ func (ng *AlertNG) Run(ctx context.Context) error {
return ng.AlertsRouter.Run(subCtx)
})
- if ng.Cfg.UnifiedAlerting.ExecuteAlerts {
+ // We explicitly check that UA is enabled here in case FlagAlertingPreviewUpgrade is enabled but UA is disabled.
+ if ng.Cfg.UnifiedAlerting.ExecuteAlerts && ng.Cfg.UnifiedAlerting.IsEnabled() {
// Only Warm() the state manager if we are actually executing alerts.
// Doing so when we are not executing alerts is wasteful and could lead
// to misleading rule status queries, as the status returned will be
diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go
index 5011cd6361f..f35cb29411c 100644
--- a/pkg/setting/setting_quota.go
+++ b/pkg/setting/setting_quota.go
@@ -36,19 +36,13 @@ func (cfg *Cfg) readQuotaSettings() {
quota := cfg.Raw.Section("quota")
cfg.Quota.Enabled = quota.Key("enabled").MustBool(false)
- var alertOrgQuota int64
- var alertGlobalQuota int64
- if cfg.UnifiedAlerting.IsEnabled() {
- alertOrgQuota = quota.Key("org_alert_rule").MustInt64(100)
- alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1)
- }
// per ORG Limits
cfg.Quota.Org = OrgQuota{
User: quota.Key("org_user").MustInt64(10),
DataSource: quota.Key("org_data_source").MustInt64(10),
Dashboard: quota.Key("org_dashboard").MustInt64(10),
ApiKey: quota.Key("org_api_key").MustInt64(10),
- AlertRule: alertOrgQuota,
+ AlertRule: quota.Key("org_alert_rule").MustInt64(100),
}
// per User limits
@@ -65,7 +59,7 @@ func (cfg *Cfg) readQuotaSettings() {
ApiKey: quota.Key("global_api_key").MustInt64(-1),
Session: quota.Key("global_session").MustInt64(-1),
File: quota.Key("global_file").MustInt64(-1),
- AlertRule: alertGlobalQuota,
+ AlertRule: quota.Key("global_alert_rule").MustInt64(-1),
Correlations: quota.Key("global_correlations").MustInt64(-1),
}
}
diff --git a/public/api-merged.json b/public/api-merged.json
index 599ef2d9aad..5d5a7c73f85 100644
--- a/public/api-merged.json
+++ b/public/api-merged.json
@@ -11999,6 +11999,20 @@
}
}
},
+ "AlertPair": {
+ "type": "object",
+ "properties": {
+ "alertRule": {
+ "$ref": "#/definitions/AlertRuleUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyAlert": {
+ "$ref": "#/definitions/LegacyAlert"
+ }
+ }
+ },
"AlertQuery": {
"type": "object",
"title": "AlertQuery represents a single query associated with an alert definition.",
@@ -12183,6 +12197,23 @@
}
}
},
+ "AlertRuleUpgrade": {
+ "type": "object",
+ "properties": {
+ "sendsTo": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "title": {
+ "type": "string"
+ },
+ "uid": {
+ "type": "string"
+ }
+ }
+ },
"AlertStateInfoDTO": {
"type": "object",
"properties": {
@@ -13097,6 +13128,20 @@
}
}
},
+ "ContactPair": {
+ "type": "object",
+ "properties": {
+ "contactPoint": {
+ "$ref": "#/definitions/ContactPointUpgrade"
+ },
+ "error": {
+ "type": "string"
+ },
+ "legacyChannel": {
+ "$ref": "#/definitions/LegacyChannel"
+ }
+ }
+ },
"ContactPointExport": {
"type": "object",
"title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.",
@@ -13116,6 +13161,20 @@
}
}
},
+ "ContactPointUpgrade": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "routeMatchers": {
+ "$ref": "#/definitions/ObjectMatchers"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
"ContactPoints": {
"type": "array",
"items": {
@@ -13860,6 +13919,48 @@
}
}
},
+ "DashboardUpgrade": {
+ "type": "object",
+ "properties": {
+ "dashboardId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "dashboardName": {
+ "type": "string"
+ },
+ "dashboardUid": {
+ "type": "string"
+ },
+ "error": {
+ "type": "string"
+ },
+ "folderName": {
+ "type": "string"
+ },
+ "folderUid": {
+ "type": "string"
+ },
+ "migratedAlerts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/AlertPair"
+ }
+ },
+ "newFolderName": {
+ "type": "string"
+ },
+ "newFolderUid": {
+ "type": "string"
+ },
+ "provisioned": {
+ "type": "boolean"
+ },
+ "warning": {
+ "type": "string"
+ }
+ }
+ },
"DashboardVersionMeta": {
"description": "DashboardVersionMeta extends the DashboardVersionDTO with the names\nassociated with the UserIds, overriding the field with the same name from\nthe DashboardVersionDTO model.",
"type": "object",
@@ -15868,6 +15969,21 @@
}
}
},
+ "LegacyChannel": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
"LibraryElementArrayResponse": {
"type": "object",
"title": "LibraryElementArrayResponse is a response struct for an array of LibraryElementDTO.",
@@ -16645,6 +16761,50 @@
}
}
},
+ "OrgMigrationState": {
+ "type": "object",
+ "properties": {
+ "migratedChannels": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/ContactPair"
+ }
+ },
+ "migratedDashboards": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/DashboardUpgrade"
+ }
+ },
+ "orgId": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "OrgMigrationSummary": {
+ "type": "object",
+ "properties": {
+ "hasErrors": {
+ "type": "boolean"
+ },
+ "newAlerts": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "newChannels": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "newDashboards": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "removed": {
+ "type": "boolean"
+ }
+ }
+ },
"OrgUserDTO": {
"type": "object",
"properties": {
@@ -21194,6 +21354,7 @@
}
},
"alertGroups": {
+ "description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@@ -21442,6 +21603,7 @@
}
},
"integration": {
+ "description": "Integration integration",
"type": "object",
"required": [
"name",
@@ -21585,7 +21747,6 @@
}
},
"postableSilence": {
- "description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",
@@ -21651,7 +21812,6 @@
}
},
"receiver": {
- "description": "Receiver receiver",
"type": "object",
"required": [
"active",
diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts
index c0358d60c4a..b934c54ceab 100644
--- a/public/app/core/utils/navBarItem-translations.ts
+++ b/public/app/core/utils/navBarItem-translations.ts
@@ -57,6 +57,8 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.oncall.title', 'OnCall');
case 'alerting-legacy':
return t('nav.alerting-legacy.title', 'Alerting (legacy)');
+ case 'alerting-upgrade':
+ return t('nav.alerting-upgrade.title', 'Alerting upgrade');
case 'alert-home':
return t('nav.alerting-home.title', 'Home');
case 'alert-list':
@@ -203,6 +205,11 @@ export function getNavSubTitle(navId: string | undefined) {
return t('nav.library-panels.subtitle', 'Reusable panels that can be added to multiple dashboards');
case 'alerting':
return t('nav.alerting.subtitle', 'Learn about problems in your systems moments after they occur');
+ case 'alerting-upgrade':
+ return t(
+ 'nav.alerting-upgrade.subtitle',
+ 'Upgrade your existing legacy alerts and notification channels to the new Grafana Alerting'
+ );
case 'alert-list':
return t('nav.alerting-list.subtitle', 'Rules that determine whether an alert will fire');
case 'receivers':
diff --git a/public/app/features/alerting/Upgrade.tsx b/public/app/features/alerting/Upgrade.tsx
new file mode 100644
index 00000000000..5480c226be1
--- /dev/null
+++ b/public/app/features/alerting/Upgrade.tsx
@@ -0,0 +1,1661 @@
+import { css, cx } from '@emotion/css';
+import uFuzzy from '@leeoniya/ufuzzy';
+import { createSelector } from '@reduxjs/toolkit';
+import { debounce } from 'lodash';
+import pluralize from 'pluralize';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import { useLocalStorage } from 'react-use';
+
+import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
+import { selectors } from '@grafana/e2e-selectors';
+import { Stack } from '@grafana/experimental';
+import { locationService } from '@grafana/runtime';
+import {
+ Alert,
+ Badge,
+ Button,
+ CallToActionCard,
+ ConfirmModal,
+ FilterInput,
+ HorizontalGroup,
+ Icon,
+ Link,
+ LoadingPlaceholder,
+ Spinner,
+ Tab,
+ TabContent,
+ TabsBar,
+ TagList,
+ Text,
+ TextLink,
+ Tooltip,
+ useStyles2,
+} from '@grafana/ui';
+import { Page } from 'app/core/components/Page/Page';
+import { useQueryParams } from 'app/core/hooks/useQueryParams';
+
+import { getSearchPlaceholder } from '../search/tempI18nPhrases';
+
+import { AlertPair, ContactPair, DashboardUpgrade, OrgMigrationState, upgradeApi } from './unified/api/upgradeApi';
+import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from './unified/components/DynamicTable';
+import { DynamicTableWithGuidelines } from './unified/components/DynamicTableWithGuidelines';
+import { Matchers } from './unified/components/notification-policies/Matchers';
+import { ActionIcon } from './unified/components/rules/ActionIcon';
+import { createContactPointLink, makeDashboardLink, makeFolderLink } from './unified/utils/misc';
+import { createUrl } from './unified/utils/url';
+
+export const UpgradePage = () => {
+ const { useGetOrgUpgradeSummaryQuery } = upgradeApi;
+ const {
+ currentData: summary,
+ isError: isFetchError,
+ error: fetchError,
+ } = useGetOrgUpgradeSummaryQuery(undefined, {
+ pollingInterval: 10000,
+ });
+
+ const alertCount = (summary?.migratedDashboards ?? []).reduce(
+ (acc, cur) => acc + (cur?.migratedAlerts?.length ?? 0),
+ 0
+ );
+ const contactCount = summary?.migratedChannels?.length ?? 0;
+
+ const errors = summary?.errors ?? [];
+ const hasData = alertCount > 0 || contactCount > 0 || errors.length > 0;
+
+ const cancelUpgrade = useMemo(() => {
+ if (!isFetchError && hasData) {
+ return ;
+ }
+ return null;
+ }, [isFetchError, hasData]);
+
+ return (
+
+
+ {isFetchError && (
+
+ {fetchError instanceof Error ? fetchError.message : 'Unknown error.'}
+
+ )}
+ {!isFetchError && !hasData && }
+ {!isFetchError && hasData && (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
+
+interface UpgradeTabsProps {
+ alertCount: number;
+ contactCount: number;
+}
+
+export const UpgradeTabs = ({ alertCount, contactCount }: UpgradeTabsProps) => {
+ const styles = useStyles2(getStyles);
+
+ const [queryParams, setQueryParams] = useQueryParams();
+ const { tab } = getActiveTabFromUrl(queryParams);
+
+ const [activeTab, setActiveTab] = useState(tab);
+
+ useEffect(() => {
+ setActiveTab(tab);
+ }, [tab]);
+
+ return (
+ <>
+
+
+ Preview of how your existing alert rules and notification channels wll be upgraded to the new Grafana
+ Alerting.
+
+ Once you are happy with the results, you can permanently upgrade by modifying the Grafana configuration.
+
+
+ {'For more information, please refer to the '}
+
+ Grafana Alerting Migration Guide
+
+
+ Note:{' '}
+ {'Previewing the upgrade process will not affect your existing legacy alerts and can be stopped at any time.'}
+
+
+ >
+ );
+
+ const cta = (
+
+
+
+
+
+
+
+ );
+
+ return (
+
+
+
How it works
+
+
+
Automatic Upgrade
+
+
+ The upgrade process seamlessly transfers your existing legacy alert rules and notification channels to
+ the new Grafana Alerting system. This means your alerting configurations are preserved during the
+ transition.
+
+
+
Preview and Modification
+
+
+ Alert Rules, Contact Points, and Notification Policies generated during the upgrade are available for
+ your review and potential adjustments. However, please note that they won't actively trigger alerts
+ or send notifications at this stage.
+
+
+
Limitations on Real-Time Updates
+
+
+ Any changes made to your configurations after initiating the upgrade won't be immediately reflected
+ in the summary table. You have the flexibility to re-upgrade specific resources like dashboards, alert
+ rules, and notification channels at any time.
+
+
+
Cancellation and Restart
+
+
+ If necessary, you can cancel and restart the upgrade process. However, it's important to be aware
+ that canceling the upgrade will result in the removal of all Grafana Alerting resources created during
+ the process, including any manual modifications.
+
+
+
Completing the Upgrade
+
+
+ To enable Grafana Alerting, you'll need to modify the Grafana configuration and restart. Until this
+ step is completed, Grafana Alerting will remain inactive.
+
+
+
+
+
+
+
Get started
+
+
+
Step 1: Preview the Upgrade
+
+
+ Start the upgrade process by clicking on "Preview upgrade." This action will display a summary
+ table showing how your existing alert rules and notification channels will be mapped to resources in the
+ new Grafana Alerting system.
+
+
+
Step 2: Investigate and Resolve Errors
+
+
+ Review the previewed upgrade carefully. Alert rules or notification channels that couldn't be
+ automatically upgraded will be marked as errors. You have two options to address these errors:
+
+
+
+ Fix the issues on the legacy side: If possible, resolve the problems within your legacy alerting
+ setup, and then attempt the upgrade again.
+
+
+ Manually create new resources: If fixing legacy issues isn't feasible, manually create new alert
+ rules, notification policies, or contact points in the new Grafana Alerting system to replace the
+ problematic ones.
+
+
+
+
Step 3: Update Your As-Code Setup (Optional)
+
+
+ In the new Grafana Alerting, Legacy Alerting methods of provisioning will no longer work. If you use
+ provisioning to manage alert rules and notification channels, you can export the upgraded versions to
+ generate Grafana Alerting-compatible provisioning files. This can all be done before completeing the
+ upgrade process.
+
+
+
Step 4: Perform the Upgrade to Grafana Alerting
+
+
+ Once you are satisfied with the state of your Grafana Alerting setup, it's time to proceed with the
+ upgrade:
+
+
+
+ Contact your Grafana server administrator to restart Grafana with the [unified_alerting] section
+ enabled in your configuration.
+
+
+ During this process, all organizations that have undergone the above upgrade process will continue to
+ use their configured setup.
+
+
+ Any organization that has not yet started the upgrade process will be automatically upgraded as part
+ of this restart.
+
+
+ Note: If the automatic upgrade fails for any reason, Grafana will not start, so it's safer to
+ address any issues before initiating this step.
+
+
+
+
+
+ For more information, please refer to the
+
+ Grafana Alerting Migration Guide
+
+
+
+
+
+
+
+