Alerting: Add endpoint to revert to a previous alertmanager configuration (#65751)

* Alerting: Add endpoint to revert to a previous alertmanager configuration

This endpoint is meant to be used in conjunction with /api/alertmanager/grafana/config/history to
revert to a previously applied alertmanager configuration. This is done by ID instead of raw config
string in order to avoid secure field complications.
pull/66059/head
Matthew Jacobson 3 years ago committed by GitHub
parent 6c6427e63f
commit 85f738cdf9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 32
      pkg/services/ngalert/api/api_alertmanager.go
  2. 40
      pkg/services/ngalert/api/api_alertmanager_test.go
  3. 2
      pkg/services/ngalert/api/authorization.go
  4. 2
      pkg/services/ngalert/api/authorization_test.go
  5. 4
      pkg/services/ngalert/api/forking_alertmanager.go
  6. 16
      pkg/services/ngalert/api/generated_base_api_alertmanager.go
  7. 8
      pkg/services/ngalert/api/tooling/api.json
  8. 16
      pkg/services/ngalert/api/tooling/definitions/alertmanager.go
  9. 43
      pkg/services/ngalert/api/tooling/post.json
  10. 43
      pkg/services/ngalert/api/tooling/spec.json
  11. 30
      pkg/services/ngalert/notifier/alertmanager_config.go
  12. 71
      pkg/services/ngalert/notifier/multiorg_alertmanager_test.go
  13. 15
      pkg/services/ngalert/notifier/testing.go
  14. 20
      pkg/services/ngalert/store/alertmanager.go
  15. 55
      pkg/services/ngalert/store/alertmanager_test.go
  16. 1
      pkg/services/ngalert/store/database.go

@ -228,6 +228,38 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response
return response.JSON(http.StatusOK, gettableSilences) return response.JSON(http.StatusOK, gettableSilences)
} }
func (srv AlertmanagerSrv) RoutePostGrafanaAlertingConfigHistoryActivate(c *contextmodel.ReqContext, id string) response.Response {
confId, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return ErrResp(http.StatusBadRequest, err, "failed to parse config id")
}
err = srv.mam.ActivateHistoricalConfiguration(c.Req.Context(), c.OrgID, confId)
if err != nil {
var unknownReceiverError notifier.UnknownReceiverError
if errors.As(err, &unknownReceiverError) {
return ErrResp(http.StatusBadRequest, unknownReceiverError, "")
}
var configRejectedError notifier.AlertmanagerConfigRejectedError
if errors.As(err, &configRejectedError) {
return ErrResp(http.StatusBadRequest, configRejectedError, "")
}
if errors.Is(err, store.ErrNoAlertmanagerConfiguration) {
return response.Error(http.StatusNotFound, err.Error(), err)
}
if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) {
return response.Error(http.StatusNotFound, err.Error(), err)
}
if errors.Is(err, notifier.ErrAlertmanagerNotReady) {
return response.Error(http.StatusConflict, err.Error(), err)
}
return ErrResp(http.StatusInternalServerError, err, "")
}
return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration activated"})
}
func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, body apimodels.PostableUserConfig) response.Response { func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, body apimodels.PostableUserConfig) response.Response {
currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgID) currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgID)
// If a config is present and valid we proceed with the guard, otherwise we // If a config is present and valid we proceed with the guard, otherwise we

