The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/pkg/services/ngalert/api/api_convert_prometheus_test.go

1978 lines
66 KiB

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
amconfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/pkg/labels"
prommodel "github.com/prometheus/common/model"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
acfakes "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
const (
existingDSUID = "test-ds"
)
func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
simpleGroup := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
{
Alert: "TestAlert",
Expr: "up == 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
},
{
Record: "recorded-metric",
Expr: "vector(1)",
Labels: map[string]string{
"severity": "warning",
},
},
},
}
t.Run("without datasource UID header should return 400", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "")
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{})
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "Missing datasource UID header")
})
t.Run("with invalid datasource should return error", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "non-existing-ds")
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{})
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("with rule group without evaluation interval should return 202", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
})
t.Run("should replace an existing rule group", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// 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)
// And a rule
rule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithGroupName(simpleGroup.Name)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("123")).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
// Get the rules
remaining, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
})
require.NoError(t, err)
require.Len(t, remaining, 2)
// Create a map of rule titles to their expected definitions
expectedRules := make(map[string]string)
for _, rule := range simpleGroup.Rules {
if rule.Alert != "" {
promRuleYAML, err := yaml.Marshal(rule)
require.NoError(t, err)
expectedRules[rule.Alert] = string(promRuleYAML)
} else if rule.Record != "" {
promRuleYAML, err := yaml.Marshal(rule)
require.NoError(t, err)
expectedRules[rule.Record] = string(promRuleYAML)
}
}
// Verify each rule matches its expected definition
for _, r := range remaining {
require.Equal(t, simpleGroup.Name, r.RuleGroup)
expectedDef, exists := expectedRules[r.Title]
require.True(t, exists, "unexpected rule title: %s", r.Title)
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)
}
})
t.Run("should fail to replace a provisioned rule group", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// 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)
rule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithGroupName(simpleGroup.Name)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("123")).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
// mark the rule as provisioned
err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceAPI)
require.NoError(t, err)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, fldr.Title, simpleGroup)
require.Equal(t, http.StatusConflict, response.Status())
// Verify the rule is still present
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
require.NoError(t, err)
require.NotNil(t, remaining)
})
t.Run("with no access to the datasource should return 403", func(t *testing.T) {
acFake := &acfakes.FakeRuleService{}
srv, _, _ := createConvertPrometheusSrv(t, withFakeAccessControlRuleService(acFake))
acFake.AuthorizeRuleChangesFunc = func(context.Context, identity.Requester, *store.GroupDelta) error {
return datasources.ErrDataSourceAccessDenied
}
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup)
require.Equal(t, http.StatusForbidden, response.Status())
require.Contains(t, string(response.Body()), "data source access denied")
})
t.Run("when alert rule quota limit exceeded", func(t *testing.T) {
quotas := &provisioning.MockQuotaChecker{}
quotas.EXPECT().LimitExceeded()
srv, _, _ := createConvertPrometheusSrv(t, withQuotaChecker(quotas))
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "folder", simpleGroup)
require.Equal(t, http.StatusForbidden, response.Status())
require.Contains(t, string(response.Body()), "quota has been exceeded")
})
t.Run("with valid pause header values should return 202", func(t *testing.T) {
testCases := []struct {
name string
headerName string
headerValue string
}{
{
name: "true recording rules pause value",
headerName: recordingRulesPausedHeader,
headerValue: "true",
},
{
name: "false recording rules pause value",
headerName: recordingRulesPausedHeader,
headerValue: "false",
},
{
name: "true alert rules pause value",
headerName: alertRulesPausedHeader,
headerValue: "true",
},
{
name: "false alert rules pause value",
headerName: alertRulesPausedHeader,
headerValue: "false",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
})
}
})
t.Run("with invalid pause header values should return 400", func(t *testing.T) {
testCases := []struct {
name string
headerName string
headerValue string
expectedError string
}{
{
name: "invalid recording rules pause value",
headerName: recordingRulesPausedHeader,
headerValue: "invalid",
expectedError: "Invalid value for header X-Grafana-Alerting-Recording-Rules-Paused: must be 'true' or 'false'",
},
{
name: "invalid alert rules pause value",
headerName: alertRulesPausedHeader,
headerValue: "invalid",
expectedError: "Invalid value for header X-Grafana-Alerting-Alert-Rules-Paused: must be 'true' or 'false'",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), tc.expectedError)
})
}
})
t.Run("with valid request should return 202", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
})
t.Run("with disabled recording rules", func(t *testing.T) {
testCases := []struct {
name string
recordingRules bool
expectedStatus int
}{
{
name: "when recording rules are enabled",
recordingRules: true,
expectedStatus: http.StatusAccepted,
},
{
name: "when recording rules are disabled",
recordingRules: false,
expectedStatus: http.StatusBadRequest,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
features := featuremgmt.WithFeatures()
srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(features))
srv.cfg.RecordingRules.Enabled = tc.recordingRules
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, tc.expectedStatus, response.Status())
})
}
})
t.Run("with disable provenance header should use ProvenanceNone", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withProvenanceStore(provenanceStore), withFolderService(folderService))
// 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)
}
})
t.Run("returns error when target datasource does not exist", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(targetDatasourceUIDHeader, "some-data-source")
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusNotFound, response.Status())
require.Contains(t, string(response.Body()), "failed to get recording rules target datasource")
})
t.Run("uses target datasource for recording rules", func(t *testing.T) {
srv, dsCache, ruleStore := createConvertPrometheusSrv(t)
rc := createRequestCtx()
targetDSUID := util.GenerateShortUID()
ds := &datasources.DataSource{
UID: targetDSUID,
Type: datasources.DS_PROMETHEUS,
}
dsCache.DataSources = append(dsCache.DataSources, ds)
rc.Req.Header.Set(targetDatasourceUIDHeader, targetDSUID)
simpleGroup := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
{
Record: "recorded-metric",
Expr: "vector(1)",
Labels: map[string]string{
"severity": "warning",
},
},
},
}
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
remaining, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
})
require.NoError(t, err)
require.Len(t, remaining, 1)
require.NotNil(t, remaining[0].Record)
require.Equal(t, targetDSUID, remaining[0].Record.TargetDatasourceUID)
})
t.Run("sets notification settings for rules if specified", func(t *testing.T) {
srv, _, ruleStore := createConvertPrometheusSrv(t)
rc := createRequestCtx()
receiver := "test-receiver"
groupBy := []string{"cluster", "pod"}
settings := apimodels.AlertRuleNotificationSettings{
Receiver: receiver,
GroupBy: groupBy,
}
settingsJSON, err := json.Marshal(settings)
require.NoError(t, err)
rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON))
simpleGroup := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
{
Alert: "TestAlert",
Expr: "up == 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
},
},
}
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
createdRules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
OrgID: 1,
})
require.NoError(t, err)
require.Len(t, createdRules, 1)
require.Len(t, createdRules[0].NotificationSettings, 1)
require.Equal(t, receiver, createdRules[0].NotificationSettings[0].Receiver)
require.Equal(t, groupBy, createdRules[0].NotificationSettings[0].GroupBy)
})
t.Run("returns error when notification settings header contains invalid JSON", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(notificationSettingsHeader, "{invalid json")
simpleGroup := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
{
Alert: "TestAlert",
Expr: "up == 0",
},
},
}
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings")
})
t.Run("returns error when notification settings contain invalid values", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
settings := apimodels.AlertRuleNotificationSettings{
Receiver: "", // empty receiver is invalid
}
settingsJSON, err := json.Marshal(settings)
require.NoError(t, err)
rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON))
simpleGroup := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
{
Alert: "TestAlert",
Expr: "up == 0",
},
},
}
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings")
})
}
func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
promRule := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRuleYAML, err := yaml.Marshal(promRule)
require.NoError(t, err)
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "non-existent", "test")
require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body()))
})
t.Run("with non-existent group should return 404", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "test", "non-existent")
require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body()))
})
t.Run("with valid request should return 200", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create two folders in the root folder
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
// Create rules in both folders
groupKey := models.GenerateGroupKey(rc.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = "test-group"
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle("TestAlert")).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
// Create a rule in another group
groupKeyNotFromProm := models.GenerateGroupKey(rc.OrgID)
groupKeyNotFromProm.NamespaceUID = fldr.UID
groupKeyNotFromProm.RuleGroup = "test-group-2"
ruleInOtherFolder := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKeyNotFromProm)).
With(models.RuleGen.WithTitle("in another group")).
With(models.RuleGen.WithIntervalSeconds(60)).
GenerateRef()
ruleStore.PutRule(context.Background(), ruleInOtherFolder)
getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup)
require.Equal(t, http.StatusOK, getResp.Status())
var respGroup apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(getResp.Body(), &respGroup)
require.NoError(t, err)
require.Equal(t, groupKey.RuleGroup, respGroup.Name)
require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), respGroup.Interval)
require.Len(t, respGroup.Rules, 1)
require.Equal(t, promRule.Alert, respGroup.Rules[0].Alert)
})
}
func TestRouteConvertPrometheusGetNamespace(t *testing.T) {
promRule1 := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRule2 := apimodels.PrometheusRule{
Alert: "test alert 2",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "also critical",
},
Annotations: map[string]string{
"summary": "test alert 2",
},
}
promGroup1 := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule1,
},
}
promGroup2 := apimodels.PrometheusRuleGroup{
Name: "Test Group 2",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule2,
},
}
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetNamespace(rc, "non-existent")
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("with valid request should return 200", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create two folders in the root folder
fldr := randFolder()
fldr.ParentUID = ""
fldr2 := randFolder()
fldr2.ParentUID = ""
folderService.ExpectedFolders = []*folder.Folder{fldr, fldr2}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr, fldr2)
// Create a Grafana rule for each Prometheus rule
for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} {
groupKey := models.GenerateGroupKey(rc.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = promGroup.Name
promRuleYAML, err := yaml.Marshal(promGroup.Rules[0])
require.NoError(t, err)
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
}
response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Len(t, respNamespaces, 1)
require.Contains(t, respNamespaces, fldr.Title)
require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2})
})
}
func TestRouteConvertPrometheusGetRules(t *testing.T) {
promRule1 := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRule2 := apimodels.PrometheusRule{
Alert: "test alert 2",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "also critical",
},
Annotations: map[string]string{
"summary": "test alert 2",
},
}
promGroup1 := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule1,
},
}
promGroup2 := apimodels.PrometheusRuleGroup{
Name: "Test Group 2",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule2,
},
}
assertEmptyResponse := func(t *testing.T, srv *ConvertPrometheusSrv, reqCtx *contextmodel.ReqContext) {
t.Helper()
response := srv.RouteConvertPrometheusGetRules(reqCtx)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Empty(t, respNamespaces)
}
// testForEmptyResponses tests that RouteConvertPrometheusGetRules returns an empty response
// when there are no rules in the folder or the folder does not exist.
testForEmptyResponses := func(t *testing.T, withCustomFolderHeader bool) {
rc := createRequestCtx()
unknownFolderUID := "some unknown folder"
rootFolderUID := ""
if withCustomFolderHeader {
rootFolderUID = unknownFolderUID
rc.Req.Header.Set(folderUIDHeader, unknownFolderUID)
}
t.Run("for non-existent folder should return empty response", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
assertEmptyResponse(t, srv, rc)
})
t.Run("for existing folder with no children should return empty response", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
fldr := randFolder()
fldr.UID = unknownFolderUID
fldr.ParentUID = rootFolderUID
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
assertEmptyResponse(t, srv, rc)
})
}
t.Run("without custom root folder", func(t *testing.T) {
testForEmptyResponses(t, false)
})
t.Run("with custom root folder", func(t *testing.T) {
testForEmptyResponses(t, true)
})
t.Run("with rules should return 200 with rules", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
// Create a folder in the root
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
// Create a Grafana rule for each Prometheus rule
for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} {
groupKey := models.GenerateGroupKey(rc.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = promGroup.Name
promRuleYAML, err := yaml.Marshal(promGroup.Rules[0])
require.NoError(t, err)
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
}
response := srv.RouteConvertPrometheusGetRules(rc)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Len(t, respNamespaces, 1)
require.Contains(t, respNamespaces, fldr.Title)
require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2})
})
}
func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) {
t.Run("for non-existent folder should return 404", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent")
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("for existing folder with no groups should return 404", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent")
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("valid request should delete rules", func(t *testing.T) {
initNamespace := func(promDefinition string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...)
// 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)
rule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(promDefinition)).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
return srv, ruleStore, fldr, rule
}
t.Run("valid request should delete rules", func(t *testing.T) {
srv, ruleStore, fldr, rule := initNamespace("prometheus definition")
// Create another rule group in a different namespace that should not be deleted
otherGroupName := "other-group"
otherRule := models.RuleGen.
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithGroupName(otherGroupName)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("other prometheus definition")).
GenerateRef()
ruleStore.PutRule(context.Background(), otherRule)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rule in the specified group was deleted
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
require.Error(t, err)
require.Nil(t, remaining)
// Verify the rule in the other group still exists
remainingOther, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: otherRule.UID,
OrgID: otherRule.OrgID,
})
require.NoError(t, err)
require.NotNil(t, remainingOther)
})
t.Run("fails to delete rules when they are provisioned", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, ruleStore, fldr, rule := initNamespace("", withProvenanceStore(provenanceStore))
rc := createRequestCtx()
// Create a provisioned rule
rule2 := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("prometheus definition")).
GenerateRef()
ruleStore.PutRule(context.Background(), rule2)
err := provenanceStore.SetProvenance(context.Background(), rule2, 1, models.ProvenanceAPI)
require.NoError(t, err)
response := srv.RouteConvertPrometheusDeleteNamespace(rc, fldr.Title)
require.Equal(t, http.StatusConflict, response.Status())
// Verify the rule is still present
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
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)
})
})
}
func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) {
t.Run("for non-existent folder should return 404", func(t *testing.T) {
srv, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, "non-existent", "test-group")
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("for existing folder with no group should return 404", func(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
rc := createRequestCtx()
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, "test-group")
require.Equal(t, http.StatusNotFound, response.Status())
})
const groupName = "test-group"
t.Run("valid request should delete rules", func(t *testing.T) {
initGroup := func(promDefinition string, groupName string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, append(opts, withFolderService(folderService))...)
// 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)
rule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithGroupName(groupName)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(promDefinition)).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
return srv, ruleStore, fldr, rule
}
t.Run("valid request should delete rules", func(t *testing.T) {
srv, ruleStore, fldr, rule := initGroup("prometheus definition", groupName)
rc := createRequestCtx()
// Create another rule in a different group that should not be deleted
otherGroupName := "other-group"
otherRule := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithGroupName(otherGroupName)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("other prometheus definition")).
GenerateRef()
ruleStore.PutRule(context.Background(), otherRule)
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)
// Verify the otherRule from the "other-group" is still present
otherRuleRefreshed, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: otherRule.UID,
OrgID: otherRule.OrgID,
})
require.NoError(t, err)
require.NotNil(t, otherRuleRefreshed)
})
t.Run("fails to delete rules when they are provisioned", func(t *testing.T) {
provenanceStore := fakes.NewFakeProvisioningStore()
srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore))
rc := createRequestCtx()
// Create a provisioned rule
rule2 := models.RuleGen.
With(models.RuleGen.WithNamespaceUID(fldr.UID)).
With(models.RuleGen.WithOrgID(1)).
With(models.RuleGen.WithGroupName(groupName)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition("prometheus definition")).
GenerateRef()
ruleStore.PutRule(context.Background(), rule2)
err := provenanceStore.SetProvenance(context.Background(), rule2, 1, models.ProvenanceAPI)
require.NoError(t, err)
response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName)
require.Equal(t, http.StatusConflict, response.Status())
// Verify the rule is still present
remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{
UID: rule.UID,
OrgID: rule.OrgID,
})
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)
})
})
}
func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) {
folderService := foldertest.NewFakeService()
srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService))
req := createRequestCtx()
req.Req.Header.Set(datasourceUIDHeader, existingDSUID)
// Create test prometheus rules
promAlertRule := apimodels.PrometheusRule{
Alert: "TestAlert",
Expr: "up == 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
}
promRecordingRule := apimodels.PrometheusRule{
Record: "TestRecordingRule",
Expr: "up == 0",
}
promGroup1 := apimodels.PrometheusRuleGroup{
Name: "TestGroup1",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{promAlertRule},
Labels: map[string]string{
"group_label": "group_value",
},
}
queryOffset := prommodel.Duration(5 * time.Minute)
promGroup2 := apimodels.PrometheusRuleGroup{
Name: "TestGroup2",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{promAlertRule},
QueryOffset: &queryOffset,
}
promGroup3 := apimodels.PrometheusRuleGroup{
Name: "TestGroup3",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{promAlertRule, promRecordingRule},
}
promGroups := map[string][]apimodels.PrometheusRuleGroup{
"namespace1": {promGroup1, promGroup2},
"namespace2": {promGroup3},
}
t.Run("should convert prometheus rules to Grafana rules", func(t *testing.T) {
// Call the endpoint
response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rules were created
rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{
OrgID: req.GetOrgID(),
})
require.NoError(t, err)
require.Len(t, rules, 4)
// Verify rule content
for _, rule := range rules {
require.Equal(t, int64(60), rule.IntervalSeconds) // 1 minute interval
// Check that the rule matches one of our original prometheus rules
switch rule.RuleGroup {
case "TestGroup1":
require.Equal(t, "TestAlert", rule.Title)
require.Equal(t, "critical", rule.Labels["severity"])
require.Equal(t, 5*time.Minute, rule.For)
require.Equal(t, "group_value", rule.Labels["group_label"])
case "TestGroup2":
require.Equal(t, "TestAlert", rule.Title)
require.Equal(t, "critical", rule.Labels["severity"])
require.Equal(t, 5*time.Minute, rule.For)
require.Equal(t, models.Duration(queryOffset), rule.Data[0].RelativeTimeRange.To)
case "TestGroup3":
switch rule.Title {
case "TestAlert":
require.Equal(t, "critical", rule.Labels["severity"])
require.Equal(t, 5*time.Minute, rule.For)
case "TestRecordingRule":
require.Equal(t, "TestRecordingRule", rule.Record.Metric)
default:
t.Fatalf("unexpected rule title: %s", rule.Title)
}
default:
t.Fatalf("unexpected rule group: %s", rule.RuleGroup)
}
}
})
t.Run("should convert Prometheus rules to Grafana rules but pause recording rules", func(t *testing.T) {
clear(ruleStore.Rules)
req.Req.Header.Set(alertRulesPausedHeader, "false")
req.Req.Header.Set(recordingRulesPausedHeader, "true")
// Call the endpoint
response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rules were created
rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{
OrgID: req.GetOrgID(),
})
require.NoError(t, err)
require.Len(t, rules, 4)
// Verify the recording rule is paused
for _, rule := range rules {
if rule.Record != nil {
require.True(t, rule.IsPaused)
}
}
})
t.Run("should convert Prometheus rules to Grafana rules but pause alert rules", func(t *testing.T) {
clear(ruleStore.Rules)
req.Req.Header.Set(alertRulesPausedHeader, "true")
req.Req.Header.Set(recordingRulesPausedHeader, "false")
// Call the endpoint
response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rules were created
rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{
OrgID: req.GetOrgID(),
})
require.NoError(t, err)
require.Len(t, rules, 4)
// Verify the alert rule is paused
for _, rule := range rules {
if rule.Record == nil {
require.True(t, rule.IsPaused)
}
}
})
t.Run("should convert Prometheus rules to Grafana rules but pause both alert and recording rules", func(t *testing.T) {
clear(ruleStore.Rules)
req.Req.Header.Set(recordingRulesPausedHeader, "true")
req.Req.Header.Set(alertRulesPausedHeader, "true")
// Call the endpoint
response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rules were created
rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{
OrgID: req.GetOrgID(),
})
require.NoError(t, err)
require.Len(t, rules, 4)
// Verify the alert rule is paused
for _, rule := range rules {
require.True(t, rule.IsPaused)
}
})
t.Run("convert Prometheus rules to Grafana rules into a specified target folder", func(t *testing.T) {
clear(ruleStore.Rules)
// Create a target folder to move the rules into
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
req.Req.Header.Del(recordingRulesPausedHeader)
req.Req.Header.Del(alertRulesPausedHeader)
req.Req.Header.Set(folderUIDHeader, fldr.UID)
// Call the endpoint
response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups)
require.Equal(t, http.StatusAccepted, response.Status())
// Verify the rules were created
rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{
OrgID: req.GetOrgID(),
})
require.NoError(t, err)
require.Len(t, rules, 4)
for _, rule := range rules {
parentFolders, err := folderService.GetParents(context.Background(), folder.GetParentsQuery{UID: rule.NamespaceUID, OrgID: 1})
require.NoError(t, err)
require.Len(t, parentFolders, 1)
require.Equal(t, fldr.UID, parentFolders[0].UID)
}
})
}
type convertPrometheusSrvOptions struct {
provenanceStore provisioning.ProvisioningStore
fakeAccessControlRuleService *acfakes.FakeRuleService
quotaChecker *provisioning.MockQuotaChecker
featureToggles featuremgmt.FeatureToggles
alertmanager Alertmanager
folderService folder.Service
}
type convertPrometheusSrvOptionsFunc func(*convertPrometheusSrvOptions)
func withProvenanceStore(store provisioning.ProvisioningStore) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.provenanceStore = store
}
}
func withFakeAccessControlRuleService(service *acfakes.FakeRuleService) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.fakeAccessControlRuleService = service
}
}
func withQuotaChecker(checker *provisioning.MockQuotaChecker) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.quotaChecker = checker
}
}
func withFeatureToggles(toggles featuremgmt.FeatureToggles) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.featureToggles = toggles
}
}
func withAlertmanager(am Alertmanager) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.alertmanager = am
}
}
func withFolderService(f folder.Service) convertPrometheusSrvOptionsFunc {
return func(opts *convertPrometheusSrvOptions) {
opts.folderService = f
}
}
func createConvertPrometheusSrv(t *testing.T, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *dsfakes.FakeCacheService, *fakes.RuleStore) {
t.Helper()
// By default the quota checker will allow the operation
quotas := &provisioning.MockQuotaChecker{}
quotas.EXPECT().LimitOK()
options := convertPrometheusSrvOptions{
provenanceStore: fakes.NewFakeProvisioningStore(),
fakeAccessControlRuleService: &acfakes.FakeRuleService{},
quotaChecker: quotas,
folderService: foldertest.NewFakeService(),
}
for _, opt := range opts {
opt(&options)
}
ruleStore := fakes.NewRuleStore(t)
folder := randFolder()
ruleStore.Folders[1] = append(ruleStore.Folders[1], folder)
dsCache := &dsfakes.FakeCacheService{}
ds := &datasources.DataSource{
UID: existingDSUID,
Type: datasources.DS_PROMETHEUS,
}
dsCache.DataSources = append(dsCache.DataSources, ds)
alertRuleService := provisioning.NewAlertRuleService(
ruleStore,
options.provenanceStore,
options.folderService,
options.quotaChecker,
&provisioning.NopTransactionManager{},
60,
10,
100,
log.New("test"),
&provisioning.NotificationSettingsValidatorProviderFake{},
options.fakeAccessControlRuleService,
)
cfg := &setting.UnifiedAlertingSettings{
DefaultRuleEvaluationInterval: 1 * time.Minute,
RecordingRules: setting.RecordingRuleSettings{
Enabled: true,
},
}
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService, options.featureToggles, options.alertmanager)
return srv, dsCache, ruleStore
}
func createRequestCtx() *contextmodel.ReqContext {
req := httptest.NewRequest("GET", "http://localhost", nil)
req.Header.Set(datasourceUIDHeader, existingDSUID)
return &contextmodel.ReqContext{
Context: &web.Context{
Req: req,
Resp: web.NewResponseWriter("GET", httptest.NewRecorder()),
},
SignedInUser: &user.SignedInUser{OrgID: 1},
}
}
// Test parseBooleanHeader function which handles boolean header values
func TestParseBooleanHeader(t *testing.T) {
headerName := "X-Test-Header"
t.Run("should return false when header is not present", func(t *testing.T) {
result, err := parseBooleanHeader("", headerName)
require.NoError(t, err)
require.False(t, result)
})
t.Run("should return true when header is 'true'", func(t *testing.T) {
result, err := parseBooleanHeader("true", headerName)
require.NoError(t, err)
require.True(t, result)
})
t.Run("should return false when header is 'false'", func(t *testing.T) {
result, err := parseBooleanHeader("false", headerName)
require.NoError(t, err)
require.False(t, result)
})
t.Run("should return true when header is 'TRUE' (case insensitive)", func(t *testing.T) {
result, err := parseBooleanHeader("TRUE", headerName)
require.NoError(t, err)
require.True(t, result)
})
t.Run("should return error when header has invalid value", func(t *testing.T) {
_, err := parseBooleanHeader("invalid", headerName)
require.Error(t, err)
require.ErrorContains(t, err, "Invalid value for header")
})
t.Run("should return error when header is numeric but not 0/1", func(t *testing.T) {
_, err := parseBooleanHeader("2", headerName)
require.Error(t, err)
})
}
func TestGetWorkingFolderUID(t *testing.T) {
t.Run("should return root folder UID when header is not present", func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Del(folderUIDHeader)
folderUID := getWorkingFolderUID(rc)
require.Equal(t, folder.RootFolderUID, folderUID)
})
t.Run("should return specified folder UID when header is present", func(t *testing.T) {
rc := createRequestCtx()
specifiedFolderUID := "specified-folder-uid"
rc.Req.Header.Set(folderUIDHeader, specifiedFolderUID)
folderUID := getWorkingFolderUID(rc)
require.Equal(t, specifiedFolderUID, folderUID)
})
t.Run("should return root folder UID when header is empty", func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(folderUIDHeader, "")
folderUID := getWorkingFolderUID(rc)
require.Equal(t, folder.RootFolderUID, folderUID)
})
t.Run("should trim whitespace from header value", func(t *testing.T) {
rc := createRequestCtx()
specifiedFolderUID := "specified-folder-uid"
rc.Req.Header.Set(folderUIDHeader, " "+specifiedFolderUID+" ")
folderUID := getWorkingFolderUID(rc)
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)
})
}
type mockAlertmanager struct {
mock.Mock
}
func (m *mockAlertmanager) SaveAndApplyExtraConfiguration(ctx context.Context, org int64, extraConfig apimodels.ExtraConfiguration) error {
args := m.Called(ctx, org, extraConfig)
return args.Error(0)
}
func (m *mockAlertmanager) GetAlertmanagerConfiguration(ctx context.Context, org int64, withAutogen bool) (apimodels.GettableUserConfig, error) {
args := m.Called(ctx, org, withAutogen)
return args.Get(0).(apimodels.GettableUserConfig), args.Error(1)
}
func (m *mockAlertmanager) DeleteExtraConfiguration(ctx context.Context, org int64, identifier string) error {
args := m.Called(ctx, org, identifier)
return args.Error(0)
}
func TestRouteConvertPrometheusPostAlertmanagerConfig(t *testing.T) {
const identifier = "test-config"
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
t.Run("should parse headers and call SaveAndApplyExtraConfiguration", func(t *testing.T) {
mockAM.On("SaveAndApplyExtraConfiguration", mock.Anything, int64(1), mock.MatchedBy(func(extraConfig apimodels.ExtraConfiguration) bool {
return extraConfig.Identifier == identifier &&
len(extraConfig.MergeMatchers) == 2 &&
len(extraConfig.TemplateFiles) == 1 &&
extraConfig.TemplateFiles["test.tmpl"] == "{{ define \"test\" }}Hello{{ end }}"
})).Return(nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
rc.Req.Header.Set(mergeMatchersHeader, "environment=production,team=backend")
amCfg := apimodels.AlertmanagerUserConfig{
AlertmanagerConfig: `{
"route": {
"receiver": "default"
},
"receivers": [
{
"name": "default"
}
]
}`,
TemplateFiles: map[string]string{
"test.tmpl": "{{ define \"test\" }}Hello{{ end }}",
},
}
response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg)
require.Equal(t, http.StatusAccepted, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should use default identifier when header is missing", func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(mergeMatchersHeader, "test=value")
mockAM := &mockAlertmanager{}
mockAM.On("SaveAndApplyExtraConfiguration", mock.Anything, int64(1), mock.MatchedBy(func(extraConfig apimodels.ExtraConfiguration) bool {
return extraConfig.Identifier == defaultConfigIdentifier
})).Return(nil)
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
amCfg := apimodels.AlertmanagerUserConfig{}
response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg)
require.Equal(t, http.StatusAccepted, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return error when merge matchers header has invalid format", func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
rc.Req.Header.Set(mergeMatchersHeader, "invalid-format")
amCfg := apimodels.AlertmanagerUserConfig{}
response := srv.RouteConvertPrometheusPostAlertmanagerConfig(rc, amCfg)
require.Equal(t, http.StatusBadRequest, response.Status())
require.Contains(t, string(response.Body()), "format should be 'key=value,key2=value2'")
})
}
func TestRouteConvertPrometheusGetAlertmanagerConfig(t *testing.T) {
const identifier = "test-config"
const orgID = int64(1)
t.Run("without feature flag should return 501", func(t *testing.T) {
ft := featuremgmt.WithFeatures()
srv, _, _ := createConvertPrometheusSrv(t, withFeatureToggles(ft))
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusNotImplemented, response.Status())
})
t.Run("without config identifier header should use default identifier", func(t *testing.T) {
mockAM := &mockAlertmanager{}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: defaultConfigIdentifier,
AlertmanagerConfig: `route:
receiver: default
receivers:
- name: default`,
},
},
}, nil)
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusOK, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("with empty config identifier header should use default identifier", func(t *testing.T) {
mockAM := &mockAlertmanager{}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: defaultConfigIdentifier,
AlertmanagerConfig: `route:
receiver: default
receivers:
- name: default`,
},
},
}, nil)
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, "")
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusOK, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return config when it is found", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
// Create a config with secrets to check that they will be hided in the response.
expectedConfig := apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: identifier,
TemplateFiles: map[string]string{
"test.tmpl": "{{ define \"test\" }}Hello{{ end }}",
},
AlertmanagerConfig: `route:
receiver: webhook
receivers:
- name: webhook
webhook_configs:
- url: "http://localhost/webhook"
http_config:
bearer_token: "some-token"
`,
},
},
}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, int64(1), false).Return(expectedConfig, nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusOK, response.Status())
expectedResponse := `alertmanager_config: |
route:
receiver: webhook
continue: false
receivers:
- name: webhook
webhook_configs:
- send_resolved: true
http_config:
authorization:
type: Bearer
credentials: <secret>
follow_redirects: true
enable_http2: true
url: <secret>
url_file: ""
max_alerts: 0
timeout: 0s
templates: []
template_files:
test.tmpl: '{{ define "test" }}Hello{{ end }}'`
require.YAMLEq(t, expectedResponse, string(response.Body()))
mockAM.AssertExpectations(t)
})
t.Run("when config not found should return 404", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
expectedConfig := apimodels.GettableUserConfig{
ExtraConfigs: []apimodels.ExtraConfiguration{
{
Identifier: "other-config",
TemplateFiles: map[string]string{
"test.tmpl": "{{ define \"test\" }}Hello{{ end }}",
},
AlertmanagerConfig: `route:
receiver: default
receivers:
- name: default`,
},
},
}
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(expectedConfig, nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusNotFound, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return error when GetAlertmanagerConfiguration fails", func(t *testing.T) {
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
mockAM.On("GetAlertmanagerConfiguration", mock.Anything, orgID, false).Return(apimodels.GettableUserConfig{}, errors.New("config error")).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusGetAlertmanagerConfig(rc)
require.Equal(t, http.StatusInternalServerError, response.Status())
mockAM.AssertExpectations(t)
})
}
func TestParseMergeMatchersHeader(t *testing.T) {
testCases := []struct {
name string
headerValue string
expectedError bool
expectedMatchers amconfig.Matchers
}{
{
name: "empty header should return error",
headerValue: "",
expectedError: true,
},
{
name: "single matcher should parse correctly",
headerValue: "env=prod",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
},
},
{
name: "multiple matchers should be parsed correctly",
headerValue: "env=prod,team=alerting",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
},
},
{
name: "matchers with spaces should be parsed correctly",
headerValue: " env = prod , team = alerting ",
expectedError: false,
expectedMatchers: amconfig.Matchers{
{Type: labels.MatchEqual, Name: "env", Value: "prod"},
{Type: labels.MatchEqual, Name: "team", Value: "alerting"},
},
},
{
name: "invalid format without equals should return error",
headerValue: "env:prod",
expectedError: true,
},
{
name: "empty key should return error",
headerValue: "=prod",
expectedError: true,
},
{
name: "empty value should return error",
headerValue: "env=",
expectedError: true,
},
{
name: "missing value should return error",
headerValue: "env",
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(mergeMatchersHeader, tc.headerValue)
matchers, err := parseMergeMatchersHeader(rc)
if tc.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.ElementsMatch(t, tc.expectedMatchers, matchers)
}
})
}
}
func TestParseConfigIdentifierHeader(t *testing.T) {
testCases := []struct {
name string
headerValue string
expectedValue string
expectedError bool
}{
{
name: "valid identifier should parse correctly",
headerValue: "test-config",
expectedValue: "test-config",
expectedError: false,
},
{
name: "identifier with spaces should be trimmed",
headerValue: " test-config ",
expectedValue: "test-config",
expectedError: false,
},
{
name: "empty identifier should return the default value",
headerValue: "",
expectedValue: defaultConfigIdentifier,
},
{
name: "whitespace only identifier should return the default value",
headerValue: " ",
expectedValue: defaultConfigIdentifier,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, tc.headerValue)
identifier := parseConfigIdentifierHeader(rc)
require.Equal(t, tc.expectedValue, identifier)
})
}
}
func TestFormatMergeMatchers(t *testing.T) {
t.Run("empty matchers should return empty string", func(t *testing.T) {
result := formatMergeMatchers(nil)
require.Equal(t, "", result)
})
t.Run("single matcher should format correctly", func(t *testing.T) {
matchers := amconfig.Matchers{
&labels.Matcher{
Type: labels.MatchEqual,
Name: "env",
Value: "prod",
},
}
result := formatMergeMatchers(matchers)
require.Equal(t, "env=prod", result)
})
t.Run("multiple matchers should format correctly", func(t *testing.T) {
matchers := amconfig.Matchers{
&labels.Matcher{
Type: labels.MatchEqual,
Name: "env",
Value: "prod",
},
&labels.Matcher{
Type: labels.MatchEqual,
Name: "team",
Value: "backend",
},
}
result := formatMergeMatchers(matchers)
require.Equal(t, "env=prod,team=backend", result)
})
}
func TestRouteConvertPrometheusDeleteAlertmanagerConfig(t *testing.T) {
const identifier = "test-config"
const orgID = int64(1)
mockAM := &mockAlertmanager{}
ft := featuremgmt.WithFeatures(featuremgmt.FlagAlertingImportAlertmanagerAPI)
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
t.Run("should parse identifier header and call DeleteExtraConfiguration", func(t *testing.T) {
mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, identifier).Return(nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc)
require.Equal(t, http.StatusAccepted, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should use default identifier when header is missing", func(t *testing.T) {
mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, defaultConfigIdentifier).Return(nil).Once()
rc := createRequestCtx()
response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc)
require.Equal(t, http.StatusAccepted, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return error when DeleteExtraConfiguration fails", func(t *testing.T) {
mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, identifier).Return(errors.New("delete error")).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc)
require.Equal(t, http.StatusInternalServerError, response.Status())
mockAM.AssertExpectations(t)
})
t.Run("should return not implemented when feature toggle is disabled", func(t *testing.T) {
ft := featuremgmt.WithFeatures()
srv, _, _ := createConvertPrometheusSrv(t, withAlertmanager(mockAM), withFeatureToggles(ft))
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, identifier)
response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc)
require.Equal(t, http.StatusNotImplemented, response.Status())
})
t.Run("should use default identifier for empty identifier header", func(t *testing.T) {
mockAM.On("DeleteExtraConfiguration", mock.Anything, orgID, defaultConfigIdentifier).Return(nil).Once()
rc := createRequestCtx()
rc.Req.Header.Set(configIdentifierHeader, "")
response := srv.RouteConvertPrometheusDeleteAlertmanagerConfig(rc)
require.Equal(t, http.StatusAccepted, response.Status())
mockAM.AssertExpectations(t)
})
}