Alerting: Allow disabling provenance in the Prometheus conversion API (#101573)

When creating Grafana-managed alerts from Prometheus rule definitions with mimirtool or cortextool, the rules are marked as "provisioned" and are not editable in the Grafana UI. This PR allows changing this by providing an extra header: --extra-header="X-Disable-Provenance=true".

When provenance is disabled, we do not keep the original rule definition in YAML, so it is impossible to read it back using the Prometheus conversion API (mimirtool/cortextool). This is intentional because if we did keep it and the rule was later changed in the UI, its Prometheus YAML definition would no longer reflect the latest version of the alert rule, as it would be unchanged.
This commit is contained in:
Alexander Akhmetov
2025-03-11 19:53:28 +01:00
committed by GitHub
parent 4dbd1846c7
commit 85b0b47efd
8 changed files with 466 additions and 20 deletions
@@ -188,11 +188,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contex
}
logger.Info("Deleting all Prometheus-imported rule groups", "folder_uid", namespace.UID, "folder_title", namespaceTitle)
provenance := getProvenance(c)
filterOpts := &provisioning.FilterOptions{
NamespaceUIDs: []string{namespace.UID},
ImportedPrometheusRule: util.Pointer(true),
}
err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, models.ProvenanceConvertedPrometheus, filterOpts)
err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, provenance, filterOpts)
if errors.Is(err, models.ErrAlertRuleGroupNotFound) {
return response.Empty(http.StatusNotFound)
}
@@ -218,7 +219,8 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contex
}
logger.Info("Deleting Prometheus-imported rule group", "folder_uid", folder.UID, "folder_title", namespaceTitle, "group", group)
err = srv.alertRuleService.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folder.UID, group, models.ProvenanceConvertedPrometheus)
provenance := getProvenance(c)
err = srv.alertRuleService.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folder.UID, group, provenance)
if errors.Is(err, models.ErrAlertRuleGroupNotFound) {
return response.Empty(http.StatusNotFound)
}
@@ -352,13 +354,21 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm
return errorToResponse(err)
}
group, err := srv.convertToGrafanaRuleGroup(c, ds, ns.UID, promGroup, logger)
provenance := getProvenance(c)
// If the provenance is not ConvertedPrometheus, we don't keep the original rule definition.
// This is because the rules can be modified through the UI, which may break compatibility
// with the Prometheus format. We only preserve the original rule definition
// to ensure we can return them in this API in Prometheus format.
keepOriginalRuleDefinition := provenance == models.ProvenanceConvertedPrometheus
group, err := srv.convertToGrafanaRuleGroup(c, ds, ns.UID, promGroup, keepOriginalRuleDefinition, logger)
if err != nil {
logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err)
return errorToResponse(err)
}
err = srv.alertRuleService.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, *group, models.ProvenanceConvertedPrometheus)
err = srv.alertRuleService.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, *group, provenance)
if err != nil {
logger.Error("Failed to replace rule group", "error", err)
return errorToResponse(err)
@@ -387,7 +397,14 @@ func (srv *ConvertPrometheusSrv) getOrCreateNamespace(c *contextmodel.ReqContext
return ns, nil
}
func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(c *contextmodel.ReqContext, ds *datasources.DataSource, namespaceUID string, promGroup apimodels.PrometheusRuleGroup, logger log.Logger) (*models.AlertRuleGroup, error) {
func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(
c *contextmodel.ReqContext,
ds *datasources.DataSource,
namespaceUID string,
promGroup apimodels.PrometheusRuleGroup,
keepOriginalRuleDefinition bool,
logger log.Logger,
) (*models.AlertRuleGroup, error) {
logger.Info("Converting Prometheus rules to Grafana rules", "rules", len(promGroup.Rules), "folder_uid", namespaceUID, "datasource_uid", ds.UID, "datasource_type", ds.Type)
rules := make([]prom.PrometheusRule, len(promGroup.Rules))
@@ -429,6 +446,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(c *contextmodel.ReqCo
AlertRules: prom.RulesConfig{
IsPaused: pauseAlertRules,
},
KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition),
},
)
if err != nil {
@@ -537,3 +555,13 @@ func promGroupHasRecordingRules(promGroup apimodels.PrometheusRuleGroup) bool {
}
return false
}
// getProvenance determines the provenance value to use for rules created via the Prometheus conversion API.
// If the X-Disable-Provenance header is present in the request, returns ProvenanceNone,
// otherwise returns ProvenanceConvertedPrometheus.
func getProvenance(ctx *contextmodel.ReqContext) models.Provenance {
if _, disabled := ctx.Req.Header[disableProvenanceHeaderName]; disabled {
return models.ProvenanceNone
}
return models.ProvenanceConvertedPrometheus
}
@@ -144,6 +144,11 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
promDefinition, err := r.PrometheusRuleDefinition()
require.NoError(t, err)
require.Equal(t, expectedDef, promDefinition)
// Verify provenance was set to ProvenanceConvertedPrometheus
prov, err := provenanceStore.GetProvenance(context.Background(), r, 1)
require.NoError(t, err)
require.Equal(t, models.ProvenanceConvertedPrometheus, prov)
}
})
@@ -341,6 +346,41 @@ 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))
// Create a folder in the root
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
// Create request with the X-Disable-Provenance header
rc := createRequestCtx()
rc.Req.Header.Set("X-Disable-Provenance", "true")
response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
// Get the created rules
rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
})
require.NoError(t, err)
require.Len(t, rules, 2)
// Verify provenance was set to ProvenanceNone
for _, r := range rules {
prov, err := provenanceStore.GetProvenance(context.Background(), r, 1)
require.NoError(t, err)
require.Equal(t, models.ProvenanceNone, prov, "Provenance should be ProvenanceNone when X-Disable-Provenance header is set")
// Prometheus rule definition should not be saved when provenance is disabled
require.Nil(t, r.Metadata.PrometheusStyleRule)
}
})
}
func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
@@ -743,6 +783,29 @@ func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, remaining)
})
t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, ruleStore, fldr, rule := initNamespace("prometheus definition", withProvenanceStore(provenanceStore))
// Mark the rule as provisioned with API provenance
err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus)
require.NoError(t, err)
rc := createRequestCtx()
rc.Req.Header.Set("X-Disable-Provenance", "true")
response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rule was deleted
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
require.Error(t, err)
require.Nil(t, remaining)
})
})
}
@@ -854,6 +917,29 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, remaining)
})
t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore))
// Mark the rule as provisioned with API provenance
err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus)
require.NoError(t, err)
rc := createRequestCtx()
rc.Req.Header.Set("X-Disable-Provenance", "true")
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rule was deleted
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
require.Error(t, err)
require.Nil(t, remaining)
})
})
}
@@ -995,3 +1081,32 @@ func TestGetWorkingFolderUID(t *testing.T) {
require.Equal(t, specifiedFolderUID, folderUID)
})
}
func TestGetProvenance(t *testing.T) {
t.Run("should return ProvenanceConvertedPrometheus when header is not present", func(t *testing.T) {
rc := createRequestCtx()
// Ensure the header is not present
rc.Req.Header.Del(disableProvenanceHeaderName)
provenance := getProvenance(rc)
require.Equal(t, models.ProvenanceConvertedPrometheus, provenance)
})
t.Run("should return ProvenanceNone when header is present", func(t *testing.T) {
rc := createRequestCtx()
// Set the disable provenance header
rc.Req.Header.Set(disableProvenanceHeaderName, "true")
provenance := getProvenance(rc)
require.Equal(t, models.ProvenanceNone, provenance)
})
t.Run("should return ProvenanceNone when header is present with any value", func(t *testing.T) {
rc := createRequestCtx()
// Set the disable provenance header with an empty value
rc.Req.Header.Set(disableProvenanceHeaderName, "")
provenance := getProvenance(rc)
require.Equal(t, models.ProvenanceNone, provenance)
})
}