@ -381,6 +381,46 @@ func TestRouteGetAlertingConfigHistory(t *testing.T) {
}) })
} }
func TestRoutePostGrafanaAlertingConfigHistoryActivate(t *testing.T) {
sut := createSut(t, nil)
t.Run("assert 404 when no historical configurations are found", func(tt *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil)
require.NoError(tt, err)
q := req.URL.Query()
req.URL.RawQuery = q.Encode()
rc := createRequestCtxInOrg(10)
response := sut.RoutePostGrafanaAlertingConfigHistoryActivate(rc, "0")
require.Equal(tt, 404, response.Status())
})
t.Run("assert 202 for a valid org and id", func(tt *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil)
require.NoError(tt, err)
q := req.URL.Query()
req.URL.RawQuery = q.Encode()
rc := createRequestCtxInOrg(1)
response := sut.RoutePostGrafanaAlertingConfigHistoryActivate(rc, "0")
require.Equal(tt, 202, response.Status())
})
t.Run("assert 400 when id is not parseable", func(tt *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil)
require.NoError(tt, err)
q := req.URL.Query()
req.URL.RawQuery = q.Encode()
rc := createRequestCtxInOrg(1)
response := sut.RoutePostGrafanaAlertingConfigHistoryActivate(rc, "abc")
require.Equal(tt, 400, response.Status())
})
}
func TestSilenceCreate(t *testing.T) { func TestSilenceCreate(t *testing.T) {
makeSilence := func(comment string, createdBy string, makeSilence := func(comment string, createdBy string,
startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence {

@ -166,6 +166,8 @@ func (api *API) authorize(method, path string) web.Handler {
case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/alerts": case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/alerts":
// additional authorization is done in the request handler // additional authorization is done in the request handler
eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsWrite)) eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsWrite))
case http.MethodPost + "/api/alertmanager/grafana/config/history/{id}/_activate":
eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsWrite))
case http.MethodGet + "/api/alertmanager/grafana/config/api/v1/receivers": case http.MethodGet + "/api/alertmanager/grafana/config/api/v1/receivers":
eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead) eval = ac.EvalPermission(ac.ActionAlertingNotificationsRead)
case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/receivers/test": case http.MethodPost + "/api/alertmanager/grafana/config/api/v1/receivers/test":

@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) {
} }
paths[p] = methods paths[p] = methods
} }
require.Len(t, paths, 46) require.Len(t, paths, 47)
ac := acmock.New() ac := acmock.New()
api := &API{AccessControl: ac} api := &API{AccessControl: ac}

@ -163,6 +163,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetGrafanaAlertingConfigHistory(ctx
return f.GrafanaSvc.RouteGetAlertingConfigHistory(ctx) return f.GrafanaSvc.RouteGetAlertingConfigHistory(ctx)
} }
func (f *AlertmanagerApiHandler) handleRoutePostGrafanaAlertingConfigHistoryActivate(ctx *contextmodel.ReqContext, id string) response.Response {
return f.GrafanaSvc.RoutePostGrafanaAlertingConfigHistoryActivate(ctx, id)
}
func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilence(ctx *contextmodel.ReqContext, id string) response.Response { func (f *AlertmanagerApiHandler) handleRouteGetGrafanaSilence(ctx *contextmodel.ReqContext, id string) response.Response {
return f.GrafanaSvc.RouteGetSilence(ctx, id) return f.GrafanaSvc.RouteGetSilence(ctx, id)
} }

