Part 2 of adding version messages to the `alert_rule_version` able. This allows setting the message via a header when using the Prometheus conversion API, which can be useful for e.g. linking changes back to source control.
914 lines
35 KiB
Go
914 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
amconfig "github.com/prometheus/alertmanager/config"
|
|
"github.com/prometheus/alertmanager/pkg/labels"
|
|
prommodel "github.com/prometheus/common/model"
|
|
"go.yaml.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/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
|
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
|
"github.com/grafana/grafana/pkg/services/ngalert/api/validation"
|
|
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
|
"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 (
|
|
// datasourceUIDHeader is the name of the header that specifies the UID of the datasource to be used for the rules.
|
|
datasourceUIDHeader = "X-Grafana-Alerting-Datasource-UID"
|
|
// targetDatasourceUIDHeader is the name of the header that specifies the UID of the target datasource to be used for recording rules.
|
|
targetDatasourceUIDHeader = "X-Grafana-Alerting-Target-Datasource-UID"
|
|
|
|
// If the folderUIDHeader is present, namespaces and rule groups will be created in the specified folder.
|
|
// If not, the root folder will be used as the default.
|
|
folderUIDHeader = "X-Grafana-Alerting-Folder-UID"
|
|
|
|
// These headers control the paused state of newly created rules. By default, rules are not paused.
|
|
recordingRulesPausedHeader = "X-Grafana-Alerting-Recording-Rules-Paused"
|
|
alertRulesPausedHeader = "X-Grafana-Alerting-Alert-Rules-Paused"
|
|
|
|
// notificationSettingsHeader is the header that specifies the notification settings to be used for the rules.
|
|
// The value should be a JSON-encoded AlertRuleNotificationSettings object.
|
|
notificationSettingsHeader = "X-Grafana-Alerting-Notification-Settings"
|
|
|
|
// mergeMatchersHeader is the header that specifies the merge matchers for imported Alertmanager config.
|
|
// The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting".
|
|
mergeMatchersHeader = "X-Grafana-Alerting-Merge-Matchers"
|
|
|
|
// extraLabelsHeader is the header that specifies extra labels to be added to all imported rules.
|
|
// The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting".
|
|
extraLabelsHeader = "X-Grafana-Alerting-Extra-Labels"
|
|
|
|
// configIdentifierHeader is the header that specifies the identifier for imported Alertmanager config.
|
|
configIdentifierHeader = "X-Grafana-Alerting-Config-Identifier"
|
|
defaultConfigIdentifier = "default"
|
|
|
|
// versionMessageHeader is the header that specifies an optional message for rule versions.
|
|
versionMessageHeader = "X-Grafana-Alerting-Version-Message"
|
|
)
|
|
|
|
var (
|
|
errDatasourceUIDHeaderMissing = errutil.ValidationFailed(
|
|
"alerting.datasourceUIDHeaderMissing",
|
|
errutil.WithPublicMessage(fmt.Sprintf("Missing datasource UID header: %s", datasourceUIDHeader)),
|
|
).Errorf("missing datasource UID header")
|
|
|
|
errInvalidHeaderValueMsg = "Invalid value for header {{.Public.Header}}: {{.Public.Error}}"
|
|
errInvalidHeaderValueBase = errutil.ValidationFailed("alerting.invalidHeaderValue").MustTemplate(errInvalidHeaderValueMsg, errutil.WithPublic(errInvalidHeaderValueMsg))
|
|
|
|
errRecordingRulesNotEnabled = errutil.ValidationFailed(
|
|
"alerting.recordingRulesNotEnabled",
|
|
errutil.WithPublicMessage("Cannot import recording rules: Feature not enabled."),
|
|
).Errorf("recording rules not enabled")
|
|
)
|
|
|
|
func errInvalidHeaderValue(header string, err error) error {
|
|
return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header, "Error": err}})
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Folder Structure Handling:
|
|
// mimirtool does not support nested folder structures, while Grafana allows folder nesting.
|
|
// To keep compatibility, this service only returns direct child folders of the working folder
|
|
// as namespaces, and rule groups and rules that are directly in these child folders.
|
|
//
|
|
// For example, given this folder structure in Grafana:
|
|
//
|
|
// grafana/
|
|
// ├── production/
|
|
// │ ├── service1/
|
|
// │ │ └── alerts/
|
|
// │ └── service2/
|
|
// └── testing/
|
|
// └── service3/
|
|
//
|
|
// If the working folder is "grafana":
|
|
// - Only namespaces "production" and "testing" are returned
|
|
// - Only rule groups directly within these folders are included
|
|
//
|
|
// If the working folder is "production":
|
|
// - Only namespaces "service1" and "service2" are returned
|
|
// - Only rule groups directly within these folders are included
|
|
//
|
|
// The "working folder" is specified by the X-Grafana-Alerting-Folder-UID header, which can be set to any folder UID,
|
|
// and defaults to the root folder if not provided.
|
|
type ConvertPrometheusSrv struct {
|
|
cfg *setting.UnifiedAlertingSettings
|
|
logger log.Logger
|
|
ruleStore RuleStore
|
|
datasourceCache datasources.CacheService
|
|
alertRuleService *provisioning.AlertRuleService
|
|
featureToggles featuremgmt.FeatureToggles
|
|
am Alertmanager
|
|
}
|
|
|
|
type Alertmanager interface {
|
|
DeleteExtraConfiguration(ctx context.Context, org int64, identifier string) error
|
|
SaveAndApplyExtraConfiguration(ctx context.Context, org int64, extraConfig apimodels.ExtraConfiguration) error
|
|
GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool, withMergedExtraConfig bool) (apimodels.GettableUserConfig, error)
|
|
}
|
|
|
|
func NewConvertPrometheusSrv(
|
|
cfg *setting.UnifiedAlertingSettings,
|
|
logger log.Logger,
|
|
ruleStore RuleStore,
|
|
datasourceCache datasources.CacheService,
|
|
alertRuleService *provisioning.AlertRuleService,
|
|
featureToggles featuremgmt.FeatureToggles,
|
|
am Alertmanager,
|
|
) *ConvertPrometheusSrv {
|
|
return &ConvertPrometheusSrv{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
ruleStore: ruleStore,
|
|
datasourceCache: datasourceCache,
|
|
alertRuleService: alertRuleService,
|
|
featureToggles: featureToggles,
|
|
am: am,
|
|
}
|
|
}
|
|
|
|
// RouteConvertPrometheusGetRules returns all Grafana-managed alert rules in all namespaces (folders)
|
|
// that were imported from a Prometheus-compatible source.
|
|
// It responds with JSON or YAML containing a mapping of folders to arrays of Prometheus rule groups.
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
folders, err := srv.ruleStore.GetNamespaceChildren(c.Req.Context(), workingFolderUID, c.GetOrgID(), c.SignedInUser)
|
|
if len(folders) == 0 || errors.Is(err, dashboards.ErrFolderNotFound) {
|
|
// If there is no such folder or no children, return empty response
|
|
// because mimirtool expects 200 OK response in this case.
|
|
return convertPrometheusResponse(c, http.StatusOK, map[string][]apimodels.PrometheusRuleGroup{})
|
|
}
|
|
if err != nil {
|
|
logger.Error("Failed to get folders", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
folderUIDs := make([]string, 0, len(folders))
|
|
for _, f := range folders {
|
|
folderUIDs = append(folderUIDs, f.UID)
|
|
}
|
|
|
|
filterOpts := &provisioning.FilterOptions{
|
|
HasPrometheusRuleDefinition: util.Pointer(true),
|
|
NamespaceUIDs: folderUIDs,
|
|
}
|
|
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 convertPrometheusResponse(c, 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 {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
logger.Debug("Looking up folder by title", "folder_title", namespaceTitle)
|
|
namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.GetOrgID(), c.SignedInUser, workingFolderUID)
|
|
if err != nil {
|
|
return namespaceErrorResponse(err)
|
|
}
|
|
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},
|
|
HasPrometheusRuleDefinition: util.Pointer(true),
|
|
}
|
|
err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, provenance, filterOpts)
|
|
if errors.Is(err, models.ErrAlertRuleGroupNotFound) {
|
|
return response.Empty(http.StatusNotFound)
|
|
}
|
|
if err != nil {
|
|
logger.Error("Failed to delete rule groups", "folder_uid", namespace.UID, "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
return successfulResponse()
|
|
}
|
|
|
|
// 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 {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
logger.Debug("Looking up folder by title", "folder_title", namespaceTitle)
|
|
folder, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.GetOrgID(), c.SignedInUser, workingFolderUID)
|
|
if err != nil {
|
|
return namespaceErrorResponse(err)
|
|
}
|
|
logger.Info("Deleting Prometheus-imported rule group", "folder_uid", folder.UID, "folder_title", namespaceTitle, "group", group)
|
|
|
|
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)
|
|
}
|
|
if err != nil {
|
|
logger.Error("Failed to delete rule group", "folder_uid", folder.UID, "group", group, "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
return successfulResponse()
|
|
}
|
|
|
|
// RouteConvertPrometheusGetNamespace returns the Grafana-managed alert rules for a specified namespace (folder).
|
|
// It responds with JSON or 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 {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
logger.Debug("Looking up folder by title", "folder_title", namespaceTitle)
|
|
namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.GetOrgID(), c.SignedInUser, workingFolderUID)
|
|
if err != nil {
|
|
logger.Error("Failed to get folder", "error", err)
|
|
return namespaceErrorResponse(err)
|
|
}
|
|
|
|
filterOpts := &provisioning.FilterOptions{
|
|
HasPrometheusRuleDefinition: 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 convertPrometheusResponse(c, http.StatusOK, ns)
|
|
}
|
|
|
|
// RouteConvertPrometheusGetRuleGroup retrieves a single rule group for a given namespace (folder)
|
|
// in Prometheus-compatible JSON or YAML format if it was imported from a Prometheus-compatible source.
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
logger.Debug("Looking up folder by title", "folder_title", namespaceTitle)
|
|
namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.GetOrgID(), c.SignedInUser, workingFolderUID)
|
|
if err != nil {
|
|
logger.Error("Failed to get folder", "error", err)
|
|
return namespaceErrorResponse(err)
|
|
}
|
|
if namespace == nil {
|
|
return response.Error(http.StatusNotFound, "Folder not found", nil)
|
|
}
|
|
|
|
filterOpts := &provisioning.FilterOptions{
|
|
HasPrometheusRuleDefinition: 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 convertPrometheusResponse(c, 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 {
|
|
return srv.RouteConvertPrometheusPostRuleGroups(c, map[string][]apimodels.PrometheusRuleGroup{namespaceTitle: {promGroup}})
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *contextmodel.ReqContext, promNamespaces map[string][]apimodels.PrometheusRuleGroup) response.Response {
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
// 1. Parse the appropriate headers
|
|
workingFolderUID := getWorkingFolderUID(c)
|
|
logger = logger.New("working_folder_uid", workingFolderUID)
|
|
|
|
pauseRecordingRules, err := parseBooleanHeader(c.Req.Header.Get(recordingRulesPausedHeader), recordingRulesPausedHeader)
|
|
if err != nil {
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
pauseAlertRules, err := parseBooleanHeader(c.Req.Header.Get(alertRulesPausedHeader), alertRulesPausedHeader)
|
|
if err != nil {
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader))
|
|
if datasourceUID == "" {
|
|
return response.Err(errDatasourceUIDHeaderMissing)
|
|
}
|
|
ds, err := srv.datasourceCache.GetDatasourceByUID(c.Req.Context(), datasourceUID, c.SignedInUser, c.SkipDSCache)
|
|
if err != nil {
|
|
logger.Error("Failed to get datasource", "datasource_uid", datasourceUID, "error", err)
|
|
return errorToResponse(fmt.Errorf("failed to get datasource: %w", err))
|
|
}
|
|
|
|
// By default the target datasource is the same as the query datasource,
|
|
// but if the header "X-Grafana-Alerting-Target-Datasource-UID" is present, we use that instead.
|
|
tds := ds
|
|
if uid := strings.TrimSpace(c.Req.Header.Get(targetDatasourceUIDHeader)); uid != "" {
|
|
tds, err = srv.datasourceCache.GetDatasourceByUID(c.Req.Context(), uid, c.SignedInUser, c.SkipDSCache)
|
|
if err != nil {
|
|
logger.Error("Failed to get target datasource for recording rules", "datasource_uid", uid, "error", err)
|
|
return errorToResponse(fmt.Errorf("failed to get recording rules target datasource: %w", err))
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
notificationSettings, err := parseNotificationSettingsHeader(c)
|
|
if err != nil {
|
|
logger.Error("Failed to parse notification settings header", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
extraLabels, err := parseExtraLabelsHeader(c)
|
|
if err != nil {
|
|
logger.Error("Failed to parse extra labels header", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
versionMessage, err := parseVersionMessageHeader(c)
|
|
if err != nil {
|
|
logger.Error("Failed to parse version message header", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
// 2. Convert Prometheus Rules to GMA
|
|
grafanaGroups := make([]*models.AlertRuleGroup, 0, len(promNamespaces))
|
|
for ns, rgs := range promNamespaces {
|
|
logger.Debug("Creating a new namespace", "title", ns)
|
|
namespace, errResp := srv.getOrCreateNamespace(c, ns, logger, workingFolderUID)
|
|
if errResp != nil {
|
|
logger.Error("Failed to create a new namespace", "folder_uid", workingFolderUID)
|
|
return errResp
|
|
}
|
|
|
|
for _, rg := range rgs {
|
|
// If we're importing recording rules, we can only import them if the feature is enabled,
|
|
// and the feature flag that enables configuring target datasources per-rule is also enabled.
|
|
if promGroupHasRecordingRules(rg) {
|
|
if !srv.cfg.RecordingRules.Enabled {
|
|
logger.Error("Cannot import recording rules", "error", errRecordingRulesNotEnabled)
|
|
return errorToResponse(errRecordingRulesNotEnabled)
|
|
}
|
|
}
|
|
|
|
grafanaGroup, err := srv.convertToGrafanaRuleGroup(
|
|
c,
|
|
ds,
|
|
tds,
|
|
namespace.UID,
|
|
rg,
|
|
pauseRecordingRules,
|
|
pauseAlertRules,
|
|
keepOriginalRuleDefinition,
|
|
notificationSettings,
|
|
extraLabels,
|
|
logger,
|
|
)
|
|
if err != nil {
|
|
logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
grafanaGroups = append(grafanaGroups, grafanaGroup)
|
|
}
|
|
}
|
|
|
|
// 3. Update the GMA Rules in the DB
|
|
err = srv.alertRuleService.ReplaceRuleGroups(c.Req.Context(), c.SignedInUser, grafanaGroups, provenance, versionMessage)
|
|
if err != nil {
|
|
logger.Error("Failed to replace rule groups", "error", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
return successfulResponse()
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) getOrCreateNamespace(c *contextmodel.ReqContext, title string, logger log.Logger, workingFolderUID string) (*folder.FolderReference, response.Response) {
|
|
logger.Debug("Getting or creating a new folder")
|
|
|
|
ns, created, err := srv.ruleStore.GetOrCreateNamespaceByTitle(
|
|
c.Req.Context(),
|
|
title,
|
|
c.GetOrgID(),
|
|
c.SignedInUser,
|
|
workingFolderUID,
|
|
)
|
|
if err != nil {
|
|
logger.Error("Failed to get or create a new folder", "error", err)
|
|
return nil, namespaceErrorResponse(err)
|
|
}
|
|
|
|
// Not all users have global-scoped permissions, even if they can create folders.
|
|
// For example, Editor users can create folders, but they have UID-scoped folder permissions.
|
|
// Permissions are populated in a middleware before this handler, and the folder we just created
|
|
// is not included in the permissions yet. We add it manually.
|
|
if created {
|
|
orgID := c.GetOrgID()
|
|
if c.Permissions == nil {
|
|
c.Permissions = make(map[int64]map[string][]string)
|
|
}
|
|
if c.Permissions[orgID] == nil {
|
|
c.Permissions[orgID] = make(map[string][]string)
|
|
}
|
|
|
|
folderScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ns.UID)
|
|
if c.Permissions[orgID][dashboards.ActionFoldersRead] == nil {
|
|
c.Permissions[orgID][dashboards.ActionFoldersRead] = []string{}
|
|
}
|
|
c.Permissions[orgID][dashboards.ActionFoldersRead] = append(c.Permissions[orgID][dashboards.ActionFoldersRead], folderScope)
|
|
}
|
|
|
|
logger.Debug("Using folder for the converted rules", "folder_uid", ns.UID)
|
|
|
|
return ns, nil
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(
|
|
c *contextmodel.ReqContext,
|
|
ds *datasources.DataSource,
|
|
tds *datasources.DataSource,
|
|
namespaceUID string,
|
|
promGroup apimodels.PrometheusRuleGroup,
|
|
pauseRecordingRules bool,
|
|
pauseAlertRules bool,
|
|
keepOriginalRuleDefinition bool,
|
|
notificationSettings []models.NotificationSettings,
|
|
extraLabels map[string]string,
|
|
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))
|
|
for i, r := range promGroup.Rules {
|
|
rules[i] = prom.PrometheusRule{
|
|
Alert: r.Alert,
|
|
Expr: r.Expr,
|
|
For: r.For,
|
|
KeepFiringFor: r.KeepFiringFor,
|
|
Labels: r.Labels,
|
|
Annotations: r.Annotations,
|
|
Record: r.Record,
|
|
}
|
|
}
|
|
group := prom.PrometheusRuleGroup{
|
|
Name: promGroup.Name,
|
|
Interval: promGroup.Interval,
|
|
Rules: rules,
|
|
QueryOffset: promGroup.QueryOffset,
|
|
Limit: promGroup.Limit,
|
|
Labels: promGroup.Labels,
|
|
}
|
|
|
|
converter, err := prom.NewConverter(
|
|
prom.Config{
|
|
DatasourceUID: ds.UID,
|
|
DatasourceType: ds.Type,
|
|
TargetDatasourceUID: tds.UID,
|
|
TargetDatasourceType: tds.Type,
|
|
DefaultInterval: srv.cfg.DefaultRuleEvaluationInterval,
|
|
RecordingRules: prom.RulesConfig{
|
|
IsPaused: pauseRecordingRules,
|
|
},
|
|
AlertRules: prom.RulesConfig{
|
|
IsPaused: pauseAlertRules,
|
|
},
|
|
KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition),
|
|
EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset,
|
|
NotificationSettings: notificationSettings,
|
|
ExtraLabels: extraLabels,
|
|
},
|
|
)
|
|
if err != nil {
|
|
logger.Error("Failed to create Prometheus converter", "datasource_uid", ds.UID, "datasource_type", ds.Type, "error", err)
|
|
return nil, err
|
|
}
|
|
|
|
grafanaGroup, err := converter.PrometheusRulesToGrafana(c.GetOrgID(), namespaceUID, group)
|
|
if err != nil {
|
|
logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err)
|
|
return nil, err
|
|
}
|
|
|
|
return grafanaGroup, nil
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostAlertmanagerConfig(c *contextmodel.ReqContext, amCfg apimodels.AlertmanagerUserConfig) response.Response {
|
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
|
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) {
|
|
return response.Error(http.StatusNotImplemented, "Not Implemented", nil)
|
|
}
|
|
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
identifier := parseConfigIdentifierHeader(c)
|
|
|
|
mergeMatchers, err := parseMergeMatchersHeader(c)
|
|
if err != nil {
|
|
logger.Error("Failed to parse merge matchers header", "error", err, "identifier", identifier)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
ec := apimodels.ExtraConfiguration{
|
|
Identifier: identifier,
|
|
MergeMatchers: mergeMatchers,
|
|
TemplateFiles: amCfg.TemplateFiles,
|
|
AlertmanagerConfig: amCfg.AlertmanagerConfig,
|
|
}
|
|
err = ec.Validate()
|
|
if err != nil {
|
|
logger.Error("Invalid alertmanager configuration", "error", err, "identifier", identifier)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
err = srv.am.SaveAndApplyExtraConfiguration(c.Req.Context(), c.GetOrgID(), ec)
|
|
if err != nil {
|
|
logger.Error("Failed to save alertmanager configuration", "error", err, "identifier", identifier)
|
|
return errorToResponse(fmt.Errorf("failed to save alertmanager configuration: %w", err))
|
|
}
|
|
|
|
logger.Info("Successfully updated alertmanager configuration with imported Prometheus config", "identifier", identifier)
|
|
return successfulResponse()
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetAlertmanagerConfig(c *contextmodel.ReqContext) response.Response {
|
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
|
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) {
|
|
return response.Error(http.StatusNotImplemented, "Not Implemented", nil)
|
|
}
|
|
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
ctx := c.Req.Context()
|
|
|
|
identifier := parseConfigIdentifierHeader(c)
|
|
|
|
cfg, err := srv.am.GetAlertmanagerConfiguration(ctx, c.GetOrgID(), false, false)
|
|
if err != nil {
|
|
logger.Error("failed to get alertmanager configuration", "err", err)
|
|
return errorToResponse(err)
|
|
}
|
|
|
|
var extraCfg *apimodels.ExtraConfiguration
|
|
for i := range cfg.ExtraConfigs {
|
|
if cfg.ExtraConfigs[i].Identifier == identifier {
|
|
extraCfg = &cfg.ExtraConfigs[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if extraCfg == nil {
|
|
return response.Error(http.StatusNotFound, "Alertmanager configuration not found", nil)
|
|
}
|
|
|
|
sanitizedConfig, err := extraCfg.GetSanitizedAlertmanagerConfigYAML()
|
|
if err != nil {
|
|
return response.Error(http.StatusBadRequest, "Invalid Alertmanager configuration format", err)
|
|
}
|
|
|
|
respBody := apimodels.AlertmanagerUserConfig{
|
|
AlertmanagerConfig: sanitizedConfig,
|
|
TemplateFiles: extraCfg.TemplateFiles,
|
|
}
|
|
|
|
resp := convertPrometheusResponse(c, http.StatusOK, respBody)
|
|
resp.SetHeader(configIdentifierHeader, extraCfg.Identifier)
|
|
resp.SetHeader(mergeMatchersHeader, formatMergeMatchers(extraCfg.MergeMatchers))
|
|
|
|
return resp
|
|
}
|
|
|
|
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteAlertmanagerConfig(c *contextmodel.ReqContext) response.Response {
|
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
|
if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingImportAlertmanagerAPI) {
|
|
return response.Error(http.StatusNotImplemented, "Not Implemented", nil)
|
|
}
|
|
|
|
logger := srv.logger.FromContext(c.Req.Context())
|
|
|
|
identifier := parseConfigIdentifierHeader(c)
|
|
|
|
err := srv.am.DeleteExtraConfiguration(c.Req.Context(), c.GetOrgID(), identifier)
|
|
if err != nil {
|
|
logger.Error("Failed to delete alertmanager configuration", "error", err, "identifier", identifier)
|
|
return errorToResponse(fmt.Errorf("failed to delete alertmanager configuration: %w", err))
|
|
}
|
|
|
|
logger.Info("Successfully deleted extra alertmanager configuration", "identifier", identifier)
|
|
return successfulResponse()
|
|
}
|
|
|
|
// parseBooleanHeader parses a boolean header value, returning an error if the header
|
|
// is present but invalid. If the header is not present, returns (false, nil).
|
|
func parseBooleanHeader(header string, headerName string) (bool, error) {
|
|
if header == "" {
|
|
return false, nil
|
|
}
|
|
val, err := strconv.ParseBool(header)
|
|
if err != nil {
|
|
return false, errInvalidHeaderValue(headerName, errors.New("must be 'true' or 'false'"))
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
func grafanaNamespacesToPrometheus(groups []models.AlertRuleGroupWithFolderFullpath) (map[string][]apimodels.PrometheusRuleGroup, error) {
|
|
result := map[string][]apimodels.PrometheusRuleGroup{}
|
|
|
|
for _, group := range groups {
|
|
// Since the folder can be nested but mimirtool does not support nested paths,
|
|
// we need to use only the last folder in the full path.
|
|
// For example, if the current working folder is "general" and the full path is "grafana/some folder/general/production",
|
|
// we should use the "production" folder.
|
|
folder := filepath.Base(group.FolderFullpath)
|
|
|
|
promGroup, err := grafanaRuleGroupToPrometheus(group.Title, group.Rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[folder] = append(result[folder], 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, err := rule.PrometheusRuleDefinition()
|
|
if err != nil {
|
|
return apimodels.PrometheusRuleGroup{}, fmt.Errorf("failed to get the Prometheus definition of the rule with UID %s: %w", rule.UID, err)
|
|
}
|
|
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 successfulResponse() response.Response {
|
|
return response.JSON(http.StatusAccepted, apimodels.ConvertPrometheusResponse{
|
|
Status: "success",
|
|
})
|
|
}
|
|
|
|
// getWorkingFolderUID returns the value of the folderUIDHeader
|
|
// if present. Otherwise, it returns the UID of the root folder.
|
|
func getWorkingFolderUID(c *contextmodel.ReqContext) string {
|
|
folderUID := strings.TrimSpace(c.Req.Header.Get(folderUIDHeader))
|
|
if folderUID != "" {
|
|
return folderUID
|
|
}
|
|
return folder.RootFolderUID
|
|
}
|
|
|
|
func namespaceErrorResponse(err error) response.Response {
|
|
if errors.Is(err, dashboards.ErrFolderNotFound) {
|
|
return response.Empty(http.StatusNotFound)
|
|
}
|
|
|
|
return toNamespaceErrorResponse(err)
|
|
}
|
|
|
|
func promGroupHasRecordingRules(promGroup apimodels.PrometheusRuleGroup) bool {
|
|
for _, rule := range promGroup.Rules {
|
|
if rule.Record != "" {
|
|
return true
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func parseNotificationSettingsHeader(ctx *contextmodel.ReqContext) ([]models.NotificationSettings, error) {
|
|
var notificationSettings []models.NotificationSettings
|
|
notificationSettingsJSON := ctx.Req.Header.Get(notificationSettingsHeader)
|
|
|
|
if notificationSettingsJSON != "" {
|
|
var settings apimodels.AlertRuleNotificationSettings
|
|
var err error
|
|
|
|
if err := json.Unmarshal([]byte(notificationSettingsJSON), &settings); err != nil {
|
|
return nil, errInvalidHeaderValue(notificationSettingsHeader, errors.New("invalid JSON"))
|
|
}
|
|
notificationSettings, err = validation.ValidateNotificationSettings(&settings)
|
|
if err != nil {
|
|
return nil, errInvalidHeaderValue(notificationSettingsHeader, err)
|
|
}
|
|
}
|
|
|
|
return notificationSettings, nil
|
|
}
|
|
|
|
// parseKeyValuePairs parses a comma-separated list of key=value pairs.
|
|
// Expected format: "key1=value1,key2=value2"
|
|
func parseKeyValuePairs(input string, headerName string) (map[string]string, error) {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
result := make(map[string]string)
|
|
|
|
for pair := range strings.SplitSeq(input, ",") {
|
|
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errInvalidHeaderValue(headerName, errors.New("format should be 'key=value,key2=value2'"))
|
|
}
|
|
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
if key == "" || value == "" {
|
|
return nil, errInvalidHeaderValue(headerName, errors.New("keys and values cannot be empty"))
|
|
}
|
|
|
|
result[key] = value
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// parseMergeMatchersHeader parses the merge matchers header value.
|
|
// Expected format: "key1=value1,key2=value2"
|
|
func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, error) {
|
|
matchersStr := strings.TrimSpace(c.Req.Header.Get(mergeMatchersHeader))
|
|
|
|
if matchersStr == "" {
|
|
return amconfig.Matchers{}, errInvalidHeaderValue(mergeMatchersHeader, errors.New("value cannot be empty"))
|
|
}
|
|
|
|
kvPairs, err := parseKeyValuePairs(matchersStr, mergeMatchersHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
matchers := amconfig.Matchers{}
|
|
for key, value := range kvPairs {
|
|
matchers = append(matchers, &labels.Matcher{
|
|
Type: labels.MatchEqual,
|
|
Name: key,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
return matchers, nil
|
|
}
|
|
|
|
// parseExtraLabelsHeader parses the extra labels header value.
|
|
// Expected format: "key1=value1,key2=value2"
|
|
func parseExtraLabelsHeader(c *contextmodel.ReqContext) (map[string]string, error) {
|
|
labelsStr := strings.TrimSpace(c.Req.Header.Get(extraLabelsHeader))
|
|
return parseKeyValuePairs(labelsStr, extraLabelsHeader)
|
|
}
|
|
|
|
// parseVersionMessageHeader obtains and validates the message header value.
|
|
func parseVersionMessageHeader(c *contextmodel.ReqContext) (string, error) {
|
|
str := strings.TrimSpace(c.Req.Header.Get(versionMessageHeader))
|
|
// Limit message to the same as the dashboards message.
|
|
if len(str) > 500 {
|
|
return "", errInvalidHeaderValue(versionMessageHeader, errors.New("must be less than 500 characters"))
|
|
}
|
|
return str, nil
|
|
}
|
|
|
|
func formatMergeMatchers(matchers amconfig.Matchers) string {
|
|
var pairs []string
|
|
for _, matcher := range matchers {
|
|
if matcher.Type == labels.MatchEqual {
|
|
pairs = append(pairs, fmt.Sprintf("%s=%s", matcher.Name, matcher.Value))
|
|
}
|
|
}
|
|
return strings.Join(pairs, ",")
|
|
}
|
|
|
|
func parseConfigIdentifierHeader(c *contextmodel.ReqContext) string {
|
|
identifier := strings.TrimSpace(c.Req.Header.Get(configIdentifierHeader))
|
|
if identifier == "" {
|
|
return defaultConfigIdentifier
|
|
}
|
|
return identifier
|
|
}
|
|
|
|
// convertPrometheusResponse returns a JSON or YAML response based on the Accept header.
|
|
// Default is YAML for backward compatibility with mimirtool.
|
|
func convertPrometheusResponse(c *contextmodel.ReqContext, status int, body interface{}) *response.NormalResponse {
|
|
acceptHeader := c.Req.Header.Get("Accept")
|
|
|
|
for _, accept := range strings.Split(acceptHeader, ",") {
|
|
mediaType, _, err := mime.ParseMediaType(accept)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if mediaType == "application/json" {
|
|
return response.JSON(status, body)
|
|
}
|
|
}
|
|
|
|
return response.YAML(status, body)
|
|
}
|