Alerting: API to read rule groups using mimirtool (#100674)

This commit is contained in:
Alexander Akhmetov
2025-02-25 15:49:08 +01:00
committed by GitHub
parent d83db31a23
commit 6eb335a8ce
18 changed files with 836 additions and 125 deletions
@@ -1,15 +1,21 @@
package api
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
prommodel "github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/folder"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -17,6 +23,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/prom"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
const (
@@ -39,6 +46,18 @@ func errInvalidHeaderValue(header string) error {
return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header}})
}
// ConvertPrometheusSrv converts Prometheus rules to Grafana rules
// and retrieves them in a Prometheus-compatible format.
//
// It is designed to support mimirtool integration, so that rules that work with Mimir
// can be imported into Grafana. It works similarly to the provisioning API,
// where once a rule group is created, it is marked as "provisioned" (via provenance mechanism)
// and is not editable in the UI.
//
// This service returns only rule groups that were initially imported from Prometheus-compatible sources.
// Rule groups not imported from Prometheus are excluded because their original rule definitions are unavailable.
// When a rule group is converted from Prometheus to Grafana, the original definition is preserved alongside
// the Grafana rule and used for reading requests here.
type ConvertPrometheusSrv struct {
cfg *setting.UnifiedAlertingSettings
logger log.Logger
@@ -57,27 +76,117 @@ func NewConvertPrometheusSrv(cfg *setting.UnifiedAlertingSettings, logger log.Lo
}
}
// RouteConvertPrometheusGetRules returns all Grafana-managed alert rules in all namespaces (folders)
// that were imported from a Prometheus-compatible source.
// It responds with a YAML containing a mapping of folders to arrays of Prometheus rule groups.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response {
return response.Error(501, "Not implemented", nil)
logger := srv.logger.FromContext(c.Req.Context())
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
}
groups, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert groups", "error", err)
return errorToResponse(err)
}
namespaces, err := grafanaNamespacesToPrometheus(groups)
if err != nil {
logger.Error("Failed to convert Grafana rules to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, namespaces)
}
// RouteConvertPrometheusDeleteNamespace deletes all rule groups that were imported from a Prometheus-compatible source
// within a specified namespace.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
return response.Error(501, "Not implemented", nil)
}
// RouteConvertPrometheusDeleteRuleGroup deletes a specific rule group if it was imported from a Prometheus-compatible source.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
return response.Error(501, "Not implemented", nil)
}
// RouteConvertPrometheusGetNamespace returns the Grafana-managed alert rules for a specified namespace (folder).
// It responds with a YAML containing a mapping of a single folder to an array of Prometheus rule groups.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
return response.Error(501, "Not implemented", nil)
logger := srv.logger.FromContext(c.Req.Context())
logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle)
namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
logger.Error("Failed to get folder", "error", err)
return namespaceErrorResponse(err)
}
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
NamespaceUIDs: []string{namespace.UID},
}
groups, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert groups", "error", err)
return errorToResponse(err)
}
ns, err := grafanaNamespacesToPrometheus(groups)
if err != nil {
logger.Error("Failed to convert Grafana rules to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, ns)
}
// RouteConvertPrometheusGetRuleGroup retrieves a single rule group for a given namespace (folder)
// in Prometheus-compatible YAML format if it was imported from a Prometheus-compatible source.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
// Just to make the mimirtool rules load work. It first checks if the group exists, and if the endpoint returns 501 it fails.
return response.YAML(http.StatusOK, apimodels.PrometheusRuleGroup{})
logger := srv.logger.FromContext(c.Req.Context())
logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle)
namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
logger.Error("Failed to get folder", "error", err)
return namespaceErrorResponse(err)
}
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
NamespaceUIDs: []string{namespace.UID},
RuleGroups: []string{group},
}
groupsWithFolders, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert group", "error", err)
return errorToResponse(err)
}
if len(groupsWithFolders) == 0 {
return response.Error(http.StatusNotFound, "Rule group not found", nil)
}
if len(groupsWithFolders) > 1 {
logger.Error("Multiple rule groups found when only one was expected", "folder_title", namespaceTitle, "group", group)
// It shouldn't happen, but if we get more than 1 group, we return an error.
return response.Error(http.StatusInternalServerError, "Multiple rule groups found", nil)
}
promGroup, err := grafanaRuleGroupToPrometheus(groupsWithFolders[0].Title, groupsWithFolders[0].Rules)
if err != nil {
logger.Error("Failed to convert Grafana rule to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, promGroup)
}
// RouteConvertPrometheusPostRuleGroup converts a Prometheus rule group into a Grafana rule group
// and creates or updates it within the specified namespace (folder).
//
// If the group already exists and was not imported from a Prometheus-compatible source initially,
// it will not be replaced and an error will be returned.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, promGroup apimodels.PrometheusRuleGroup) response.Response {
logger := srv.logger.FromContext(c.Req.Context())
logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name)
@@ -101,6 +210,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm
group, err := srv.convertToGrafanaRuleGroup(c, ds, ns.UID, promGroup, logger)
if err != nil {
logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err)
return errorToResponse(err)
}
@@ -202,3 +312,54 @@ func parseBooleanHeader(header string, headerName string) (bool, error) {
}
return val, nil
}
func grafanaNamespacesToPrometheus(groups []models.AlertRuleGroupWithFolderFullpath) (map[string][]apimodels.PrometheusRuleGroup, error) {
result := map[string][]apimodels.PrometheusRuleGroup{}
for _, group := range groups {
promGroup, err := grafanaRuleGroupToPrometheus(group.Title, group.Rules)
if err != nil {
return nil, err
}
result[group.FolderFullpath] = append(result[group.FolderFullpath], promGroup)
}
return result, nil
}
func grafanaRuleGroupToPrometheus(group string, rules []models.AlertRule) (apimodels.PrometheusRuleGroup, error) {
if len(rules) == 0 {
return apimodels.PrometheusRuleGroup{}, nil
}
interval := time.Duration(rules[0].IntervalSeconds) * time.Second
promGroup := apimodels.PrometheusRuleGroup{
Name: group,
Interval: prommodel.Duration(interval),
Rules: make([]apimodels.PrometheusRule, len(rules)),
}
for i, rule := range rules {
promDefinition := rule.PrometheusRuleDefinition()
if promDefinition == "" {
return apimodels.PrometheusRuleGroup{}, fmt.Errorf("failed to get the Prometheus definition of the rule with UID %s", rule.UID)
}
var r apimodels.PrometheusRule
if err := yaml.Unmarshal([]byte(promDefinition), &r); err != nil {
return apimodels.PrometheusRuleGroup{}, fmt.Errorf("failed to unmarshal Prometheus rule definition of the rule with UID %s: %w", rule.UID, err)
}
promGroup.Rules[i] = r
}
return promGroup, nil
}
func namespaceErrorResponse(err error) response.Response {
if errors.Is(err, dashboards.ErrFolderAccessDenied) {
// If there is no such folder, the error is ErrFolderAccessDenied.
// We should return 404 in this case, otherwise mimirtool does not work correctly.
return response.Empty(http.StatusNotFound)
}
return toNamespaceErrorResponse(err)
}