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) +}