@ -42,6 +42,7 @@ type AlertmanagerApi interface {
RoutePostAMAlerts(*contextmodel.ReqContext) response.Response RoutePostAMAlerts(*contextmodel.ReqContext) response.Response
RoutePostAlertingConfig(*contextmodel.ReqContext) response.Response RoutePostAlertingConfig(*contextmodel.ReqContext) response.Response
RoutePostGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response RoutePostGrafanaAlertingConfig(*contextmodel.ReqContext) response.Response
RoutePostGrafanaAlertingConfigHistoryActivate(*contextmodel.ReqContext) response.Response
RoutePostTestGrafanaReceivers(*contextmodel.ReqContext) response.Response RoutePostTestGrafanaReceivers(*contextmodel.ReqContext) response.Response
} }
@ -167,6 +168,11 @@ func (f *AlertmanagerApiHandler) RoutePostGrafanaAlertingConfig(ctx *contextmode
} }
return f.handleRoutePostGrafanaAlertingConfig(ctx, conf) return f.handleRoutePostGrafanaAlertingConfig(ctx, conf)
} }
func (f *AlertmanagerApiHandler) RoutePostGrafanaAlertingConfigHistoryActivate(ctx *contextmodel.ReqContext) response.Response {
// Parse Path Parameters
idParam := web.Params(ctx.Req)[":id"]
return f.handleRoutePostGrafanaAlertingConfigHistoryActivate(ctx, idParam)
}
func (f *AlertmanagerApiHandler) RoutePostTestGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response { func (f *AlertmanagerApiHandler) RoutePostTestGrafanaReceivers(ctx *contextmodel.ReqContext) response.Response {
// Parse Request Body // Parse Request Body
conf := apimodels.TestReceiversConfigBodyParams{} conf := apimodels.TestReceiversConfigBodyParams{}
@ -408,6 +414,16 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApi, m *metrics
m, m,
), ),
) )
group.Post(
toMacaronPath("/api/alertmanager/grafana/config/history/{id}/_activate"),
api.authorize(http.MethodPost, "/api/alertmanager/grafana/config/history/{id}/_activate"),
metrics.Instrument(
http.MethodPost,
"/api/alertmanager/grafana/config/history/{id}/_activate",
srv.RoutePostGrafanaAlertingConfigHistoryActivate,
m,
),
)
group.Post( group.Post(
toMacaronPath("/api/alertmanager/grafana/config/api/v1/receivers/test"), toMacaronPath("/api/alertmanager/grafana/config/api/v1/receivers/test"),
api.authorize(http.MethodPost, "/api/alertmanager/grafana/config/api/v1/receivers/test"), api.authorize(http.MethodPost, "/api/alertmanager/grafana/config/api/v1/receivers/test"),

@ -3644,6 +3644,7 @@
"type": "object" "type": "object"
}, },
"alertGroup": { "alertGroup": {
"description": "AlertGroup alert group",
"properties": { "properties": {
"alerts": { "alerts": {
"description": "alerts", "description": "alerts",
@ -3667,6 +3668,7 @@
"type": "object" "type": "object"
}, },
"alertGroups": { "alertGroups": {
"description": "AlertGroups alert groups",
"items": { "items": {
"$ref": "#/definitions/alertGroup" "$ref": "#/definitions/alertGroup"
}, },
@ -3771,6 +3773,7 @@
"type": "object" "type": "object"
}, },
"gettableAlert": { "gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": { "properties": {
"annotations": { "annotations": {
"$ref": "#/definitions/labelSet" "$ref": "#/definitions/labelSet"
@ -3826,13 +3829,13 @@
"type": "object" "type": "object"
}, },
"gettableAlerts": { "gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": { "items": {
"$ref": "#/definitions/gettableAlert" "$ref": "#/definitions/gettableAlert"
}, },
"type": "array" "type": "array"
}, },
"gettableSilence": { "gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": { "properties": {
"comment": { "comment": {
"description": "comment", "description": "comment",
@ -3881,12 +3884,14 @@
"type": "object" "type": "object"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
"type": "array" "type": "array"
}, },
"integration": { "integration": {
"description": "Integration integration",
"properties": { "properties": {
"lastNotifyAttempt": { "lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -4068,7 +4073,6 @@
"type": "object" "type": "object"
}, },
"receiver": { "receiver": {
"description": "Receiver receiver",
"properties": { "properties": {
"active": { "active": {
"description": "active", "description": "active",

@ -61,6 +61,15 @@ import (
// Responses: // Responses:
// 200: GettableHistoricUserConfigs // 200: GettableHistoricUserConfigs
// swagger:route POST /api/alertmanager/grafana/config/history/{id}/_activate alertmanager RoutePostGrafanaAlertingConfigHistoryActivate
//
// revert Alerting configuration to the historical configuration specified by the given id
//
// Responses:
// 202: Ack
// 400: ValidationError
// 404: NotFound
// swagger:route DELETE /api/alertmanager/grafana/config/api/v1/alerts alertmanager RouteDeleteGrafanaAlertingConfig // swagger:route DELETE /api/alertmanager/grafana/config/api/v1/alerts alertmanager RouteDeleteGrafanaAlertingConfig
// //
// deletes the Alerting config for a tenant // deletes the Alerting config for a tenant
@ -458,6 +467,13 @@ type BodyAlertingConfig struct {
Body PostableUserConfig Body PostableUserConfig
} }
// swagger:parameters RoutePostGrafanaAlertingConfigHistoryActivate
type HistoricalConfigId struct {
// Id should be the id of the GettableHistoricUserConfig
// in:path
Id int64 `json:"id"`
}
// alertmanager routes // alertmanager routes
// swagger:parameters RoutePostAlertingConfig RouteGetAlertingConfig RouteDeleteAlertingConfig RouteGetAMStatus RouteGetAMAlerts RoutePostAMAlerts RouteGetAMAlertGroups RouteGetSilences RouteCreateSilence RouteGetSilence RouteDeleteSilence RoutePostAlertingConfig // swagger:parameters RoutePostAlertingConfig RouteGetAlertingConfig RouteDeleteAlertingConfig RouteGetAMStatus RouteGetAMAlerts RoutePostAMAlerts RouteGetAMAlertGroups RouteGetSilences RouteCreateSilence RouteGetSilence RouteDeleteSilence RoutePostAlertingConfig
// testing routes // testing routes

@ -3432,7 +3432,6 @@
"type": "object" "type": "object"
}, },
"URL": { "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": { "properties": {
"ForceQuery": { "ForceQuery": {
"type": "boolean" "type": "boolean"
@ -3468,7 +3467,7 @@
"$ref": "#/definitions/Userinfo" "$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" "type": "object"
}, },
"Userinfo": { "Userinfo": {
@ -3882,6 +3881,7 @@
"type": "object" "type": "object"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
@ -4629,6 +4629,45 @@
] ]
} }
}, },
"/api/alertmanager/grafana/config/history/{id}/_activate": {
"post": {
"description": "revert Alerting configuration to the historical configuration specified by the given id",
"operationId": "RoutePostGrafanaAlertingConfigHistoryActivate",
"parameters": [
{
"description": "Id should be the id of the GettableHistoricUserConfig",
"format": "int64",
"in": "path",
"name": "id",
"required": true,
"type": "integer"
}
],
"responses": {
"202": {
"description": "Ack",
"schema": {
"$ref": "#/definitions/Ack"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
},
"tags": [
"alertmanager"
]
}
},
"/api/alertmanager/{DatasourceUID}/api/v2/alerts": { "/api/alertmanager/{DatasourceUID}/api/v2/alerts": {
"get": { "get": {
"description": "get alertmanager alerts", "description": "get alertmanager alerts",

@ -458,6 +458,45 @@
} }
} }
}, },
"/api/alertmanager/grafana/config/history/{id}/_activate": {
"post": {
"description": "revert Alerting configuration to the historical configuration specified by the given id",
"tags": [
"alertmanager"
],
"operationId": "RoutePostGrafanaAlertingConfigHistoryActivate",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "Id should be the id of the GettableHistoricUserConfig",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "Ack",
"schema": {
"$ref": "#/definitions/Ack"
}
},
"400": {
"description": "ValidationError",
"schema": {
"$ref": "#/definitions/ValidationError"
}
},
"404": {
"description": "NotFound",
"schema": {
"$ref": "#/definitions/NotFound"
}
}
}
}
},
"/api/alertmanager/{DatasourceUID}/api/v2/alerts": { "/api/alertmanager/{DatasourceUID}/api/v2/alerts": {
"get": { "get": {
"description": "get alertmanager alerts", "description": "get alertmanager alerts",
@ -6107,9 +6146,8 @@
} }
}, },
"URL": { "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", "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": { "properties": {
"ForceQuery": { "ForceQuery": {
"type": "boolean" "type": "boolean"
@ -6562,6 +6600,7 @@
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"
}, },
"gettableSilences": { "gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/gettableSilence" "$ref": "#/definitions/gettableSilence"

@ -43,6 +43,36 @@ func (moa *MultiOrgAlertmanager) GetAlertmanagerConfiguration(ctx context.Contex
return moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration) return moa.gettableUserConfigFromAMConfigString(ctx, org, amConfig.AlertmanagerConfiguration)
} }
// ActivateHistoricalConfiguration will set the current alertmanager configuration to a previous value based on the provided
// alert_configuration_history id.
func (moa *MultiOrgAlertmanager) ActivateHistoricalConfiguration(ctx context.Context, orgId int64, id int64) error {
config, err := moa.configStore.GetHistoricalConfiguration(ctx, orgId, id)
if err != nil {
return fmt.Errorf("failed to get historical alertmanager configuration: %w", err)
}
cfg, err := Load([]byte(config.AlertmanagerConfiguration))
if err != nil {
return fmt.Errorf("failed to unmarshal historical alertmanager configuration: %w", err)
}
am, err := moa.AlertmanagerFor(orgId)
if err != nil {
// It's okay if the alertmanager isn't ready yet, we're changing its config anyway.
if !errors.Is(err, ErrAlertmanagerNotReady) {
return err
}
}
if err := am.SaveAndApplyConfig(ctx, cfg); err != nil {
moa.logger.Error("unable to save and apply historical alertmanager configuration", "error", err, "org", orgId, "id", id)
return AlertmanagerConfigRejectedError{err}
}
moa.logger.Info("applied historical alertmanager configuration", "org", orgId, "id", id)
return nil
}
// GetAppliedAlertmanagerConfigurations returns the last n configurations marked as applied for a given org. // GetAppliedAlertmanagerConfigurations returns the last n configurations marked as applied for a given org.
func (moa *MultiOrgAlertmanager) GetAppliedAlertmanagerConfigurations(ctx context.Context, org int64, limit int) ([]*definitions.GettableHistoricUserConfig, error) { func (moa *MultiOrgAlertmanager) GetAppliedAlertmanagerConfigurations(ctx context.Context, org int64, limit int) ([]*definitions.GettableHistoricUserConfig, error) {
configs, err := moa.configStore.GetAppliedConfigurations(ctx, org, limit) configs, err := moa.configStore.GetAppliedConfigurations(ctx, org, limit)

@ -18,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/metrics"
"github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
@ -298,6 +299,76 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) {
} }
} }
func TestMultiOrgAlertmanager_ActivateHistoricalConfiguration(t *testing.T) {
configStore := NewFakeConfigStore(t, map[int64]*models.AlertConfiguration{})
orgStore := &FakeOrgStore{
orgs: []int64{1, 2, 3},
}
tmpDir := t.TempDir()
defaultConfig := `{"template_files":null,"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"email receiver","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}`
cfg := &setting.Cfg{
DataPath: tmpDir,
UnifiedAlerting: setting.UnifiedAlertingSettings{AlertmanagerConfigPollInterval: 3 * time.Minute, DefaultConfiguration: defaultConfig}, // do not poll in tests.
}
kvStore := NewFakeKVStore(t)
provStore := provisioning.NewFakeProvisioningStore()
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
decryptFn := secretsService.GetDecryptedValue
reg := prometheus.NewPedanticRegistry()
m := metrics.NewNGAlert(reg)
mam, err := NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService)
require.NoError(t, err)
ctx := context.Background()
// Ensure that one Alertmanager is created per org.
{
require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx))
require.Len(t, mam.alertmanagers, 3)
}
// First, let's confirm the default configs are active.
cfgs, err := mam.getLatestConfigs(ctx)
require.NoError(t, err)
require.Equal(t, defaultConfig, cfgs[1].AlertmanagerConfiguration)
require.Equal(t, defaultConfig, cfgs[2].AlertmanagerConfiguration)
// Store id for later use.
originalId := cfgs[2].ID
require.Equal(t, defaultConfig, cfgs[3].AlertmanagerConfiguration)
// Now let's save a new config for org 2.
newConfig := `{"template_files":null,"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}`
am, err := mam.AlertmanagerFor(2)
require.NoError(t, err)
postable, err := Load([]byte(newConfig))
require.NoError(t, err)
err = am.SaveAndApplyConfig(ctx, postable)
require.NoError(t, err)
// Verify that the org has the new config.
cfgs, err = mam.getLatestConfigs(ctx)
require.NoError(t, err)
require.Equal(t, newConfig, cfgs[2].AlertmanagerConfiguration)
// First, let's try to activate a historical alertmanager config that doesn't exist.
{
err := mam.ActivateHistoricalConfiguration(ctx, 1, 42)
require.Error(t, err, store.ErrNoAlertmanagerConfiguration)
}
// Finally, we activate the default config for org 2.
{
err := mam.ActivateHistoricalConfiguration(ctx, 2, originalId)
require.NoError(t, err)
}
// Verify that the org has the old default config.
cfgs, err = mam.getLatestConfigs(ctx)
require.NoError(t, err)
require.Equal(t, defaultConfig, cfgs[2].AlertmanagerConfiguration)
}
var brokenConfig = ` var brokenConfig = `
"alertmanager_config": { "alertmanager_config": {
"route": { "route": {

@ -154,6 +154,21 @@ func (f *fakeConfigStore) GetAppliedConfigurations(_ context.Context, orgID int6
return configs, nil return configs, nil
} }
func (f *fakeConfigStore) GetHistoricalConfiguration(_ context.Context, orgID int64, id int64) (*models.HistoricAlertConfiguration, error) {
configsByOrg, ok := f.historicConfigs[orgID]
if !ok {
return &models.HistoricAlertConfiguration{}, store.ErrNoAlertmanagerConfiguration
}
for _, conf := range configsByOrg {
if conf.ID == id && conf.OrgID == orgID {
return conf, nil
}
}
return &models.HistoricAlertConfiguration{}, store.ErrNoAlertmanagerConfiguration
}
type FakeOrgStore struct { type FakeOrgStore struct {
orgs []int64 orgs []int64
} }

@ -189,6 +189,26 @@ func (st *DBstore) GetAppliedConfigurations(ctx context.Context, orgID int64, li
return configs, nil return configs, nil
} }
// GetHistoricalConfiguration returns a single historical configuration based on provided org and id.
func (st *DBstore) GetHistoricalConfiguration(ctx context.Context, orgID int64, id int64) (*models.HistoricAlertConfiguration, error) {
var config models.HistoricAlertConfiguration
if err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
ok, err := sess.Table("alert_configuration_history").
Where("id = ? AND org_id = ?", id, orgID).
Get(&config)
if err != nil {
return err
}
if !ok {
return ErrNoAlertmanagerConfiguration
}
return nil
}); err != nil {
return nil, err
}
return &config, nil
}
func (st *DBstore) deleteOldConfigurations(ctx context.Context, orgID int64, limit int) (int64, error) { func (st *DBstore) deleteOldConfigurations(ctx context.Context, orgID int64, limit int) (int64, error) {
if limit < 1 { if limit < 1 {
return 0, fmt.Errorf("failed to delete old configurations: limit is set to '%d' but needs to be > 0", limit) return 0, fmt.Errorf("failed to delete old configurations: limit is set to '%d' but needs to be > 0", limit)

@ -425,6 +425,61 @@ func TestIntegrationGetAppliedConfigurations(t *testing.T) {
}) })
} }
func TestIntegrationGetHistoricalConfiguration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
sqlStore := db.InitTestDB(t)
store := &DBstore{
SQLStore: sqlStore,
Logger: log.NewNopLogger(),
}
// Tracks the autogenerated PK for the history table.
var historyTablePK int64 = 0
t.Run("no configurations = error", func(tt *testing.T) {
_, err := store.GetHistoricalConfiguration(context.Background(), 10, 10)
require.Error(tt, err)
})
t.Run("correct configurations should be returned", func(tt *testing.T) {
ctx := context.Background()
var org int64 = 1
setupConfigInOrg(t, "testa", org, store)
historyTablePK += 1
setupConfigInOrg(t, "testb", org, store)
historyTablePK += 1
cfg, err := store.GetHistoricalConfiguration(ctx, org, historyTablePK)
require.NoError(tt, err)
// Check that the returned configuration is the one that we're expecting.
require.Equal(tt, "testb", cfg.AlertmanagerConfiguration)
})
t.Run("configurations from other orgs should not be retrievable by id", func(tt *testing.T) {
ctx := context.Background()
var org int64 = 1
setupConfigInOrg(t, "test1", org, store)
historyTablePK += 1
// Create a config in a different org.
var otherOrg int64 = 2
setupConfigInOrg(t, "test2", otherOrg, store)
historyTablePK += 1
// Sanity check that config is retrievable with correct org and id.
cfg, err := store.GetHistoricalConfiguration(ctx, otherOrg, historyTablePK)
require.NoError(tt, err)
require.Equal(tt, "test2", cfg.AlertmanagerConfiguration)
// Verify that we cannot retrieve the config from org=2 when passing in org=1.
_, err = store.GetHistoricalConfiguration(ctx, org, historyTablePK)
require.Error(tt, err, ErrNoAlertmanagerConfiguration)
})
}
func setupConfig(t *testing.T, config string, store *DBstore) (string, string) { func setupConfig(t *testing.T, config string, store *DBstore) (string, string) {
t.Helper() t.Helper()
return setupConfigInOrg(t, config, 1, store) return setupConfigInOrg(t, config, 1, store)

@ -29,6 +29,7 @@ type AlertingStore interface {
UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error UpdateAlertmanagerConfiguration(ctx context.Context, cmd *models.SaveAlertmanagerConfigurationCmd) error
MarkConfigurationAsApplied(ctx context.Context, cmd *models.MarkConfigurationAsAppliedCmd) error MarkConfigurationAsApplied(ctx context.Context, cmd *models.MarkConfigurationAsAppliedCmd) error
GetAppliedConfigurations(ctx context.Context, orgID int64, limit int) ([]*models.HistoricAlertConfiguration, error) GetAppliedConfigurations(ctx context.Context, orgID int64, limit int) ([]*models.HistoricAlertConfiguration, error)
GetHistoricalConfiguration(ctx context.Context, orgID int64, id int64) (*models.HistoricAlertConfiguration, error)
} }
// DBstore stores the alert definitions and instances in the database. // DBstore stores the alert definitions and instances in the database.

Loading…
Cancel
Save