Alerting: Add support for retrieving imported Prometheus Alertmanager configurations (#106864)

This commit is contained in:
Alexander Akhmetov
2025-06-18 10:49:40 +02:00
committed by GitHub
parent 4bfd7b6d7c
commit 8c6df8b449
9 changed files with 550 additions and 151 deletions
+1
View File
@@ -189,6 +189,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
api.DatasourceCache,
api.AlertRules,
api.FeatureManager,
api.MultiOrgAlertmanager,
),
), m)
}
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -10,6 +11,8 @@ import (
"strings"
"time"
amconfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
prommodel "github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
@@ -47,6 +50,13 @@ const (
// notificationSettingsHeader is the header that specifies the notification settings to be used for the rules.
// The value should be a JSON-encoded AlertRuleNotificationSettings object.
notificationSettingsHeader = "X-Grafana-Alerting-Notification-Settings"
// mergeMatchersHeader is the header that specifies the merge matchers for imported Alertmanager config.
// The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting".
mergeMatchersHeader = "X-Grafana-Alerting-Merge-Matchers"
// configIdentifierHeader is the header that specifies the identifier for imported Alertmanager config.
configIdentifierHeader = "X-Grafana-Alerting-Config-Identifier"
)
var (
@@ -113,6 +123,11 @@ type ConvertPrometheusSrv struct {
datasourceCache datasources.CacheService
alertRuleService *provisioning.AlertRuleService
featureToggles featuremgmt.FeatureToggles
am Alertmanager
}
type Alertmanager interface {
GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error)
}
func NewConvertPrometheusSrv(
@@ -122,6 +137,7 @@ func NewConvertPrometheusSrv(
datasourceCache datasources.CacheService,
alertRuleService *provisioning.AlertRuleService,
featureToggles featuremgmt.FeatureToggles,
am Alertmanager,
) *ConvertPrometheusSrv {
return &ConvertPrometheusSrv{
cfg: cfg,
@@ -130,6 +146,7 @@ func NewConvertPrometheusSrv(
datasourceCache: datasourceCache,
alertRuleService: alertRuleService,
featureToggles: featureToggles,
am: am,
}
}
@@ -518,7 +535,47 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostAlertmanagerConfig(c
}
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetAlertmanagerConfig(c *contextmodel.ReqContext) response.Response {
return response.Error(http.StatusNotImplemented, "Not Implemented", nil)
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) {
return response.Error(http.StatusNotImplemented, "Not Implemented", nil)
}
logger := srv.logger.FromContext(c.Req.Context())
ctx := c.Req.Context()
identifier, err := parseConfigIdentifierHeader(c)
if err != nil {
logger.Error("failed to parse config identifier header", "err", err)
return errorToResponse(err)
}
cfg, err := srv.am.GetAlertmanagerConfiguration(ctx, c.GetOrgID(), false)
if err != nil {
logger.Error("failed to get alertmanager configuration", "err", err)
return errorToResponse(err)
}
var extraCfg *apimodels.ExtraConfiguration
for i := range cfg.ExtraConfigs {
if cfg.ExtraConfigs[i].Identifier == identifier {
extraCfg = &cfg.ExtraConfigs[i]
break
}
}
if extraCfg == nil {
return response.Error(http.StatusNotFound, "Alertmanager configuration not found", nil)
}
respBody := apimodels.AlertmanagerUserConfig{
AlertmanagerConfig: extraCfg.AlertmanagerConfig,
TemplateFiles: extraCfg.TemplateFiles,
}
resp := response.YAML(http.StatusOK, respBody)
resp.SetHeader(configIdentifierHeader, extraCfg.Identifier)
resp.SetHeader(mergeMatchersHeader, formatMergeMatchers(extraCfg.MergeMatchers))
return resp
}
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteAlertmanagerConfig(c *contextmodel.ReqContext) response.Response {
@@ -647,3 +704,55 @@ func parseNotificationSettingsHeader(ctx *contextmodel.ReqContext) ([]models.Not
return notificationSettings, nil
}
// parseMergeMatchersHeader parses the merge matchers header value.
// Expected format: "key1=value1,key2=value2"
func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, error) {
matchersStr := strings.TrimSpace(c.Req.Header.Get(mergeMatchersHeader))
if matchersStr == "" {
return amconfig.Matchers{}, errInvalidHeaderValue(mergeMatchersHeader, errors.New("value cannot be empty"))
}
matchers := amconfig.Matchers{}
for pair := range strings.SplitSeq(matchersStr, ",") {
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(parts) != 2 {
return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("format should be 'key=value,key2=value2'"))
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "" || value == "" {
return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("keys and values cannot be empty"))
}
matchers = append(matchers, &labels.Matcher{
Type: labels.MatchEqual,
Name: key,
Value: value,
})
}
return matchers, nil
}
func formatMergeMatchers(matchers amconfig.Matchers) string {
var pairs []string
for _, matcher := range matchers {
if matcher.Type == labels.MatchEqual {
pairs = append(pairs, fmt.Sprintf("%s=%s", matcher.Name, matcher.Value))
}
}
return strings.Join(pairs, ",")
}
func parseConfigIdentifierHeader(c *contextmodel.ReqContext) (string, error) {
identifier := strings.TrimSpace(c.Req.Header.Get(configIdentifierHeader))
if identifier == "" {
return "", errInvalidHeaderValue(configIdentifierHeader, errors.New("identifier cannot be empty"))
}
return identifier, nil
}
@@ -3,12 +3,16 @@ package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
amconfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
prommodel "github.com/prometheus/common/model"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
@@ -60,7 +64,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
}
t.Run("without datasource UID header should return 400", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "")
@@ -71,7 +75,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with invalid datasource should return error", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "non-existing-ds")
@@ -81,7 +85,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with rule group without evaluation interval should return 202", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
@@ -90,7 +94,8 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
t.Run("should replace an existing rule group", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore))
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// Create a folder in the root
fldr := randFolder()
@@ -152,7 +157,8 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
t.Run("should fail to replace a provisioned rule group", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore))
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// Create a folder in the root
fldr := randFolder()
@@ -187,7 +193,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
t.Run("with no access to the datasource should return 403", func(t *testing.T) {
acFake := &acfakes.FakeRuleService{}
srv, _, _, _ := createConvertPrometheusSrv(t, withFakeAccessControlRuleService(acFake))
srv, _, _ := createConvertPrometheusSrv(t, withFakeAccessControlRuleService(acFake))
acFake.AuthorizeRuleChangesFunc = func(context.Context, identity.Requester, *store.GroupDelta) error {
return datasources.ErrDataSourceAccessDenied
@@ -203,7 +209,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
quotas := &provisioning.MockQuotaChecker{}
quotas.EXPECT().LimitExceeded()
srv, _, _, _ := createConvertPrometheusSrv(t, withQuotaChecker(quotas))
srv, _, _ := createConvertPrometheusSrv(t, withQuotaChecker(quotas))
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup)
@@ -241,7 +247,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
@@ -274,7 +280,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
@@ -286,7 +292,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with valid request should return 202", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
@@ -315,7 +321,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
features := featuremgmt.WithFeatures()
srv, _, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features))
srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features))
srv.cfg.RecordingRules.Enabled = tc.recordingRules
rc := createRequestCtx()
@@ -327,7 +333,8 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
t.Run("with disable provenance header should use ProvenanceNone", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore))
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// Create a folder in the root
fldr := randFolder()
@@ -361,7 +368,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("returns error when target datasource does not exist", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(targetDatasourceUIDHeader, "some-data-source")
@@ -371,7 +378,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("uses target datasource for recording rules", func(t *testing.T) {
srv, dsCache, ruleStore, _ := createConvertPrometheusSrv(t)
srv, dsCache, ruleStore := createConvertPrometheusSrv(t)
rc := createRequestCtx()
targetDSUID := util.GenerateShortUID()
ds := &datasources.DataSource{
@@ -408,7 +415,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("sets notification settings for rules if specified", func(t *testing.T) {
srv, _, ruleStore, _ := createConvertPrometheusSrv(t)
srv, _, ruleStore := createConvertPrometheusSrv(t)
rc := createRequestCtx()
receiver := "test-receiver"
@@ -450,7 +457,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("returns error when notification settings header contains invalid JSON", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(notificationSettingsHeader, "{invalid json")
@@ -472,7 +479,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("returns error when notification settings contain invalid values", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
settings := apimodels.AlertRuleNotificationSettings{
@@ -515,7 +522,7 @@ func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
require.NoError(t, err)
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "non-existent", "test")
@@ -523,7 +530,7 @@ func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
})
t.Run("with non-existent group should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "test", "non-existent")
@@ -531,7 +538,8 @@ func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
})
t.Run("with valid request should return 200", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create two folders in the root folder
@@ -619,7 +627,7 @@ func TestRouteConvertPrometheusGetNamespace(t *testing.T) {
}
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetNamespace(rc, "non-existent")
@@ -627,7 +635,8 @@ func TestRouteConvertPrometheusGetNamespace(t *testing.T) {
})
t.Run("with valid request should return 200", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create two folders in the root folder
@@ -731,12 +740,13 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) {
}
t.Run("for non-existent folder should return empty response", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
assertEmptyResponse(t, srv, rc)
})
t.Run("for existing folder with no children should return empty response", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
fldr := randFolder()
fldr.UID = unknownFolderUID
@@ -757,7 +767,8 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) {
})
t.Run("with rules should return 200 with rules", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create a folder in the root
@@ -797,7 +808,7 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) {
func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
t.Run("for non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent")
@@ -805,7 +816,8 @@ func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
})
t.Run("for existing folder with no groups should return 404", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
fldr := randFolder()
@@ -820,7 +832,8 @@ func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
t.Run("valid request should delete rules", func(t *testing.T) {
initNamespace := func(promDefinition string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, opts...)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...)
// Create a folder in the root
fldr := randFolder()
@@ -927,7 +940,7 @@ func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
t.Run("for non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, "non-existent", "test-group")
@@ -935,7 +948,8 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
})
t.Run("for existing folder with no group should return 404", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
fldr := randFolder()
@@ -952,7 +966,8 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
t.Run("valid request should delete rules", func(t *testing.T) {
initGroup := func(promDefinition string, groupName string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, opts...)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...)
// Create a folder in the root
fldr := randFolder()
@@ -1060,7 +1075,8 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
}
func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
req := createRequestCtx()
req.Req.Header.Set(datasourceUIDHeader, existingDSUID)
@@ -1265,6 +1281,8 @@ type convertPrometheusSrvOptions struct {
fakeAccessControlRuleService *acfakes.FakeRuleService
quotaChecker *provisioning.MockQuotaChecker
featureToggles featuremgmt.FeatureToggles
alertmanager Alertmanager
folderService folder.Service
}
type convertPrometheusSrvOptionsFunc func(*convertPrometheusSrvOptions)
@@ -1293,7 +1311,19 @@ func withFeatureToggles(toggles featuremgmt.FeatureToggles) convertPrometheusSrv
}
}
func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *dsfakes.FakeCacheService, *fakes.RuleStore, *foldertest.FakeService) {
func withAlertmanager(am Alertmanager) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.alertmanager = am
}
}
func withFolderService(f folder.Service) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.folderService = f
}
}
func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *dsfakes.FakeCacheService, *fakes.RuleStore) {
t.Helper()
// By default the quota checker will allow the operation
@@ -1304,6 +1334,7 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
provenanceStore: fakes.NewFakeProvisioningStore(),
fakeAccessControlRuleService: &acfakes.FakeRuleService{},
quotaChecker: quotas,
folderService: foldertest.NewFakeService(),
}
for _, opt := range opts {
@@ -1321,12 +1352,10 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
}
dsCache.DataSources = append(dsCache.DataSources, ds)
folderService := foldertest.NewFakeService()
alertRuleService := provisioning.NewAlertRuleService(
ruleStore,
options.provenanceStore,
folderService,
options.folderService,
options.quotaChecker,
&provisioning.NopTransactionManager{},
60,
@@ -1344,9 +1373,9 @@ func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOption
},
}
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService, options.featureToggles)
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService, options.featureToggles, options.alertmanager)
return srv, dsCache, ruleStore, folderService
return srv, dsCache, ruleStore
}
func createRequestCtx() *contextmodel.ReqContext {
@@ -1466,3 +1495,307 @@ func TestGetProvenance(t *testing.T) {
require.Equal(t, models.ProvenanceNone, provenance)
})
}
type mockAlertmanager struct {
mock.Mock
}
func (m *mockAlertmanager) SaveAndApplyExtraConfiguration(ctx context.Context, org int64, extraConfig apimodels.ExtraConfiguration) error {
args := m.Called(ctx, org, extraConfig)
return args.Error(0)
}
func (m *mockAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error) {
args := m.Called(ctx, org, withAutogen)
return args.Get(0).(apimodels.GettableUserConfig), args.Error(1)
}
func (m *mockAlertmanager) DeleteAndApplyExtraConfiguration(ctx context.Context, org int64, identifier string) error {
args := m.Called(ctx, org, identifier)
return args.Error(0)
}
func TestRouteConvertPrometheusGetAlertmanagerConfig(t *testing.T) {
const identifier = "test-config"
const orgID = int64(1)
t.Run("without feature flag should return 501", func(t *testing.T) {
ft := featuremgmt.WithFeatures()
srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(ft))
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusNotImplemented, response.Status())
})
t.Run("without config identifier header should return 400", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusBadRequest, response.Status())
})
t.Run("with empty config identifier header should return 400", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, "")
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusBadRequest, response.Status())
})
t.Run("should return config when it is found", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
expectedConfig := apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: identifier,
TemplateFiles: map[string]string{
"test.tmpl": "{{ define \"test\" }}Hello{{ end }}",
},
AlertmanagerConfig: `route:
receiver: default
receivers:
- name: default`,
},
},
}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, int64(1), false).Return(expectedConfig, nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusOK, response.Status())
expectedResponse := `alertmanager_config: |-
route:
receiver: default
receivers:
- name: default
template_files:
test.tmpl: '{{ define "test" }}Hello{{ end }}'`
require.YAMLEq(t, expectedResponse, string(response.Body()))
mockAM.AssertExpectations(t)
})
t.Run("when config not found should return 404", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
expectedConfig := apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: "other-config",
TemplateFiles: map[string]string{
"test.tmpl": "{{ define \"test\" }}Hello{{ end }}",
},
AlertmanagerConfig: `route:
receiver: default
receivers:
- name: default`,
},
},
}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(expectedConfig, nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusNotFound, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return error when GetAlertmanagerConfiguration fails", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{}, errors.New("config error")).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusInternalServerError, response.Status())
mockAM.AssertExpectations(t)
})
}
func TestParseMergeMatchersHeader(t *testing.T) {
testCases := []struct {
name string
headerValue string
expectedError bool
expectedMatchers amconfig.Matchers
}{
{
name: "empty header should return error",
headerValue: "",
expectedError: true,
},
{
name: "single matcher should parse correctly",
headerValue: "env=prod",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
},
},
{
name: "multiple matchers should be parsed correctly",
headerValue: "env=prod,team=alerting",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
},
},
{
name: "matchers with spaces should be parsed correctly",
headerValue: " env = prod , team = alerting ",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
},
},
{
name: "invalid format without equals should return error",
headerValue: "env:prod",
expectedError: true,
},
{
name: "empty key should return error",
headerValue: "=prod",
expectedError: true,
},
{
name: "empty value should return error",
headerValue: "env=",
expectedError: true,
},
{
name: "missing value should return error",
headerValue: "env",
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(mergeMatchersHeader, tc.headerValue)
matchers, err := parseMergeMatchersHeader(rc)
if tc.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.ElementsMatch(t, tc.expectedMatchers, matchers)
}
})
}
}
func TestParseConfigIdentifierHeader(t *testing.T) {
testCases := []struct {
name string
headerValue string
expectedValue string
expectedError bool
}{
{
name: "valid identifier should parse correctly",
headerValue: "test-config",
expectedValue: "test-config",
expectedError: false,
},
{
name: "identifier with spaces should be trimmed",
headerValue: " test-config ",
expectedValue: "test-config",
expectedError: false,
},
{
name: "empty identifier should return error",
headerValue: "",
expectedError: true,
},
{
name: "whitespace only identifier should return error",
headerValue: " ",
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, tc.headerValue)
identifier, err := parseConfigIdentifierHeader(rc)
if tc.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedValue, identifier)
}
})
}
}
func TestFormatMergeMatchers(t *testing.T) {
t.Run("empty matchers should return empty string", func(t *testing.T) {
result := formatMergeMatchers(nil)
require.Equal(t, "", result)
})
t.Run("single matcher should format correctly", func(t *testing.T) {
matchers := amconfig.Matchers{
&labels.Matcher{
Type: labels.MatchEqual,
Name: "env",
Value: "prod",
},
}
result := formatMergeMatchers(matchers)
require.Equal(t, "env=prod", result)
})
t.Run("multiple matchers should format correctly", func(t *testing.T) {
matchers := amconfig.Matchers{
&labels.Matcher{
Type: labels.MatchEqual,
Name: "env",
Value: "prod",
},
&labels.Matcher{
Type: labels.MatchEqual,
Name: "team",
Value: "backend",
},
}
result := formatMergeMatchers(matchers)
require.Equal(t, "env=prod,team=backend", result)
})
}
+15 -16
View File
@@ -514,6 +514,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"queriedDatasourceUIDs": {
"items": {
"type": "string"
@@ -579,7 +582,8 @@
"AlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"$ref": "#/definitions/Config"
"description": "Configuration for Alertmanager in YAML format.\nin: body",
"type": "string"
},
"template_files": {
"additionalProperties": {
@@ -1434,20 +1438,6 @@
"title": "Frames is a slice of Frame pointers.",
"type": "array"
},
"GettableAlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"type": "string"
},
"template_files": {
"additionalProperties": {
"type": "string"
},
"type": "object"
}
},
"type": "object"
},
"GettableAlertmanagers": {
"properties": {
"data": {
@@ -3719,6 +3709,7 @@
"type": "object"
},
"Route": {
"description": "A Route is a node that contains definitions of how to handle alerts. This is modified\nfrom the upstream alertmanager in that it adds the ObjectMatchers property.",
"properties": {
"active_time_intervals": {
"items": {
@@ -3760,6 +3751,12 @@
},
"type": "array"
},
"object_matchers": {
"$ref": "#/definitions/ObjectMatchers"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"receiver": {
"type": "string"
},
@@ -3773,7 +3770,6 @@
"type": "array"
}
},
"title": "A Route is a node that contains definitions of how to handle alerts.",
"type": "object"
},
"RouteExport": {
@@ -3869,6 +3865,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"query": {
"type": "string"
},
@@ -1,9 +1,7 @@
package definitions
import (
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
)
// Route for mimirtool
@@ -230,7 +228,7 @@ import (
// - application/yaml
//
// Responses:
// 200: GettableAlertmanagerUserConfig
// 200: AlertmanagerUserConfig
// 403: ForbiddenError
// Route for `mimirtool alertmanager delete`
@@ -347,33 +345,8 @@ type RouteConvertPrometheusDeleteAlertmanagerConfigParams struct {
// swagger:model
type AlertmanagerUserConfig struct {
AlertmanagerConfig config.Config `yaml:"alertmanager_config" json:"alertmanager_config"`
TemplateFiles map[string]string `yaml:"template_files" json:"template_files"`
}
func (c *AlertmanagerUserConfig) UnmarshalYAML(value *yaml.Node) error {
// mimirtool sends alertmanager_config as a string
type cortexAlertmanagerUserConfig struct {
TemplateFiles map[string]string `yaml:"template_files" json:"template_files"`
AlertmanagerConfig string `yaml:"alertmanager_config" json:"alertmanager_config"`
}
var tmp cortexAlertmanagerUserConfig
if err := value.Decode(&tmp); err != nil {
return err
}
if err := yaml.Unmarshal([]byte(tmp.AlertmanagerConfig), &c.AlertmanagerConfig); err != nil {
return err
}
c.TemplateFiles = tmp.TemplateFiles
return nil
}
// swagger:model
type GettableAlertmanagerUserConfig struct {
// Configuration for Alertmanager in YAML format.
// in: body
AlertmanagerConfig string `yaml:"alertmanager_config" json:"alertmanager_config"`
TemplateFiles map[string]string `yaml:"template_files" json:"template_files"`
}
+10 -17
View File
@@ -514,6 +514,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"queriedDatasourceUIDs": {
"items": {
"type": "string"
@@ -579,7 +582,8 @@
"AlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"$ref": "#/definitions/Config"
"description": "Configuration for Alertmanager in YAML format.\nin: body",
"type": "string"
},
"template_files": {
"additionalProperties": {
@@ -1434,20 +1438,6 @@
"title": "Frames is a slice of Frame pointers.",
"type": "array"
},
"GettableAlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"type": "string"
},
"template_files": {
"additionalProperties": {
"type": "string"
},
"type": "object"
}
},
"type": "object"
},
"GettableAlertmanagers": {
"properties": {
"data": {
@@ -3875,6 +3865,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"query": {
"type": "string"
},
@@ -6923,9 +6916,9 @@
],
"responses": {
"200": {
"description": "GettableAlertmanagerUserConfig",
"description": "AlertmanagerUserConfig",
"schema": {
"$ref": "#/definitions/GettableAlertmanagerUserConfig"
"$ref": "#/definitions/AlertmanagerUserConfig"
}
},
"403": {
+10 -17
View File
@@ -1381,9 +1381,9 @@
],
"responses": {
"200": {
"description": "GettableAlertmanagerUserConfig",
"description": "AlertmanagerUserConfig",
"schema": {
"$ref": "#/definitions/GettableAlertmanagerUserConfig"
"$ref": "#/definitions/AlertmanagerUserConfig"
}
},
"403": {
@@ -4812,6 +4812,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"queriedDatasourceUIDs": {
"type": "array",
"items": {
@@ -4868,7 +4871,8 @@
"type": "object",
"properties": {
"alertmanager_config": {
"$ref": "#/definitions/Config"
"description": "Configuration for Alertmanager in YAML format.\nin: body",
"type": "string"
},
"template_files": {
"type": "object",
@@ -5723,20 +5727,6 @@
"$ref": "#/definitions/Frame"
}
},
"GettableAlertmanagerUserConfig": {
"type": "object",
"properties": {
"alertmanager_config": {
"type": "string"
},
"template_files": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"GettableAlertmanagers": {
"type": "object",
"properties": {
@@ -8172,6 +8162,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"query": {
"type": "string"
},
+15 -16
View File
@@ -12941,6 +12941,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"queriedDatasourceUIDs": {
"type": "array",
"items": {
@@ -12997,7 +13000,8 @@
"type": "object",
"properties": {
"alertmanager_config": {
"$ref": "#/definitions/Config"
"description": "Configuration for Alertmanager in YAML format.\nin: body",
"type": "string"
},
"template_files": {
"type": "object",
@@ -15883,20 +15887,6 @@
}
}
},
"GettableAlertmanagerUserConfig": {
"type": "object",
"properties": {
"alertmanager_config": {
"type": "string"
},
"template_files": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"GettableAlertmanagers": {
"type": "object",
"properties": {
@@ -20073,8 +20063,8 @@
}
},
"Route": {
"description": "A Route is a node that contains definitions of how to handle alerts. This is modified\nfrom the upstream alertmanager in that it adds the ObjectMatchers property.",
"type": "object",
"title": "A Route is a node that contains definitions of how to handle alerts.",
"properties": {
"active_time_intervals": {
"type": "array",
@@ -20116,6 +20106,12 @@
"type": "string"
}
},
"object_matchers": {
"$ref": "#/definitions/ObjectMatchers"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"receiver": {
"type": "string"
},
@@ -20230,6 +20226,9 @@
"notificationSettings": {
"$ref": "#/definitions/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/definitions/Provenance"
},
"query": {
"type": "string"
},
+15 -16
View File
@@ -2982,6 +2982,9 @@
"notificationSettings": {
"$ref": "#/components/schemas/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/components/schemas/Provenance"
},
"queriedDatasourceUIDs": {
"items": {
"type": "string"
@@ -3047,7 +3050,8 @@
"AlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"$ref": "#/components/schemas/Config"
"description": "Configuration for Alertmanager in YAML format.\nin: body",
"type": "string"
},
"template_files": {
"additionalProperties": {
@@ -5934,20 +5938,6 @@
},
"type": "object"
},
"GettableAlertmanagerUserConfig": {
"properties": {
"alertmanager_config": {
"type": "string"
},
"template_files": {
"additionalProperties": {
"type": "string"
},
"type": "object"
}
},
"type": "object"
},
"GettableAlertmanagers": {
"properties": {
"data": {
@@ -10124,6 +10114,7 @@
"type": "object"
},
"Route": {
"description": "A Route is a node that contains definitions of how to handle alerts. This is modified\nfrom the upstream alertmanager in that it adds the ObjectMatchers property.",
"properties": {
"active_time_intervals": {
"items": {
@@ -10165,6 +10156,12 @@
},
"type": "array"
},
"object_matchers": {
"$ref": "#/components/schemas/ObjectMatchers"
},
"provenance": {
"$ref": "#/components/schemas/Provenance"
},
"receiver": {
"type": "string"
},
@@ -10178,7 +10175,6 @@
"type": "array"
}
},
"title": "A Route is a node that contains definitions of how to handle alerts.",
"type": "object"
},
"RouteExport": {
@@ -10274,6 +10270,9 @@
"notificationSettings": {
"$ref": "#/components/schemas/AlertRuleNotificationSettings"
},
"provenance": {
"$ref": "#/components/schemas/Provenance"
},
"query": {
"type": "string"
},