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
8 changed files with 354 additions and 15 deletions
+13 -11
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)
}
@@ -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)
@@ -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)
}