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
This commit is contained in:
Alexander Akhmetov 2025-08-13 17:28:43 +02:00 committed by GitHub
parent 466aa70179
commit 587f52cf5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 354 additions and 15 deletions

View File

@ -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;

View File

@ -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",

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
227 skipTokenRotationIfRecent GA @grafana/identity-access-team false false false
228 alertEnrichment experimental @grafana/alerting-squad false false false
229 alertingImportAlertmanagerAPI experimental @grafana/alerting-squad false false false
230 alertingImportAlertmanagerUI experimental @grafana/alerting-squad false false false
231 sharingDashboardImage experimental @grafana/sharing-squad false false true
232 preferLibraryPanelTitle privatePreview @grafana/dashboards-squad false false false
233 tabularNumbers GA @grafana/grafana-frontend-platform false false false

View File

@ -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"

View File

@ -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",

View File

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

View File

@ -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)

View File

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