Remote Alertmanager: Optionally merge remote state before starting the internal Alertmanager (#107710)

* Remote Alertmanager: Use the same struct for Grafana stat and Mimir full state

* Alertmanager: Add methods to merge nflog and silences

* update grafana/alerting version

* make update-workspace

* update mocks

* remove unnecesary methods from the remote Alertmanager implementation, create separate StateMerger interface

* (WIP) Remote Alertmanager: Optionally merge remote state before starting the internal Alertmanager

* cleanup ngalert.go

* restore defaults.ini

* move state parsing logic to 'remote' package, clean up ngalert.go

* remove GetBase, implement MegeNflog and MergeSilences

* delete fmt.Println

* FetchRemoteState -> GetRemoteState

* UserGrafanaState -> UserState

* remove duplicate clusterpb import

* reorder MimirClient interface

* use general getState() method for Grafana state and Mimir full state

* remove unnecessary state merging methods from the Alertmanager interface

* remove pullState field

* reduce diff

* add info log after merging

* merge silences and nflog entries in the same method

* merge the remote state in the forked AM

* reduce diff

* update remote AM mock

* tests

* make error more specific

* typo
This commit is contained in:
Santiago
2025-07-25 15:06:59 +00:00
committed by GitHub
parent 5f6fc38430
commit dcb965b7dc
9 changed files with 467 additions and 282 deletions
@@ -1074,4 +1074,9 @@ export interface FeatureToggles {
* Enables adhoc filtering support for the dashboard datasource
*/
dashboardDsAdHocFiltering?: boolean;
/**
* Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.
* @default false
*/
alertmanagerRemoteSecondaryWithRemoteState?: boolean;
}
+9
View File
@@ -1859,6 +1859,15 @@ var (
Owner: grafanaDataProSquad,
FrontendOnly: true,
},
{
Name: "alertmanagerRemoteSecondaryWithRemoteState",
Description: "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
HideFromAdminPage: true,
HideFromDocs: true,
Expression: "false",
},
}
)
+1
View File
@@ -240,3 +240,4 @@ alertingNotificationHistory,experimental,@grafana/alerting-squad,false,false,fal
pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,false
unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false
dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true
alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
240 pluginAssetProvider experimental @grafana/plugins-platform-backend false true false
241 unifiedStorageSearchDualReaderEnabled experimental @grafana/search-and-storage false false false
242 dashboardDsAdHocFiltering experimental @grafana/datapro false false true
243 alertmanagerRemoteSecondaryWithRemoteState experimental @grafana/alerting-squad false false false
+4
View File
@@ -970,4 +970,8 @@ const (
// FlagDashboardDsAdHocFiltering
// Enables adhoc filtering support for the dashboard datasource
FlagDashboardDsAdHocFiltering = "dashboardDsAdHocFiltering"
// FlagAlertmanagerRemoteSecondaryWithRemoteState
// Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.
FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState"
)
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -193,7 +193,8 @@ func (ng *AlertNG) init() error {
crypto := notifier.NewCrypto(ng.SecretsService, ng.store, moaLogger)
remotePrimary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemotePrimary)
remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary)
if remotePrimary || remoteSecondary {
remoteSecondaryWithRemoteState := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondaryWithRemoteState)
if remotePrimary || remoteSecondary || remoteSecondaryWithRemoteState {
m := ng.Metrics.GetRemoteAlertmanagerMetrics()
smtpCfg := remoteClient.SmtpConfig{
FromAddress: ng.Cfg.Smtp.FromAddress,
@@ -268,6 +269,10 @@ func (ng *AlertNG) init() error {
cfg.OrgID = orgID
remoteAM, err := createRemoteAlertmanager(ctx, cfg, ng.KVStore, crypto, autogenFn, m, ng.tracer)
if err != nil {
if remoteSecondaryWithRemoteState {
// We can't start the internal Alertmanager without the remote state.
return nil, fmt.Errorf("failed to create remote Alertmanager, can't start the internal Alertmanager without the remote state: %w", err)
}
moaLogger.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err)
return internalAM, nil
}
@@ -84,6 +84,75 @@ func TestForkedAlertmanager_ModeRemoteSecondary(t *testing.T) {
}
})
t.Run("ApplyConfig - with remote state", func(tt *testing.T) {
{
// During the first ApplyConfig call, we should:
// 1. Apply the configuration to the remote Alertmanager
// 2. Merge the remote state
// 3. Apply the configuration to the internal Alertmanager
internal, remote, forked := genTestAlertmanagers(tt, modeRemoteSecondary, withRemoteState)
readyCall := remote.EXPECT().Ready().Return(false).Once()
remote.EXPECT().ApplyConfig(ctx, mock.Anything).Return(nil).Once().NotBefore(readyCall)
remote.EXPECT().Ready().Return(true).Once()
remoteStateCall := remote.EXPECT().GetRemoteState(mock.Anything).Return(notifier.ExternalState{}, nil).Once()
internal.EXPECT().ApplyConfig(ctx, mock.Anything).Return(nil).Once().NotBefore(remoteStateCall)
require.NoError(tt, forked.ApplyConfig(ctx, &models.AlertConfiguration{}))
require.True(tt, internal.mergeStateCalled)
// We shouldn't attempt to merge the remote state again on the next sync loop iteration.
internal.mergeStateCalled = false
remote.EXPECT().Ready().Return(true).Once()
internal.EXPECT().ApplyConfig(ctx, mock.Anything).Return(nil).Once()
remote.EXPECT().CompareAndSendConfiguration(ctx, mock.Anything).Return(nil).Once()
require.NoError(tt, forked.ApplyConfig(ctx, &models.AlertConfiguration{}))
require.False(tt, internal.mergeStateCalled)
}
{
// If we fail to apply the configuration in the remote Alertmanager, we should get an error and not start the internal Alertmanager.
internal, remote, forked := genTestAlertmanagers(tt, modeRemoteSecondary, withSyncInterval(10*time.Minute), withRemoteState)
readyCall := remote.EXPECT().Ready().Return(false).Once()
remote.EXPECT().ApplyConfig(ctx, mock.Anything).Return(expErr).Once().NotBefore(readyCall)
remote.EXPECT().Ready().Return(false).Once()
err := forked.ApplyConfig(ctx, &models.AlertConfiguration{})
require.Equal(tt, "remote Alertmanager not ready, can't fetch remote state", err.Error())
require.False(tt, internal.mergeStateCalled)
// Calling ApplyConfig again should result in the forked Alertmanager calling ApplyConfig on both
// Alertmanagers and merging the remote state, even if the sync interval has not elapsed.
remote.EXPECT().Ready().Return(true).Twice()
remote.EXPECT().CompareAndSendConfiguration(ctx, mock.Anything).Return(nil).Once()
remoteStateCall := remote.EXPECT().GetRemoteState(mock.Anything).Return(notifier.ExternalState{}, nil).Once()
internal.EXPECT().ApplyConfig(ctx, mock.Anything).Return(nil).Once().NotBefore(remoteStateCall)
require.NoError(tt, forked.ApplyConfig(ctx, &models.AlertConfiguration{}))
require.True(tt, internal.mergeStateCalled)
}
{
// An error in the remote Alertmanager should be returned.
// The internal Alertmanager shouldn't be started.
internal, remote, forked := genTestAlertmanagers(tt, modeRemotePrimary)
remote.EXPECT().ApplyConfig(ctx, mock.Anything).Return(expErr).Once()
require.ErrorIs(tt, forked.ApplyConfig(ctx, &models.AlertConfiguration{}), expErr)
require.False(t, internal.mergeStateCalled)
}
{
// An error in the internal Alertmanager should be returned.
internal, remote, forked := genTestAlertmanagers(tt, modeRemoteSecondary, withRemoteState)
internal.EXPECT().ApplyConfig(ctx, mock.Anything).Return(expErr).Once()
// Simulate starting the remote Alertmanager and merging the remote state.
readyCall := remote.EXPECT().Ready().Return(false).Once()
remote.EXPECT().ApplyConfig(ctx, mock.Anything).Return(nil).Once().NotBefore(readyCall)
remote.EXPECT().Ready().Return(true).Once()
remote.EXPECT().GetRemoteState(mock.Anything).Return(notifier.ExternalState{}, nil).Once()
require.ErrorIs(tt, forked.ApplyConfig(ctx, &models.AlertConfiguration{}), expErr)
require.True(t, internal.mergeStateCalled)
}
})
t.Run("SaveAndApplyConfig", func(tt *testing.T) {
// SaveAndApplyConfig should only be called on the remote Alertmanager.
// State and configuration are updated on an interval.
@@ -707,6 +776,11 @@ func (m *internalAlertmanagerMock) MergeState(notifier.ExternalState) error {
return nil
}
func withRemoteState(rsc RemoteSecondaryConfig) RemoteSecondaryConfig {
rsc.WithRemoteState = true
return rsc
}
func withSyncInterval(syncInterval time.Duration) func(RemoteSecondaryConfig) RemoteSecondaryConfig {
return func(rsc RemoteSecondaryConfig) RemoteSecondaryConfig {
rsc.SyncInterval = syncInterval
@@ -13,6 +13,8 @@ import (
models "github.com/grafana/grafana/pkg/services/ngalert/models"
notifier "github.com/grafana/grafana/pkg/services/ngalert/notifier"
notify "github.com/grafana/alerting/notify"
v2models "github.com/prometheus/alertmanager/api/v2/models"
@@ -413,6 +415,62 @@ func (_c *RemoteAlertmanagerMock_GetReceivers_Call) RunAndReturn(run func(contex
return _c
}
// GetRemoteState provides a mock function with given fields: _a0
func (_m *RemoteAlertmanagerMock) GetRemoteState(_a0 context.Context) (notifier.ExternalState, error) {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for GetRemoteState")
}
var r0 notifier.ExternalState
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) (notifier.ExternalState, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(context.Context) notifier.ExternalState); ok {
r0 = rf(_a0)
} else {
r0 = ret.Get(0).(notifier.ExternalState)
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoteAlertmanagerMock_GetRemoteState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRemoteState'
type RemoteAlertmanagerMock_GetRemoteState_Call struct {
*mock.Call
}
// GetRemoteState is a helper method to define mock.On call
// - _a0 context.Context
func (_e *RemoteAlertmanagerMock_Expecter) GetRemoteState(_a0 interface{}) *RemoteAlertmanagerMock_GetRemoteState_Call {
return &RemoteAlertmanagerMock_GetRemoteState_Call{Call: _e.mock.On("GetRemoteState", _a0)}
}
func (_c *RemoteAlertmanagerMock_GetRemoteState_Call) Run(run func(_a0 context.Context)) *RemoteAlertmanagerMock_GetRemoteState_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *RemoteAlertmanagerMock_GetRemoteState_Call) Return(_a0 notifier.ExternalState, _a1 error) *RemoteAlertmanagerMock_GetRemoteState_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *RemoteAlertmanagerMock_GetRemoteState_Call) RunAndReturn(run func(context.Context) (notifier.ExternalState, error)) *RemoteAlertmanagerMock_GetRemoteState_Call {
_c.Call.Return(run)
return _c
}
// GetSilence provides a mock function with given fields: _a0, _a1
func (_m *RemoteAlertmanagerMock) GetSilence(_a0 context.Context, _a1 string) (v2models.GettableSilence, error) {
ret := _m.Called(_a0, _a1)
@@ -22,6 +22,7 @@ type configStore interface {
type remoteAlertmanager interface {
notifier.Alertmanager
CompareAndSendConfiguration(context.Context, *models.AlertConfiguration) error
GetRemoteState(context.Context) (notifier.ExternalState, error)
SendState(context.Context) error
}
@@ -35,6 +36,8 @@ type RemoteSecondaryForkedAlertmanager struct {
lastSync time.Time
syncInterval time.Duration
shouldFetchRemoteState bool
}
type RemoteSecondaryConfig struct {
@@ -45,6 +48,9 @@ type RemoteSecondaryConfig struct {
// SyncInterval determines how often we should attempt to synchronize
// the configuration on the remote Alertmanager.
SyncInterval time.Duration
// WithRemoteState is used to fetch and merge the state from the remote Alertmanager before starting the internal one.
WithRemoteState bool
}
func (c *RemoteSecondaryConfig) Validate() error {
@@ -59,12 +65,13 @@ func NewRemoteSecondaryForkedAlertmanager(cfg RemoteSecondaryConfig, internal no
return nil, err
}
return &RemoteSecondaryForkedAlertmanager{
log: cfg.Logger,
orgID: cfg.OrgID,
store: cfg.Store,
internal: internal,
remote: remote,
syncInterval: cfg.SyncInterval,
log: cfg.Logger,
orgID: cfg.OrgID,
store: cfg.Store,
internal: internal,
remote: remote,
syncInterval: cfg.SyncInterval,
shouldFetchRemoteState: cfg.WithRemoteState,
}, nil
}
@@ -99,6 +106,28 @@ func (fam *RemoteSecondaryForkedAlertmanager) ApplyConfig(ctx context.Context, c
}
}()
if fam.shouldFetchRemoteState {
wg.Wait()
if !fam.remote.Ready() {
return fmt.Errorf("remote Alertmanager not ready, can't fetch remote state")
}
// Pull and merge the remote Alertmanager state.
rs, err := fam.remote.GetRemoteState(ctx)
if err != nil {
return fmt.Errorf("failed to fetch remote state: %w", err)
}
// The internal Alertmanager should implement the StateMerger interface.
sm := fam.internal.(notifier.StateMerger)
if err := sm.MergeState(rs); err != nil {
return fmt.Errorf("failed to merge remote state: %w", err)
}
fam.log.Info("Successfully merged remote silences and nflog entries")
// This operation should only be performed at startup.
fam.shouldFetchRemoteState = false
}
// Call ApplyConfig on the internal Alertmanager - we only care about errors for this one.
err := fam.internal.ApplyConfig(ctx, config)
wg.Wait()