From 587f52cf5b8c480cfe63490a1e77907faa1a263a Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 13 Aug 2025 17:28:43 +0200 Subject: [PATCH] Alerting: API to show converted alertmanager configurations in the UI (#109353) What is this feature? This PR add the backend functionality to support viewing extra Alertmanager configurations (imported with the Prometheus conversion API) in the UI under the feature flag alertingImportAlertmanagerUI. The same flag will be used to enable this in the UI. This is just the backend part, the full PoC PR is here: #109027 It uses a special datasource UID prefix __grafana-converted-extra-config-{identifier} to identify imported configurations. When the Alertmanager proxy handler detects this prefix: GET requests are proxied to either the Grafana Alertmanager service (for alerts, silences, etc.) or the Prometheus conversion API to get the config Write operations are not supported --- .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 9 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 15 ++ pkg/services/ngalert/api/api.go | 24 ++- .../ngalert/api/forking_alertmanager.go | 108 +++++++++- .../ngalert/api/forking_alertmanager_test.go | 203 ++++++++++++++++++ 8 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 pkg/services/ngalert/api/forking_alertmanager_test.go diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6b8812ee68e..1ba87b75cd8 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1012,6 +1012,11 @@ export interface FeatureToggles { */ alertingImportAlertmanagerAPI?: boolean; /** + * Enables the UI to see imported Alertmanager configuration + * @default false + */ + alertingImportAlertmanagerUI?: boolean; + /** * Enables image sharing functionality for dashboards */ sharingDashboardImage?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e2ee8daad09..407d5bb8675 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1748,6 +1748,15 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "alertingImportAlertmanagerUI", + Description: "Enables the UI to see imported Alertmanager configuration", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromAdminPage: true, + HideFromDocs: true, + Expression: "false", + }, { Name: "sharingDashboardImage", Description: "Enables image sharing functionality for dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index fa1853ebe62..da5c86efce9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -227,6 +227,7 @@ restoreDashboards,experimental,@grafana/grafana-frontend-platform,false,false,fa skipTokenRotationIfRecent,GA,@grafana/identity-access-team,false,false,false alertEnrichment,experimental,@grafana/alerting-squad,false,false,false alertingImportAlertmanagerAPI,experimental,@grafana/alerting-squad,false,false,false +alertingImportAlertmanagerUI,experimental,@grafana/alerting-squad,false,false,false sharingDashboardImage,experimental,@grafana/sharing-squad,false,false,true preferLibraryPanelTitle,privatePreview,@grafana/dashboards-squad,false,false,false tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index c77c705182f..850aeaaf482 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -919,6 +919,10 @@ const ( // Enables the API to import Alertmanager configuration FlagAlertingImportAlertmanagerAPI = "alertingImportAlertmanagerAPI" + // FlagAlertingImportAlertmanagerUI + // Enables the UI to see imported Alertmanager configuration + FlagAlertingImportAlertmanagerUI = "alertingImportAlertmanagerUI" + // FlagSharingDashboardImage // Enables image sharing functionality for dashboards FlagSharingDashboardImage = "sharingDashboardImage" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 272c58a23ca..deabfe2302c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -242,6 +242,21 @@ "expression": "false" } }, + { + "metadata": { + "name": "alertingImportAlertmanagerUI", + "resourceVersion": "1754585847887", + "creationTimestamp": "2025-08-07T16:57:27Z" + }, + "spec": { + "description": "Enables the UI to see imported Alertmanager configuration", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "alertingImportYAMLUI", diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index a6e3ba62cd5..eefbb6dea30 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -95,6 +95,16 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { } ruleAuthzService := accesscontrol.NewRuleService(api.AccessControl) + convertSrv := NewConvertPrometheusSrv( + &api.Cfg.UnifiedAlerting, + logger, + api.RuleStore, + api.DatasourceCache, + api.AlertRules, + api.FeatureManager, + api.MultiOrgAlertmanager, + ) + // Register endpoints for proxying to Alertmanager-compatible backends. api.RegisterAlertmanagerApiEndpoints(NewForkingAM( api.DatasourceCache, @@ -115,6 +125,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { ), receiverAuthz: accesscontrol.NewReceiverAccess[ReceiverStatus](api.AccessControl, false), }, + convertSrv, + api.FeatureManager, ), m) // Register endpoints for proxying to Prometheus-compatible backends. api.RegisterPrometheusApiEndpoints(NewForkingProm( @@ -181,15 +193,5 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { hist: api.Historian, }), m) - api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi( - NewConvertPrometheusSrv( - &api.Cfg.UnifiedAlerting, - logger, - api.RuleStore, - api.DatasourceCache, - api.AlertRules, - api.FeatureManager, - api.MultiOrgAlertmanager, - ), - ), m) + api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi(convertSrv), m) } diff --git a/pkg/services/ngalert/api/forking_alertmanager.go b/pkg/services/ngalert/api/forking_alertmanager.go index df5059b296c..9d76ccb07ff 100644 --- a/pkg/services/ngalert/api/forking_alertmanager.go +++ b/pkg/services/ngalert/api/forking_alertmanager.go @@ -1,36 +1,83 @@ package api import ( + "net/http" + "strings" + + amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) +const extraConfigPrefix = "~grafana-converted-extra-config-" + +type ConvertService interface { + RouteConvertPrometheusGetAlertmanagerConfig(ctx *contextmodel.ReqContext) response.Response +} + type AlertmanagerApiHandler struct { AMSvc *LotexAM GrafanaSvc *AlertmanagerSrv + ConvertSvc ConvertService DatasourceCache datasources.CacheService + FeatureManager featuremgmt.FeatureToggles } // NewForkingAM implements a set of routes that proxy to various Alertmanager-compatible backends. -func NewForkingAM(datasourceCache datasources.CacheService, proxy *LotexAM, grafana *AlertmanagerSrv) *AlertmanagerApiHandler { +func NewForkingAM(datasourceCache datasources.CacheService, proxy *LotexAM, grafana *AlertmanagerSrv, convertSvc ConvertService, featureManager featuremgmt.FeatureToggles) *AlertmanagerApiHandler { return &AlertmanagerApiHandler{ AMSvc: proxy, GrafanaSvc: grafana, + ConvertSvc: convertSvc, DatasourceCache: datasourceCache, + FeatureManager: featureManager, } } func (f *AlertmanagerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexAM, error) { - _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.AlertmanagerBackend) - if err != nil { - return nil, err + // If this is not an extra config request, we should check that the datasource exists and is of the correct type. + if isExtra, _ := f.isExtraConfig(ctx); !isExtra { + _, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.AlertmanagerBackend) + if err != nil { + return nil, err + } } + return f.AMSvc, nil } +// isExtraConfig checks if the datasourceUID represents an extra config. +// Extra configs are the alertmanager configurations that were saved using the Prometheus conversion API. +func (f *AlertmanagerApiHandler) isExtraConfig(ctx *contextmodel.ReqContext) (bool, string) { + // Only enabled if feature flag is on + if !f.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerUI) { + return false, "" + } + + datasourceUID := web.Params(ctx.Req)[":DatasourceUID"] + if strings.HasPrefix(datasourceUID, extraConfigPrefix) { + identifier := strings.TrimPrefix(datasourceUID, extraConfigPrefix) + return true, identifier + } + return false, "" +} + func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + status := apimodels.GettableStatus{ + Cluster: &amv2.ClusterStatus{ + Status: util.Pointer("ready"), + }, + } + return response.JSON(http.StatusOK, status) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -40,6 +87,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMStatus(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *contextmodel.ReqContext, body apimodels.PostableSilence, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -49,6 +100,10 @@ func (f *AlertmanagerApiHandler) handleRouteCreateSilence(ctx *contextmodel.ReqC } func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -58,6 +113,10 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteAlertingConfig(ctx *contextmod } func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -67,6 +126,23 @@ func (f *AlertmanagerApiHandler) handleRouteDeleteSilence(ctx *contextmodel.ReqC } func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, identifier := f.isExtraConfig(ctx); isExtra { + ctx.Req.Header.Set(configIdentifierHeader, identifier) + ctx.Req.Header.Set("Accept", "application/yaml") + + conversionResp := f.ConvertSvc.RouteConvertPrometheusGetAlertmanagerConfig(ctx) + if conversionResp.Status() != http.StatusOK { + return conversionResp + } + + config, err := yamlExtractor(&apimodels.GettableUserConfig{})(conversionResp.(*response.NormalResponse)) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to parse alertmanager config", err) + } + + return response.JSON(http.StatusOK, config) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -76,6 +152,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAlertingConfig(ctx *contextmodel. } func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return f.GrafanaSvc.RouteGetAMAlertGroups(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -85,6 +165,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlertGroups(ctx *contextmodel.R } func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return f.GrafanaSvc.RouteGetAMAlerts(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -94,6 +178,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetAMAlerts(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *contextmodel.ReqContext, silenceID string, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return f.GrafanaSvc.RouteGetSilence(ctx, silenceID) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -103,6 +191,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilence(ctx *contextmodel.ReqCont } func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *contextmodel.ReqContext, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return f.GrafanaSvc.RouteGetSilences(ctx) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -112,6 +204,10 @@ func (f *AlertmanagerApiHandler) handleRouteGetSilences(ctx *contextmodel.ReqCon } func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel.ReqContext, body apimodels.PostableUserConfig, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) @@ -123,6 +219,10 @@ func (f *AlertmanagerApiHandler) handleRoutePostAlertingConfig(ctx *contextmodel } func (f *AlertmanagerApiHandler) handleRoutePostAMAlerts(ctx *contextmodel.ReqContext, body apimodels.PostableAlerts, dsUID string) response.Response { + if isExtra, _ := f.isExtraConfig(ctx); isExtra { + return response.Error(http.StatusForbidden, "Read-only configuration", nil) + } + s, err := f.getService(ctx) if err != nil { return errorToResponse(err) diff --git a/pkg/services/ngalert/api/forking_alertmanager_test.go b/pkg/services/ngalert/api/forking_alertmanager_test.go new file mode 100644 index 00000000000..46074531398 --- /dev/null +++ b/pkg/services/ngalert/api/forking_alertmanager_test.go @@ -0,0 +1,203 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/response" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/web" +) + +func TestAlertmanagerApiHandler_isExtraConfig(t *testing.T) { + tests := []struct { + name string + datasourceUID string + flagEnabled bool + expectedIsExtra bool + expectedIdentifier string + }{ + { + name: "normal datasource when feature enabled", + datasourceUID: "normal-datasource", + flagEnabled: true, + expectedIsExtra: false, + expectedIdentifier: "", + }, + { + name: "extra config when feature enabled", + datasourceUID: "~grafana-converted-extra-config-test-config", + flagEnabled: true, + expectedIsExtra: true, + expectedIdentifier: "test-config", + }, + { + name: "extra config when feature disabled", + datasourceUID: "~grafana-converted-extra-config-test-config", + flagEnabled: false, + expectedIsExtra: false, + expectedIdentifier: "", + }, + { + name: "empty datasource UID", + datasourceUID: "", + flagEnabled: true, + expectedIsExtra: false, + expectedIdentifier: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": tt.datasourceUID, + }) + + var features featuremgmt.FeatureToggles + if tt.flagEnabled { + features = featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI) + } else { + features = featuremgmt.WithFeatures() + } + + handler := &AlertmanagerApiHandler{ + FeatureManager: features, + } + + isExtra, identifier := handler.isExtraConfig(ctx) + assert.Equal(t, tt.expectedIsExtra, isExtra) + assert.Equal(t, tt.expectedIdentifier, identifier) + }) + } +} + +func TestAlertmanagerApiHandler_ExtraConfigRouting(t *testing.T) { + t.Run("GET status returns ready for extra config", func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": "~grafana-converted-extra-config-test", + }) + + handler := &AlertmanagerApiHandler{ + FeatureManager: featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI), + } + + resp := handler.handleRouteGetAMStatus(ctx, "~grafana-converted-extra-config-test") + require.Equal(t, http.StatusOK, resp.Status()) + }) + + t.Run("POST operations return 403 for extra config", func(t *testing.T) { + req := &http.Request{} + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": "~grafana-converted-extra-config-test", + }) + + handler := &AlertmanagerApiHandler{ + FeatureManager: featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI), + } + + resp := handler.handleRouteCreateSilence(ctx, apimodels.PostableSilence{}, "~grafana-converted-extra-config-test") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRouteDeleteAlertingConfig(ctx, "~grafana-converted-extra-config-test") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRoutePostAlertingConfig(ctx, apimodels.PostableUserConfig{}, "~grafana-converted-extra-config-test") + assert.Equal(t, http.StatusForbidden, resp.Status()) + + resp = handler.handleRoutePostAMAlerts(ctx, apimodels.PostableAlerts{}, "~grafana-converted-extra-config-test") + assert.Equal(t, http.StatusForbidden, resp.Status()) + }) + + t.Run("GET extra config", func(t *testing.T) { + req := &http.Request{ + Header: make(http.Header), + } + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + } + + ctx.Req = web.SetURLParams(req, map[string]string{ + ":DatasourceUID": "~grafana-converted-extra-config-test-identifier", + }) + + mockConvertSvc := &mockConvertService{} + + yamlConfig := `alertmanager_config: | + global: {} + route: + receiver: test-receiver + receivers: + - name: test-receiver` + + mockResponse := response.Respond(http.StatusOK, yamlConfig). + SetHeader("Content-Type", "application/yaml") + + mockConvertSvc.On("RouteConvertPrometheusGetAlertmanagerConfig", mock.Anything). + Run(func(args mock.Arguments) { + passedCtx := args.Get(0).(*contextmodel.ReqContext) + assert.Equal(t, "test-identifier", passedCtx.Req.Header.Get(configIdentifierHeader)) + assert.Equal(t, "application/yaml", passedCtx.Req.Header.Get("Accept")) + }). + Return(mockResponse) + + handler := &AlertmanagerApiHandler{ + FeatureManager: featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerUI), + ConvertSvc: mockConvertSvc, + } + + resp := handler.handleRouteGetAlertingConfig(ctx, "~grafana-converted-extra-config-test-identifier") + assert.Equal(t, http.StatusOK, resp.Status()) + + mockConvertSvc.AssertExpectations(t) + }) +} + +type mockConvertService struct { + mock.Mock +} + +func (m *mockConvertService) RouteConvertPrometheusGetAlertmanagerConfig(ctx *contextmodel.ReqContext) response.Response { + args := m.Called(ctx) + return args.Get(0).(response.Response) +}