mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Rule Modify Export APIs (#75322)
* extend RuleStore interface to get namespace by UID * add new export API endpoints * implement request handlers * update authorization and wire handlers to paths * add folder error matchers to errorToResponse * add tests for export methods
This commit is contained in:
parent
e877174501
commit
027bd9356f
@ -454,16 +454,9 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleExport(c *contextmodel.ReqContext,
|
||||
return ErrResp(http.StatusInternalServerError, err, "")
|
||||
}
|
||||
|
||||
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]alerting_models.AlertRuleGroupWithFolderTitle{{
|
||||
AlertRuleGroup: &alerting_models.AlertRuleGroup{
|
||||
Title: rule.AlertRule.RuleGroup,
|
||||
FolderUID: rule.AlertRule.NamespaceUID,
|
||||
Interval: rule.AlertRule.IntervalSeconds,
|
||||
Rules: []alerting_models.AlertRule{rule.AlertRule},
|
||||
},
|
||||
OrgID: c.OrgID,
|
||||
FolderTitle: rule.FolderTitle,
|
||||
}})
|
||||
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]alerting_models.AlertRuleGroupWithFolderTitle{
|
||||
alerting_models.NewAlertRuleGroupWithFolderTitleFromRulesGroup(rule.AlertRule.GetGroupKey(), alerting_models.RulesGroup{&rule.AlertRule}, rule.FolderTitle),
|
||||
})
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
|
||||
}
|
||||
|
@ -475,6 +475,31 @@ func validateQueries(ctx context.Context, groupChanges *store.GroupDelta, valida
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAuthorizedRuleByUid fetches all rules in group to which the specified rule belongs, and checks whether the user is authorized to access the group.
|
||||
// A user is authorized to access a group of rules only when it has permission to query all data sources used by all rules in this group.
|
||||
// Returns rule identified by provided UID or ErrAuthorization if user is not authorized to access the rule.
|
||||
func (srv RulerSrv) getAuthorizedRuleByUid(ctx context.Context, c *contextmodel.ReqContext, ruleUID string) (ngmodels.AlertRule, error) {
|
||||
hasAccess := accesscontrol.HasAccess(srv.ac, c)
|
||||
q := ngmodels.GetAlertRulesGroupByRuleUIDQuery{
|
||||
UID: ruleUID,
|
||||
OrgID: c.OrgID,
|
||||
}
|
||||
var err error
|
||||
rules, err := srv.store.GetAlertRulesGroupByRuleUID(ctx, &q)
|
||||
if err != nil {
|
||||
return ngmodels.AlertRule{}, err
|
||||
}
|
||||
if !authorizeAccessToRuleGroup(rules, hasAccess) {
|
||||
return ngmodels.AlertRule{}, fmt.Errorf("%w to access rules in this group", ErrAuthorization)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.UID == ruleUID {
|
||||
return *rule, nil
|
||||
}
|
||||
}
|
||||
return ngmodels.AlertRule{}, ngmodels.ErrAlertRuleNotFound
|
||||
}
|
||||
|
||||
// getAuthorizedRuleGroup fetches rules that belong to the specified models.AlertRuleGroupKey and validate user's authorization.
|
||||
// A user is authorized to access a group of rules only when it has permission to query all data sources used by all rules in this group.
|
||||
// Returns models.RuleGroup if authorization passed or ErrAuthorization if user is not authorized to access the rule.
|
||||
|
172
pkg/services/ngalert/api/api_ruler_export.go
Normal file
172
pkg/services/ngalert/api/api_ruler_export.go
Normal file
@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
// ExportFromPayload converts the rule groups from the argument `ruleGroupConfig` to export format. All rules are expected to be fully specified. The access to data sources mentioned in the rules is not enforced.
|
||||
// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceTitle`
|
||||
func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
||||
rulesWithOptionals, err := validateRuleGroup(&ruleGroupConfig, c.SignedInUser.OrgID, namespace, srv.cfg)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
if len(rulesWithOptionals) == 0 {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
rules := make([]ngmodels.AlertRule, 0, len(rulesWithOptionals))
|
||||
for _, optional := range rulesWithOptionals {
|
||||
rules = append(rules, optional.AlertRule)
|
||||
}
|
||||
|
||||
groupsWithTitle := ngmodels.NewAlertRuleGroupWithFolderTitle(rules[0].GetGroupKey(), rules, namespace.Title)
|
||||
|
||||
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]ngmodels.AlertRuleGroupWithFolderTitle{groupsWithTitle})
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
|
||||
}
|
||||
|
||||
return exportResponse(c, e)
|
||||
}
|
||||
|
||||
// ExportRules reads alert rules that user has access to from database according to the filters.
|
||||
func (srv RulerSrv) ExportRules(c *contextmodel.ReqContext) response.Response {
|
||||
// The similar method exists in provisioning (see ProvisioningSrv.RouteGetAlertRulesExport).
|
||||
// Modification to parameters and response format should be made in these two methods at the same time.
|
||||
|
||||
folderUIDs := c.QueryStrings("folderUid")
|
||||
group := c.Query("group")
|
||||
uid := c.Query("ruleUid")
|
||||
|
||||
var groups []ngmodels.AlertRuleGroupWithFolderTitle
|
||||
if uid != "" {
|
||||
if group != "" || len(folderUIDs) > 0 {
|
||||
return ErrResp(http.StatusBadRequest, errors.New("group and folder should not be specified when a single rule is requested"), "")
|
||||
}
|
||||
rulesGroup, err := srv.getRuleWithFolderTitleByRuleUid(c, uid)
|
||||
if err != nil {
|
||||
return errorToResponse(err)
|
||||
}
|
||||
groups = []ngmodels.AlertRuleGroupWithFolderTitle{rulesGroup}
|
||||
} else if group != "" {
|
||||
if len(folderUIDs) != 1 || folderUIDs[0] == "" {
|
||||
return ErrResp(http.StatusBadRequest,
|
||||
fmt.Errorf("group name must be specified together with a single folder_uid parameter. Got %d", len(folderUIDs)),
|
||||
"",
|
||||
)
|
||||
}
|
||||
rulesGroup, err := srv.getRuleGroupWithFolderTitle(c, ngmodels.AlertRuleGroupKey{
|
||||
OrgID: c.OrgID,
|
||||
NamespaceUID: folderUIDs[0],
|
||||
RuleGroup: group,
|
||||
})
|
||||
if err != nil {
|
||||
return errorToResponse(err)
|
||||
}
|
||||
groups = []ngmodels.AlertRuleGroupWithFolderTitle{rulesGroup}
|
||||
} else {
|
||||
var err error
|
||||
groups, err = srv.getRulesWithFolderTitleInFolders(c, folderUIDs)
|
||||
if err != nil {
|
||||
return errorToResponse(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(groups) == 0 {
|
||||
return response.Empty(http.StatusNotFound)
|
||||
}
|
||||
|
||||
// sort result so the response is always stable
|
||||
ngmodels.SortAlertRuleGroupWithFolderTitle(groups)
|
||||
|
||||
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle(groups)
|
||||
if err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
|
||||
}
|
||||
return exportResponse(c, e)
|
||||
}
|
||||
|
||||
// getRuleWithFolderTitleByRuleUid calls getAuthorizedRuleByUid and combines its result with folder (aka namespace) title.
|
||||
func (srv RulerSrv) getRuleWithFolderTitleByRuleUid(c *contextmodel.ReqContext, ruleUID string) (ngmodels.AlertRuleGroupWithFolderTitle, error) {
|
||||
rule, err := srv.getAuthorizedRuleByUid(c.Req.Context(), c, ruleUID)
|
||||
if err != nil {
|
||||
return ngmodels.AlertRuleGroupWithFolderTitle{}, err
|
||||
}
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), rule.NamespaceUID, c.SignedInUser.OrgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
return ngmodels.AlertRuleGroupWithFolderTitle{}, errors.Join(errFolderAccess, err)
|
||||
}
|
||||
return ngmodels.NewAlertRuleGroupWithFolderTitle(rule.GetGroupKey(), []ngmodels.AlertRule{rule}, namespace.Title), nil
|
||||
}
|
||||
|
||||
// getRuleGroupWithFolderTitle calls getAuthorizedRuleGroup and combines its result with folder (aka namespace) title.
|
||||
func (srv RulerSrv) getRuleGroupWithFolderTitle(c *contextmodel.ReqContext, ruleGroupKey ngmodels.AlertRuleGroupKey) (ngmodels.AlertRuleGroupWithFolderTitle, error) {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), ruleGroupKey.NamespaceUID, c.SignedInUser.OrgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
return ngmodels.AlertRuleGroupWithFolderTitle{}, errors.Join(errFolderAccess, err)
|
||||
}
|
||||
rules, err := srv.getAuthorizedRuleGroup(c.Req.Context(), c, ruleGroupKey)
|
||||
if err != nil {
|
||||
return ngmodels.AlertRuleGroupWithFolderTitle{}, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return ngmodels.AlertRuleGroupWithFolderTitle{}, ngmodels.ErrAlertRuleNotFound
|
||||
}
|
||||
return ngmodels.NewAlertRuleGroupWithFolderTitleFromRulesGroup(ruleGroupKey, rules, namespace.Title), nil
|
||||
}
|
||||
|
||||
// getRulesWithFolderTitleInFolders gets list of folders to which user has access, and then calls searchAuthorizedAlertRules.
|
||||
// If argument folderUIDs is not empty it intersects it with the list of folders available for user and then retrieves rules that are in those folders.
|
||||
func (srv RulerSrv) getRulesWithFolderTitleInFolders(c *contextmodel.ReqContext, folderUIDs []string) ([]ngmodels.AlertRuleGroupWithFolderTitle, error) {
|
||||
folders, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.OrgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := ngmodels.ListAlertRulesQuery{
|
||||
OrgID: c.OrgID,
|
||||
NamespaceUIDs: nil,
|
||||
}
|
||||
if len(folderUIDs) > 0 {
|
||||
for _, folderUID := range folderUIDs {
|
||||
if _, ok := folders[folderUID]; ok {
|
||||
query.NamespaceUIDs = append(query.NamespaceUIDs, folderUID)
|
||||
}
|
||||
}
|
||||
if len(query.NamespaceUIDs) == 0 {
|
||||
return nil, fmt.Errorf("%w access rules in the specified folders", ErrAuthorization)
|
||||
}
|
||||
} else {
|
||||
for _, folder := range folders {
|
||||
query.NamespaceUIDs = append(query.NamespaceUIDs, folder.UID)
|
||||
}
|
||||
}
|
||||
|
||||
rulesByGroup, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), c, folderUIDs, "", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ngmodels.AlertRuleGroupWithFolderTitle, 0, len(rulesByGroup))
|
||||
for groupKey, rulesGroup := range rulesByGroup {
|
||||
namespace, ok := folders[groupKey.NamespaceUID]
|
||||
if !ok {
|
||||
continue // user does not have access
|
||||
}
|
||||
result = append(result, ngmodels.NewAlertRuleGroupWithFolderTitleFromRulesGroup(groupKey, rulesGroup, namespace.Title))
|
||||
}
|
||||
return result, nil
|
||||
}
|
456
pkg/services/ngalert/api/api_ruler_export_test.go
Normal file
456
pkg/services/ngalert/api/api_ruler_export_test.go
Normal file
@ -0,0 +1,456 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
folder2 "github.com/grafana/grafana/pkg/services/folder"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
|
||||
)
|
||||
|
||||
//go:embed test-data/*.*
|
||||
var testData embed.FS
|
||||
|
||||
func TestExportFromPayload(t *testing.T) {
|
||||
orgID := int64(1)
|
||||
folder := &folder2.Folder{
|
||||
UID: "e4584834-1a87-4dff-8913-8a4748dfca79",
|
||||
Title: "foo bar",
|
||||
}
|
||||
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder)
|
||||
|
||||
srv := createService(ruleStore)
|
||||
|
||||
requestFile := "post-rulegroup-101.json"
|
||||
rawBody, err := testData.ReadFile(path.Join("test-data", requestFile))
|
||||
require.NoError(t, err)
|
||||
var body apimodels.PostableRuleGroupConfig
|
||||
require.NoError(t, json.Unmarshal(rawBody, &body))
|
||||
|
||||
createRequest := func() *contextmodel.ReqContext {
|
||||
return createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil)
|
||||
}
|
||||
|
||||
t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("query format contains yaml, GET returns text yaml", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "text/yaml", rc.Resp.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("query format contains unknown value, GET returns text yaml", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "foo")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("accept header contains json, GET returns json", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json, application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("download", "true")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment")
|
||||
})
|
||||
|
||||
t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("download", "false")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
|
||||
})
|
||||
|
||||
t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
|
||||
})
|
||||
|
||||
t.Run("json body content is as expected", func(t *testing.T) {
|
||||
expectedResponse, err := testData.ReadFile(path.Join("test-data", strings.Replace(requestFile, ".json", "-export.json", 1)))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
t.Log(string(response.Body()))
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.JSONEq(t, string(expectedResponse), string(response.Body()))
|
||||
})
|
||||
|
||||
t.Run("yaml body content is as expected", func(t *testing.T) {
|
||||
expectedResponse, err := testData.ReadFile(path.Join("test-data", strings.Replace(requestFile, ".json", "-export.yaml", 1)))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, string(expectedResponse), string(response.Body()))
|
||||
})
|
||||
|
||||
t.Run("hcl body content is as expected", func(t *testing.T) {
|
||||
expectedResponse, err := testData.ReadFile(path.Join("test-data", strings.Replace(requestFile, ".json", "-export.hcl", 1)))
|
||||
require.NoError(t, err)
|
||||
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "hcl")
|
||||
rc.Context.Req.Form.Set("download", "false")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, string(expectedResponse), string(response.Body()))
|
||||
require.Equal(t, "text/hcl", rc.Resp.Header().Get("Content-Type"))
|
||||
|
||||
t.Run("and add specific headers if download=true", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "hcl")
|
||||
rc.Context.Req.Form.Set("download", "true")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, string(expectedResponse), string(response.Body()))
|
||||
require.Equal(t, "application/terraform+hcl", rc.Resp.Header().Get("Content-Type"))
|
||||
require.Equal(t, `attachment;filename=export.tf`, rc.Resp.Header().Get("Content-Disposition"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestExportRules(t *testing.T) {
|
||||
uids := sync.Map{}
|
||||
orgID := int64(1)
|
||||
f1 := randFolder()
|
||||
f2 := randFolder()
|
||||
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f1, f2)
|
||||
|
||||
hasAccessKey1 := ngmodels.AlertRuleGroupKey{
|
||||
OrgID: orgID,
|
||||
NamespaceUID: f1.UID,
|
||||
RuleGroup: "HAS-ACCESS-1",
|
||||
}
|
||||
accessQuery := ngmodels.GenerateAlertQuery()
|
||||
noAccessQuery := ngmodels.GenerateAlertQuery()
|
||||
|
||||
_, hasAccess1 := ngmodels.GenerateUniqueAlertRules(5,
|
||||
ngmodels.AlertRuleGen(
|
||||
ngmodels.WithUniqueUID(&uids),
|
||||
withGroupKey(hasAccessKey1),
|
||||
ngmodels.WithQuery(accessQuery),
|
||||
ngmodels.WithUniqueGroupIndex(),
|
||||
))
|
||||
ruleStore.PutRule(context.Background(), hasAccess1...)
|
||||
noAccessKey1 := ngmodels.AlertRuleGroupKey{
|
||||
OrgID: orgID,
|
||||
NamespaceUID: f1.UID,
|
||||
RuleGroup: "NO-ACCESS",
|
||||
}
|
||||
_, noAccess1 := ngmodels.GenerateUniqueAlertRules(5,
|
||||
ngmodels.AlertRuleGen(
|
||||
ngmodels.WithUniqueUID(&uids),
|
||||
withGroupKey(noAccessKey1),
|
||||
ngmodels.WithQuery(noAccessQuery),
|
||||
))
|
||||
noAccessRule := ngmodels.AlertRuleGen(
|
||||
ngmodels.WithUniqueUID(&uids),
|
||||
withGroupKey(noAccessKey1),
|
||||
ngmodels.WithQuery(accessQuery),
|
||||
)()
|
||||
noAccess1 = append(noAccess1, noAccessRule)
|
||||
ruleStore.PutRule(context.Background(), noAccess1...)
|
||||
|
||||
hasAccessKey2 := ngmodels.AlertRuleGroupKey{
|
||||
OrgID: orgID,
|
||||
NamespaceUID: f2.UID,
|
||||
RuleGroup: "HAS-ACCESS-2",
|
||||
}
|
||||
_, hasAccess2 := ngmodels.GenerateUniqueAlertRules(5,
|
||||
ngmodels.AlertRuleGen(
|
||||
ngmodels.WithUniqueUID(&uids),
|
||||
withGroupKey(hasAccessKey2),
|
||||
ngmodels.WithQuery(accessQuery),
|
||||
ngmodels.WithUniqueGroupIndex(),
|
||||
))
|
||||
ruleStore.PutRule(context.Background(), hasAccess2...)
|
||||
|
||||
_, noAccess2 := ngmodels.GenerateUniqueAlertRules(10,
|
||||
ngmodels.AlertRuleGen(
|
||||
ngmodels.WithUniqueUID(&uids),
|
||||
ngmodels.WithQuery(accessQuery), // no access because of folder
|
||||
))
|
||||
ruleStore.PutRule(context.Background(), noAccess2...)
|
||||
|
||||
srv := createService(ruleStore)
|
||||
|
||||
testCases := []struct {
|
||||
title string
|
||||
params url.Values
|
||||
headers http.Header
|
||||
expectedStatus int
|
||||
expectedHeaders http.Header
|
||||
expectedRules []*ngmodels.AlertRule
|
||||
}{
|
||||
{
|
||||
title: "return all rules user has access to when no parameters",
|
||||
expectedStatus: 200,
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/yaml"},
|
||||
},
|
||||
expectedRules: append(hasAccess1, hasAccess2...),
|
||||
},
|
||||
{
|
||||
title: "return all rules in folder",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/yaml"},
|
||||
},
|
||||
expectedRules: hasAccess1,
|
||||
},
|
||||
{
|
||||
title: "return all rules in many folders",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID, hasAccessKey2.NamespaceUID},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/yaml"},
|
||||
},
|
||||
expectedRules: append(hasAccess1, hasAccess2...),
|
||||
},
|
||||
{
|
||||
title: "return rules in single group",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID},
|
||||
"group": []string{hasAccessKey1.RuleGroup},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/yaml"},
|
||||
},
|
||||
expectedRules: hasAccess1,
|
||||
},
|
||||
{
|
||||
title: "return single rule",
|
||||
params: url.Values{
|
||||
"ruleUid": []string{hasAccess1[0].UID},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/yaml"},
|
||||
},
|
||||
expectedRules: []*ngmodels.AlertRule{hasAccess1[0]},
|
||||
},
|
||||
{
|
||||
title: "fail if group and many folders",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID, hasAccessKey2.NamespaceUID},
|
||||
"group": []string{hasAccessKey1.RuleGroup},
|
||||
},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
title: "fail if ruleUid and group",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID},
|
||||
"group": []string{hasAccessKey1.RuleGroup},
|
||||
"ruleUid": []string{hasAccess1[0].UID},
|
||||
},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
title: "fail if ruleUid and folderUid",
|
||||
params: url.Values{
|
||||
"folderUid": []string{hasAccessKey1.NamespaceUID},
|
||||
"ruleUid": []string{hasAccess1[0].UID},
|
||||
},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
title: "unauthorized if folders are not accessible",
|
||||
params: url.Values{
|
||||
"folderUid": []string{noAccess2[0].NamespaceUID},
|
||||
},
|
||||
expectedStatus: 401,
|
||||
expectedRules: nil,
|
||||
},
|
||||
{
|
||||
title: "unauthorized if group is not accessible",
|
||||
params: url.Values{
|
||||
"folderUid": []string{noAccessKey1.NamespaceUID},
|
||||
"group": []string{noAccessKey1.RuleGroup},
|
||||
},
|
||||
expectedStatus: 401,
|
||||
},
|
||||
{
|
||||
title: "unauthorized if rule's group is not accessible",
|
||||
params: url.Values{
|
||||
"ruleUid": []string{noAccessRule.UID},
|
||||
},
|
||||
expectedStatus: 401,
|
||||
},
|
||||
{
|
||||
title: "return in JSON if header is specified",
|
||||
headers: http.Header{
|
||||
"Accept": []string{"application/json"},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedRules: append(hasAccess1, hasAccess2...),
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "return in JSON if format is specified",
|
||||
params: url.Values{
|
||||
"format": []string{"json"},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedRules: append(hasAccess1, hasAccess2...),
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "return in HCL if format is specified",
|
||||
params: url.Values{
|
||||
"format": []string{"hcl"},
|
||||
},
|
||||
expectedStatus: 200,
|
||||
expectedRules: append(hasAccess1, hasAccess2...),
|
||||
expectedHeaders: http.Header{
|
||||
"Content-Type": []string{"text/hcl"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
rc := createRequestContextWithPerms(orgID, map[int64]map[string][]string{
|
||||
orgID: {
|
||||
datasources.ActionQuery: []string{datasources.ScopeProvider.GetResourceScopeUID(accessQuery.DatasourceUID)},
|
||||
},
|
||||
}, nil)
|
||||
rc.Req.Form = tc.params
|
||||
rc.Req.Header = tc.headers
|
||||
|
||||
resp := srv.ExportRules(rc)
|
||||
|
||||
require.Equal(t, tc.expectedStatus, resp.Status())
|
||||
if tc.expectedStatus != 200 {
|
||||
return
|
||||
}
|
||||
var exp []ngmodels.AlertRuleGroupWithFolderTitle
|
||||
gr := ngmodels.GroupByAlertRuleGroupKey(tc.expectedRules)
|
||||
for key, rules := range gr {
|
||||
folder, err := ruleStore.GetNamespaceByUID(context.Background(), key.NamespaceUID, orgID, nil)
|
||||
require.NoError(t, err)
|
||||
exp = append(exp, ngmodels.NewAlertRuleGroupWithFolderTitleFromRulesGroup(key, rules, folder.Title))
|
||||
}
|
||||
sort.SliceStable(exp, func(i, j int) bool {
|
||||
gi, gj := exp[i], exp[j]
|
||||
if gi.OrgID != gj.OrgID {
|
||||
return gi.OrgID < gj.OrgID
|
||||
}
|
||||
if gi.FolderUID != gj.FolderUID {
|
||||
return gi.FolderUID < gj.FolderUID
|
||||
}
|
||||
return gi.Title < gj.Title
|
||||
})
|
||||
groups, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle(exp)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, string(exportResponse(rc, groups).Body()), string(resp.Body()))
|
||||
|
||||
resp.WriteTo(rc)
|
||||
actualHeaders := rc.Resp.Header()
|
||||
for h, hv := range tc.expectedHeaders {
|
||||
assert.Contains(t, actualHeaders, h)
|
||||
actual := actualHeaders[h]
|
||||
require.Equal(t, hv, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -7,8 +7,10 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -597,8 +599,10 @@ func createService(store *fakes.RuleStore) *RulerSrv {
|
||||
QuotaService: nil,
|
||||
provenanceStore: provisioning.NewFakeProvisioningStore(),
|
||||
log: log.New("test"),
|
||||
cfg: nil,
|
||||
ac: acimpl.ProvideAccessControl(setting.NewCfg()),
|
||||
cfg: &setting.UnifiedAlertingSettings{
|
||||
BaseInterval: 10 * time.Second,
|
||||
},
|
||||
ac: acimpl.ProvideAccessControl(setting.NewCfg()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -609,9 +613,14 @@ func createRequestContext(orgID int64, params map[string]string) *contextmodel.R
|
||||
|
||||
func createRequestContextWithPerms(orgID int64, permissions map[int64]map[string][]string, params map[string]string) *contextmodel.ReqContext {
|
||||
uri, _ := url.Parse("http://localhost")
|
||||
ctx := web.Context{Req: &http.Request{
|
||||
URL: uri,
|
||||
}}
|
||||
ctx := web.Context{
|
||||
Req: &http.Request{
|
||||
URL: uri,
|
||||
Header: make(http.Header),
|
||||
Form: make(url.Values),
|
||||
},
|
||||
Resp: web.NewResponseWriter("GET", httptest.NewRecorder()),
|
||||
}
|
||||
if params != nil {
|
||||
ctx.Req = web.SetURLParams(ctx.Req, params)
|
||||
}
|
||||
|
@ -36,8 +36,13 @@ func (api *API) authorize(method, path string) web.Handler {
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace")))
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rules/{Namespace}":
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead, dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace")))
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rules":
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rules",
|
||||
http.MethodGet + "/api/ruler/grafana/api/v1/export/rules":
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
|
||||
case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}/export":
|
||||
scope := dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))
|
||||
// more granular permissions are enforced by the handler via "authorizeRuleChanges"
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead, scope)
|
||||
case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}":
|
||||
scope := dashboards.ScopeFoldersProvider.GetResourceScopeName(ac.Parameter(":Namespace"))
|
||||
// more granular permissions are enforced by the handler via "authorizeRuleChanges"
|
||||
|
@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) {
|
||||
}
|
||||
paths[p] = methods
|
||||
}
|
||||
require.Len(t, paths, 50)
|
||||
require.Len(t, paths, 52)
|
||||
|
||||
ac := acmock.New()
|
||||
api := &API{AccessControl: ac}
|
||||
|
@ -11,6 +11,9 @@ import (
|
||||
|
||||
var (
|
||||
errUnexpectedDatasourceType = errors.New("unexpected datasource type")
|
||||
|
||||
// errFolderAccess is used as a wrapper to propagate folder related errors and correctly map to the response status
|
||||
errFolderAccess = errors.New("cannot get folder")
|
||||
)
|
||||
|
||||
func unexpectedDatasourceTypeError(actual string, expected string) error {
|
||||
@ -34,5 +37,8 @@ func errorToResponse(err error) response.Response {
|
||||
if errors.Is(err, ErrAuthorization) {
|
||||
return ErrResp(401, err, "")
|
||||
}
|
||||
if errors.Is(err, errFolderAccess) {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
return ErrResp(500, err, "")
|
||||
}
|
||||
|
@ -101,6 +101,18 @@ func (f *RulerApiHandler) handleRoutePostNameGrafanaRulesConfig(ctx *contextmode
|
||||
return f.GrafanaRuler.RoutePostNameRulesConfig(ctx, conf, namespace)
|
||||
}
|
||||
|
||||
func (f *RulerApiHandler) handleRoutePostRulesGroupForExport(ctx *contextmodel.ReqContext, conf apimodels.PostableRuleGroupConfig, namespace string) response.Response {
|
||||
payloadType := conf.Type()
|
||||
if payloadType != apimodels.GrafanaBackend {
|
||||
return errorToResponse(backendTypeDoesNotMatchPayloadTypeError(apimodels.GrafanaBackend, conf.Type().String()))
|
||||
}
|
||||
return f.GrafanaRuler.ExportFromPayload(ctx, conf, namespace)
|
||||
}
|
||||
|
||||
func (f *RulerApiHandler) handleRouteGetRulesForExport(ctx *contextmodel.ReqContext) response.Response {
|
||||
return f.GrafanaRuler.ExportRules(ctx)
|
||||
}
|
||||
|
||||
func (f *RulerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexRuler, error) {
|
||||
_, err := getDatasourceByUID(ctx, f.DatasourceCache, apimodels.LoTexRulerBackend)
|
||||
if err != nil {
|
||||
|
@ -30,8 +30,10 @@ type RulerApi interface {
|
||||
RouteGetNamespaceRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulegGroupConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulesForExport(*contextmodel.ReqContext) response.Response
|
||||
RoutePostNameGrafanaRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RoutePostNameRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RoutePostRulesGroupForExport(*contextmodel.ReqContext) response.Response
|
||||
}
|
||||
|
||||
func (f *RulerApiHandler) RouteDeleteGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response {
|
||||
@ -90,6 +92,9 @@ func (f *RulerApiHandler) RouteGetRulesConfig(ctx *contextmodel.ReqContext) resp
|
||||
datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"]
|
||||
return f.handleRouteGetRulesConfig(ctx, datasourceUIDParam)
|
||||
}
|
||||
func (f *RulerApiHandler) RouteGetRulesForExport(ctx *contextmodel.ReqContext) response.Response {
|
||||
return f.handleRouteGetRulesForExport(ctx)
|
||||
}
|
||||
func (f *RulerApiHandler) RoutePostNameGrafanaRulesConfig(ctx *contextmodel.ReqContext) response.Response {
|
||||
// Parse Path Parameters
|
||||
namespaceParam := web.Params(ctx.Req)[":Namespace"]
|
||||
@ -111,6 +116,16 @@ func (f *RulerApiHandler) RoutePostNameRulesConfig(ctx *contextmodel.ReqContext)
|
||||
}
|
||||
return f.handleRoutePostNameRulesConfig(ctx, conf, datasourceUIDParam, namespaceParam)
|
||||
}
|
||||
func (f *RulerApiHandler) RoutePostRulesGroupForExport(ctx *contextmodel.ReqContext) response.Response {
|
||||
// Parse Path Parameters
|
||||
namespaceParam := web.Params(ctx.Req)[":Namespace"]
|
||||
// Parse Request Body
|
||||
conf := apimodels.PostableRuleGroupConfig{}
|
||||
if err := web.Bind(ctx.Req, &conf); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
return f.handleRoutePostRulesGroupForExport(ctx, conf, namespaceParam)
|
||||
}
|
||||
|
||||
func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
|
||||
api.RouteRegister.Group("", func(group routing.RouteRegister) {
|
||||
@ -234,6 +249,17 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Get(
|
||||
toMacaronPath("/api/ruler/grafana/api/v1/export/rules"),
|
||||
requestmeta.SetOwner(requestmeta.TeamAlerting),
|
||||
api.authorize(http.MethodGet, "/api/ruler/grafana/api/v1/export/rules"),
|
||||
metrics.Instrument(
|
||||
http.MethodGet,
|
||||
"/api/ruler/grafana/api/v1/export/rules",
|
||||
api.Hooks.Wrap(srv.RouteGetRulesForExport),
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Post(
|
||||
toMacaronPath("/api/ruler/grafana/api/v1/rules/{Namespace}"),
|
||||
requestmeta.SetOwner(requestmeta.TeamAlerting),
|
||||
@ -258,5 +284,16 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Post(
|
||||
toMacaronPath("/api/ruler/grafana/api/v1/rules/{Namespace}/export"),
|
||||
requestmeta.SetOwner(requestmeta.TeamAlerting),
|
||||
api.authorize(http.MethodPost, "/api/ruler/grafana/api/v1/rules/{Namespace}/export"),
|
||||
metrics.Instrument(
|
||||
http.MethodPost,
|
||||
"/api/ruler/grafana/api/v1/rules/{Namespace}/export",
|
||||
api.Hooks.Wrap(srv.RoutePostRulesGroupForExport),
|
||||
m,
|
||||
),
|
||||
)
|
||||
}, middleware.ReqSignedIn)
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
type RuleStore interface {
|
||||
GetUserVisibleNamespaces(context.Context, int64, *user.SignedInUser) (map[string]*folder.Folder, error)
|
||||
GetNamespaceByTitle(context.Context, string, int64, *user.SignedInUser) (*folder.Folder, error)
|
||||
GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user *user.SignedInUser) (*folder.Folder, error)
|
||||
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error)
|
||||
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
|
||||
|
||||
|
@ -0,0 +1,91 @@
|
||||
resource "grafana_rule_group" "rule_group_0000" {
|
||||
org_id = 1
|
||||
name = "group101"
|
||||
folder_uid = "e4584834-1a87-4dff-8913-8a4748dfca79"
|
||||
interval_seconds = 10
|
||||
|
||||
rule {
|
||||
name = "prom query with SSE - 2"
|
||||
condition = "condition"
|
||||
|
||||
data {
|
||||
ref_id = "query"
|
||||
query_type = ""
|
||||
|
||||
relative_time_range {
|
||||
from = 18000
|
||||
to = 10800
|
||||
}
|
||||
|
||||
datasource_uid = "000000002"
|
||||
model = "{\n \"expr\": \"http_request_duration_microseconds_count\",\n \"hide\": false,\n \"interval\": \"\",\n \"intervalMs\": 1000,\n \"legendFormat\": \"\",\n \"maxDataPoints\": 100,\n \"refId\": \"query\"\n }"
|
||||
}
|
||||
data {
|
||||
ref_id = "reduced"
|
||||
query_type = ""
|
||||
|
||||
relative_time_range {
|
||||
from = 18000
|
||||
to = 10800
|
||||
}
|
||||
|
||||
datasource_uid = "__expr__"
|
||||
model = "{\n \"expression\": \"query\",\n \"hide\": false,\n \"intervalMs\": 1000,\n \"maxDataPoints\": 100,\n \"reducer\": \"mean\",\n \"refId\": \"reduced\",\n \"type\": \"reduce\"\n }"
|
||||
}
|
||||
data {
|
||||
ref_id = "condition"
|
||||
query_type = ""
|
||||
|
||||
relative_time_range {
|
||||
from = 18000
|
||||
to = 10800
|
||||
}
|
||||
|
||||
datasource_uid = "__expr__"
|
||||
model = "{\n \"expression\": \"$reduced > 10\",\n \"hide\": false,\n \"intervalMs\": 1000,\n \"maxDataPoints\": 100,\n \"refId\": \"condition\",\n \"type\": \"math\"\n }"
|
||||
}
|
||||
|
||||
no_data_state = "NoData"
|
||||
exec_err_state = "Alerting"
|
||||
for = 0
|
||||
annotations = null
|
||||
labels = null
|
||||
is_paused = false
|
||||
}
|
||||
rule {
|
||||
name = "reduced testdata query - 2"
|
||||
condition = "B"
|
||||
|
||||
data {
|
||||
ref_id = "A"
|
||||
query_type = ""
|
||||
|
||||
relative_time_range {
|
||||
from = 18000
|
||||
to = 10800
|
||||
}
|
||||
|
||||
datasource_uid = "000000004"
|
||||
model = "{\n \"alias\": \"just-testing\",\n \"intervalMs\": 1000,\n \"maxDataPoints\": 100,\n \"orgId\": 0,\n \"refId\": \"A\",\n \"scenarioId\": \"csv_metric_values\",\n \"stringInput\": \"1,20,90,30,5,0\"\n }"
|
||||
}
|
||||
data {
|
||||
ref_id = "B"
|
||||
query_type = ""
|
||||
|
||||
relative_time_range {
|
||||
from = 18000
|
||||
to = 10800
|
||||
}
|
||||
|
||||
datasource_uid = "__expr__"
|
||||
model = "{\n \"expression\": \"$A\",\n \"intervalMs\": 2000,\n \"maxDataPoints\": 200,\n \"orgId\": 0,\n \"reducer\": \"mean\",\n \"refId\": \"B\",\n \"type\": \"reduce\"\n }"
|
||||
}
|
||||
|
||||
no_data_state = "NoData"
|
||||
exec_err_state = "Alerting"
|
||||
for = 0
|
||||
annotations = null
|
||||
labels = null
|
||||
is_paused = false
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"groups": [
|
||||
{
|
||||
"orgId": 1,
|
||||
"name": "group101",
|
||||
"folder": "foo bar",
|
||||
"interval": "10s",
|
||||
"rules": [
|
||||
{
|
||||
"uid": "",
|
||||
"title": "prom query with SSE - 2",
|
||||
"condition": "condition",
|
||||
"data": [
|
||||
{
|
||||
"refId": "query",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "000000002",
|
||||
"model": {
|
||||
"expr": "http_request_duration_microseconds_count",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"intervalMs": 1000,
|
||||
"legendFormat": "",
|
||||
"maxDataPoints": 100,
|
||||
"refId": "query"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "reduced",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "query",
|
||||
"hide": false,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 100,
|
||||
"reducer": "mean",
|
||||
"refId": "reduced",
|
||||
"type": "reduce"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "condition",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "$reduced \u003e 10",
|
||||
"hide": false,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 100,
|
||||
"refId": "condition",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"noDataState": "NoData",
|
||||
"execErrState": "Alerting",
|
||||
"for": "0s",
|
||||
"isPaused": false
|
||||
},
|
||||
{
|
||||
"uid": "",
|
||||
"title": "reduced testdata query - 2",
|
||||
"condition": "B",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "000000004",
|
||||
"model": {
|
||||
"alias": "just-testing",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 100,
|
||||
"orgId": 0,
|
||||
"refId": "A",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"relativeTimeRange": {
|
||||
"from": 18000,
|
||||
"to": 10800
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "$A",
|
||||
"intervalMs": 2000,
|
||||
"maxDataPoints": 200,
|
||||
"orgId": 0,
|
||||
"reducer": "mean",
|
||||
"refId": "B",
|
||||
"type": "reduce"
|
||||
}
|
||||
}
|
||||
],
|
||||
"noDataState": "NoData",
|
||||
"execErrState": "Alerting",
|
||||
"for": "0s",
|
||||
"isPaused": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
apiVersion: 1
|
||||
groups:
|
||||
- orgId: 1
|
||||
name: group101
|
||||
folder: foo bar
|
||||
interval: 10s
|
||||
rules:
|
||||
- uid: ""
|
||||
title: prom query with SSE - 2
|
||||
condition: condition
|
||||
data:
|
||||
- refId: query
|
||||
relativeTimeRange:
|
||||
from: 18000
|
||||
to: 10800
|
||||
datasourceUid: "000000002"
|
||||
model:
|
||||
expr: http_request_duration_microseconds_count
|
||||
hide: false
|
||||
interval: ""
|
||||
intervalMs: 1000
|
||||
legendFormat: ""
|
||||
maxDataPoints: 100
|
||||
refId: query
|
||||
- refId: reduced
|
||||
relativeTimeRange:
|
||||
from: 18000
|
||||
to: 10800
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
expression: query
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 100
|
||||
reducer: mean
|
||||
refId: reduced
|
||||
type: reduce
|
||||
- refId: condition
|
||||
relativeTimeRange:
|
||||
from: 18000
|
||||
to: 10800
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
expression: $reduced > 10
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 100
|
||||
refId: condition
|
||||
type: math
|
||||
noDataState: NoData
|
||||
execErrState: Alerting
|
||||
for: 0s
|
||||
isPaused: false
|
||||
- uid: ""
|
||||
title: reduced testdata query - 2
|
||||
condition: B
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 18000
|
||||
to: 10800
|
||||
datasourceUid: "000000004"
|
||||
model:
|
||||
alias: just-testing
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 100
|
||||
orgId: 0
|
||||
refId: A
|
||||
scenarioId: csv_metric_values
|
||||
stringInput: 1,20,90,30,5,0
|
||||
- refId: B
|
||||
relativeTimeRange:
|
||||
from: 18000
|
||||
to: 10800
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
expression: $A
|
||||
intervalMs: 2000
|
||||
maxDataPoints: 200
|
||||
orgId: 0
|
||||
reducer: mean
|
||||
refId: B
|
||||
type: reduce
|
||||
noDataState: NoData
|
||||
execErrState: Alerting
|
||||
for: 0s
|
||||
isPaused: false
|
@ -137,7 +137,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"relativeTimeRange": {
|
||||
"$ref": "#/definitions/RelativeTimeRange"
|
||||
"$ref": "#/definitions/RelativeTimeRangeExport"
|
||||
}
|
||||
},
|
||||
"title": "AlertQueryExport is the provisioned export of models.AlertQuery.",
|
||||
@ -418,6 +418,9 @@
|
||||
"for": {
|
||||
"type": "string"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -1223,6 +1226,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -1257,12 +1266,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -1316,6 +1319,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/GettableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -2289,6 +2295,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -2323,12 +2335,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -2382,6 +2388,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/PostableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -2753,6 +2762,9 @@
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
},
|
||||
"ttl": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -2869,6 +2881,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -2903,12 +2921,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -2978,6 +2990,19 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RelativeTimeRangeExport": {
|
||||
"properties": {
|
||||
"from": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"to": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ResponseDetails": {
|
||||
"properties": {
|
||||
"msg": {
|
||||
@ -3857,6 +3882,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"URL": {
|
||||
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
|
||||
"properties": {
|
||||
"ForceQuery": {
|
||||
"type": "boolean"
|
||||
@ -3892,7 +3918,7 @@
|
||||
"$ref": "#/definitions/Userinfo"
|
||||
}
|
||||
},
|
||||
"title": "URL is a custom URL type that allows validation at configuration load time.",
|
||||
"title": "A URL represents a parsed URL (technically, a URI reference).",
|
||||
"type": "object"
|
||||
},
|
||||
"Userinfo": {
|
||||
@ -4072,7 +4098,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroup": {
|
||||
"description": "AlertGroup alert group",
|
||||
"properties": {
|
||||
"alerts": {
|
||||
"description": "alerts",
|
||||
@ -4096,6 +4121,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup"
|
||||
},
|
||||
@ -4256,14 +4282,12 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"gettableSilence": {
|
||||
"description": "GettableSilence gettable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@ -4499,6 +4523,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"receiver": {
|
||||
"description": "Receiver receiver",
|
||||
"properties": {
|
||||
"active": {
|
||||
"description": "active",
|
||||
|
@ -19,6 +19,18 @@ import (
|
||||
// 202: NamespaceConfigResponse
|
||||
//
|
||||
|
||||
// swagger:route Get /api/ruler/grafana/api/v1/export/rules ruler RouteGetRulesForExport
|
||||
//
|
||||
// List rules in provisioning format
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/yaml
|
||||
//
|
||||
// Responses:
|
||||
// 200: AlertingFileExport
|
||||
// 404: description: Not found.
|
||||
|
||||
// swagger:route Get /api/ruler/{DatasourceUID}/api/v1/rules ruler RouteGetRulesConfig
|
||||
//
|
||||
// List rule groups
|
||||
@ -42,6 +54,18 @@ import (
|
||||
// 202: Ack
|
||||
//
|
||||
|
||||
// swagger:route POST /api/ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport
|
||||
//
|
||||
// Converts submitted rule group to provisioning format
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/yaml
|
||||
//
|
||||
// Responses:
|
||||
// 200: AlertingFileExport
|
||||
// 404: description: Not found.
|
||||
|
||||
// swagger:route POST /api/ruler/{DatasourceUID}/api/v1/rules/{Namespace} ruler RoutePostNameRulesConfig
|
||||
//
|
||||
// Creates or updates a rule group
|
||||
@ -126,7 +150,7 @@ import (
|
||||
// 202: Ack
|
||||
// 404: NotFound
|
||||
|
||||
// swagger:parameters RoutePostNameRulesConfig RoutePostNameGrafanaRulesConfig
|
||||
// swagger:parameters RoutePostNameRulesConfig RoutePostNameGrafanaRulesConfig RoutePostRulesGroupForExport
|
||||
type NamespaceConfig struct {
|
||||
// in:path
|
||||
Namespace string
|
||||
|
@ -9,7 +9,7 @@ type AlertingFileExport struct {
|
||||
Policies []NotificationPolicyExport `json:"policies,omitempty" yaml:"policies,omitempty"`
|
||||
}
|
||||
|
||||
// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetContactpointsExport RouteGetContactpointExport
|
||||
// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetContactpointsExport RouteGetContactpointExport RoutePostRulesGroupForExport
|
||||
type ExportQueryParams struct {
|
||||
// Whether to initiate a download of the file or not.
|
||||
// in: query
|
||||
|
@ -71,7 +71,7 @@ import (
|
||||
// Responses:
|
||||
// 204: description: The alert rule was deleted successfully.
|
||||
|
||||
// swagger:parameters RouteGetAlertRulesExport
|
||||
// swagger:parameters RouteGetAlertRulesExport RouteGetRulesForExport
|
||||
type AlertRulesExportParameters struct {
|
||||
ExportQueryParams
|
||||
// UIDs of folders from which to export rules
|
||||
|
@ -137,7 +137,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"relativeTimeRange": {
|
||||
"$ref": "#/definitions/RelativeTimeRange"
|
||||
"$ref": "#/definitions/RelativeTimeRangeExport"
|
||||
}
|
||||
},
|
||||
"title": "AlertQueryExport is the provisioned export of models.AlertQuery.",
|
||||
@ -418,6 +418,9 @@
|
||||
"for": {
|
||||
"type": "string"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -1223,6 +1226,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -1257,12 +1266,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -1316,6 +1319,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/GettableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -2289,6 +2295,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -2323,12 +2335,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -2382,6 +2388,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/PostableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@ -2753,6 +2762,9 @@
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
},
|
||||
"ttl": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -2869,6 +2881,12 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"msteams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -2903,12 +2921,6 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"teams_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"telegram_configs": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/TelegramConfig"
|
||||
@ -2978,6 +2990,19 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RelativeTimeRangeExport": {
|
||||
"properties": {
|
||||
"from": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"to": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ResponseDetails": {
|
||||
"properties": {
|
||||
"msg": {
|
||||
@ -3857,7 +3882,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"URL": {
|
||||
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
|
||||
"properties": {
|
||||
"ForceQuery": {
|
||||
"type": "boolean"
|
||||
@ -3893,7 +3917,7 @@
|
||||
"$ref": "#/definitions/Userinfo"
|
||||
}
|
||||
},
|
||||
"title": "A URL represents a parsed URL (technically, a URI reference).",
|
||||
"title": "URL is a custom URL type that allows validation at configuration load time.",
|
||||
"type": "object"
|
||||
},
|
||||
"Userinfo": {
|
||||
@ -4073,7 +4097,6 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroup": {
|
||||
"description": "AlertGroup alert group",
|
||||
"properties": {
|
||||
"alerts": {
|
||||
"description": "alerts",
|
||||
@ -4202,6 +4225,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"properties": {
|
||||
"annotations": {
|
||||
"$ref": "#/definitions/labelSet"
|
||||
@ -4257,6 +4281,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
@ -4318,6 +4343,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"integration": {
|
||||
"description": "Integration integration",
|
||||
"properties": {
|
||||
"lastNotifyAttempt": {
|
||||
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
|
||||
@ -4461,7 +4487,6 @@
|
||||
"type": "array"
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"description": "comment",
|
||||
@ -5799,6 +5824,67 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/export/rules": {
|
||||
"get": {
|
||||
"consumes": [
|
||||
"application/json",
|
||||
"application/yaml"
|
||||
],
|
||||
"description": "List rules in provisioning format",
|
||||
"operationId": "RouteGetRulesForExport",
|
||||
"parameters": [
|
||||
{
|
||||
"default": false,
|
||||
"description": "Whether to initiate a download of the file or not.",
|
||||
"in": "query",
|
||||
"name": "download",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"default": "yaml",
|
||||
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "UIDs of folders from which to export rules",
|
||||
"in": "query",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": "folderUid",
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"description": "Name of group of rules to export. Must be specified only together with a single folder UID",
|
||||
"in": "query",
|
||||
"name": "group",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.",
|
||||
"in": "query",
|
||||
"name": "ruleUid",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertingFileExport",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertingFileExport"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"ruler"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules": {
|
||||
"get": {
|
||||
"description": "List rule groups",
|
||||
@ -5917,6 +6003,59 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules/{Namespace}/export": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json",
|
||||
"application/yaml"
|
||||
],
|
||||
"description": "Converts submitted rule group to provisioning format",
|
||||
"operationId": "RoutePostRulesGroupForExport",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "body",
|
||||
"name": "Body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/PostableRuleGroupConfig"
|
||||
}
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"description": "Whether to initiate a download of the file or not.",
|
||||
"in": "query",
|
||||
"name": "download",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"default": "yaml",
|
||||
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertingFileExport",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertingFileExport"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"ruler"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": {
|
||||
"delete": {
|
||||
"description": "Delete rule group",
|
||||
|
@ -1197,6 +1197,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/export/rules": {
|
||||
"get": {
|
||||
"description": "List rules in provisioning format",
|
||||
"consumes": [
|
||||
"application/json",
|
||||
"application/yaml"
|
||||
],
|
||||
"tags": [
|
||||
"ruler"
|
||||
],
|
||||
"operationId": "RouteGetRulesForExport",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether to initiate a download of the file or not.",
|
||||
"name": "download",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "yaml",
|
||||
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "UIDs of folders from which to export rules",
|
||||
"name": "folderUid",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Name of group of rules to export. Must be specified only together with a single folder UID",
|
||||
"name": "group",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.",
|
||||
"name": "ruleUid",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertingFileExport",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertingFileExport"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules": {
|
||||
"get": {
|
||||
"description": "List rule groups",
|
||||
@ -1315,6 +1376,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules/{Namespace}/export": {
|
||||
"post": {
|
||||
"description": "Converts submitted rule group to provisioning format",
|
||||
"consumes": [
|
||||
"application/json",
|
||||
"application/yaml"
|
||||
],
|
||||
"tags": [
|
||||
"ruler"
|
||||
],
|
||||
"operationId": "RoutePostRulesGroupForExport",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "Body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/PostableRuleGroupConfig"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether to initiate a download of the file or not.",
|
||||
"name": "download",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "yaml",
|
||||
"description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.",
|
||||
"name": "format",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "AlertingFileExport",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/AlertingFileExport"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ruler/grafana/api/v1/rules/{Namespace}/{Groupname}": {
|
||||
"get": {
|
||||
"description": "Get rule group",
|
||||
@ -3003,7 +3117,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"relativeTimeRange": {
|
||||
"$ref": "#/definitions/RelativeTimeRange"
|
||||
"$ref": "#/definitions/RelativeTimeRangeExport"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -3283,6 +3397,9 @@
|
||||
"for": {
|
||||
"type": "string"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@ -4091,6 +4208,12 @@
|
||||
"$ref": "#/definitions/GettableGrafanaReceiver"
|
||||
}
|
||||
},
|
||||
"msteams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -4125,12 +4248,6 @@
|
||||
"$ref": "#/definitions/SNSConfig"
|
||||
}
|
||||
},
|
||||
"teams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"telegram_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -4184,6 +4301,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/GettableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@ -5158,6 +5278,12 @@
|
||||
"$ref": "#/definitions/PostableGrafanaReceiver"
|
||||
}
|
||||
},
|
||||
"msteams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -5192,12 +5318,6 @@
|
||||
"$ref": "#/definitions/SNSConfig"
|
||||
}
|
||||
},
|
||||
"teams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"telegram_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -5251,6 +5371,9 @@
|
||||
"grafana_alert": {
|
||||
"$ref": "#/definitions/PostableGrafanaRule"
|
||||
},
|
||||
"keep_firing_for": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@ -5622,6 +5745,9 @@
|
||||
"token_file": {
|
||||
"type": "string"
|
||||
},
|
||||
"ttl": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -5739,6 +5865,12 @@
|
||||
"$ref": "#/definitions/EmailConfig"
|
||||
}
|
||||
},
|
||||
"msteams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"description": "A unique identifier for this receiver.",
|
||||
"type": "string"
|
||||
@ -5773,12 +5905,6 @@
|
||||
"$ref": "#/definitions/SNSConfig"
|
||||
}
|
||||
},
|
||||
"teams_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/MSTeamsConfig"
|
||||
}
|
||||
},
|
||||
"telegram_configs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -5846,6 +5972,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RelativeTimeRangeExport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"to": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResponseDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -6725,9 +6864,8 @@
|
||||
}
|
||||
},
|
||||
"URL": {
|
||||
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
|
||||
"type": "object",
|
||||
"title": "A URL represents a parsed URL (technically, a URI reference).",
|
||||
"title": "URL is a custom URL type that allows validation at configuration load time.",
|
||||
"properties": {
|
||||
"ForceQuery": {
|
||||
"type": "boolean"
|
||||
@ -6941,7 +7079,6 @@
|
||||
}
|
||||
},
|
||||
"alertGroup": {
|
||||
"description": "AlertGroup alert group",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"alerts",
|
||||
@ -7072,6 +7209,7 @@
|
||||
}
|
||||
},
|
||||
"gettableAlert": {
|
||||
"description": "GettableAlert gettable alert",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"labels",
|
||||
@ -7128,6 +7266,7 @@
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert"
|
||||
@ -7192,6 +7331,7 @@
|
||||
"$ref": "#/definitions/gettableSilences"
|
||||
},
|
||||
"integration": {
|
||||
"description": "Integration integration",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
@ -7336,7 +7476,6 @@
|
||||
}
|
||||
},
|
||||
"postableSilence": {
|
||||
"description": "PostableSilence postable silence",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"comment",
|
||||
|
@ -143,6 +143,43 @@ type AlertRuleGroupWithFolderTitle struct {
|
||||
FolderTitle string
|
||||
}
|
||||
|
||||
func NewAlertRuleGroupWithFolderTitle(groupKey AlertRuleGroupKey, rules []AlertRule, folderTitle string) AlertRuleGroupWithFolderTitle {
|
||||
SortAlertRulesByGroupIndex(rules)
|
||||
var interval int64
|
||||
if len(rules) > 0 {
|
||||
interval = rules[0].IntervalSeconds
|
||||
}
|
||||
var result = AlertRuleGroupWithFolderTitle{
|
||||
AlertRuleGroup: &AlertRuleGroup{
|
||||
Title: groupKey.RuleGroup,
|
||||
FolderUID: groupKey.NamespaceUID,
|
||||
Interval: interval,
|
||||
Rules: rules,
|
||||
},
|
||||
FolderTitle: folderTitle,
|
||||
OrgID: groupKey.OrgID,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func NewAlertRuleGroupWithFolderTitleFromRulesGroup(groupKey AlertRuleGroupKey, rules RulesGroup, folderTitle string) AlertRuleGroupWithFolderTitle {
|
||||
derefRules := make([]AlertRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
derefRules = append(derefRules, *rule)
|
||||
}
|
||||
return NewAlertRuleGroupWithFolderTitle(groupKey, derefRules, folderTitle)
|
||||
}
|
||||
|
||||
// SortAlertRuleGroupWithFolderTitle sorts AlertRuleGroupWithFolderTitle by folder UID and group name
|
||||
func SortAlertRuleGroupWithFolderTitle(g []AlertRuleGroupWithFolderTitle) {
|
||||
sort.SliceStable(g, func(i, j int) bool {
|
||||
if g[i].AlertRuleGroup.FolderUID == g[j].AlertRuleGroup.FolderUID {
|
||||
return g[i].AlertRuleGroup.Title < g[j].AlertRuleGroup.Title
|
||||
}
|
||||
return g[i].AlertRuleGroup.FolderUID < g[j].AlertRuleGroup.FolderUID
|
||||
})
|
||||
}
|
||||
|
||||
// AlertRule is the model for alert rules in unified alerting.
|
||||
type AlertRule struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
@ -556,6 +593,15 @@ func (g RulesGroup) SortByGroupIndex() {
|
||||
})
|
||||
}
|
||||
|
||||
func SortAlertRulesByGroupIndex(rules []AlertRule) {
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
if rules[i].RuleGroupIndex == rules[j].RuleGroupIndex {
|
||||
return rules[i].ID < rules[j].ID
|
||||
}
|
||||
return rules[i].RuleGroupIndex < rules[j].RuleGroupIndex
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
QuotaTargetSrv quota.TargetSrv = "ngalert"
|
||||
QuotaTarget quota.Target = "alert_rule"
|
||||
|
@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
@ -92,6 +94,7 @@ func WithNotEmptyLabels(count int, prefix string) AlertRuleMutator {
|
||||
rule.Labels = GenerateAlertLabels(count, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func WithUniqueID() AlertRuleMutator {
|
||||
usedID := make(map[int64]struct{})
|
||||
return func(rule *AlertRule) {
|
||||
@ -227,6 +230,29 @@ func WithLabel(key, value string) AlertRuleMutator {
|
||||
}
|
||||
}
|
||||
|
||||
func WithUniqueUID(knownUids *sync.Map) AlertRuleMutator {
|
||||
return func(rule *AlertRule) {
|
||||
uid := rule.UID
|
||||
for {
|
||||
_, ok := knownUids.LoadOrStore(uid, struct{}{})
|
||||
if !ok {
|
||||
rule.UID = uid
|
||||
return
|
||||
}
|
||||
uid = uuid.NewString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithQuery(query ...AlertQuery) AlertRuleMutator {
|
||||
return func(rule *AlertRule) {
|
||||
rule.Data = query
|
||||
if len(query) > 1 {
|
||||
rule.Condition = query[0].RefID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateAlertLabels(count int, prefix string) data.Labels {
|
||||
labels := make(data.Labels, count)
|
||||
for i := 0; i < count; i++ {
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@ -436,21 +435,7 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Co
|
||||
return models.AlertRuleGroupWithFolderTitle{}, err
|
||||
}
|
||||
|
||||
res := models.AlertRuleGroupWithFolderTitle{
|
||||
AlertRuleGroup: &models.AlertRuleGroup{
|
||||
Title: ruleList[0].RuleGroup,
|
||||
FolderUID: ruleList[0].NamespaceUID,
|
||||
Interval: ruleList[0].IntervalSeconds,
|
||||
Rules: []models.AlertRule{},
|
||||
},
|
||||
OrgID: orgID,
|
||||
FolderTitle: dash.Title,
|
||||
}
|
||||
for _, r := range ruleList {
|
||||
if r != nil {
|
||||
res.AlertRuleGroup.Rules = append(res.AlertRuleGroup.Rules, *r)
|
||||
}
|
||||
}
|
||||
res := models.NewAlertRuleGroupWithFolderTitleFromRulesGroup(ruleList[0].GetGroupKey(), ruleList, dash.Title)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@ -507,26 +492,11 @@ func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Conte
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot find title for folder with uid '%s'", groupKey.NamespaceUID)
|
||||
}
|
||||
result = append(result, models.AlertRuleGroupWithFolderTitle{
|
||||
AlertRuleGroup: &models.AlertRuleGroup{
|
||||
Title: rules[0].RuleGroup,
|
||||
FolderUID: rules[0].NamespaceUID,
|
||||
Interval: rules[0].IntervalSeconds,
|
||||
Rules: rules,
|
||||
},
|
||||
OrgID: orgID,
|
||||
FolderTitle: title,
|
||||
})
|
||||
result = append(result, models.NewAlertRuleGroupWithFolderTitle(groupKey, rules, title))
|
||||
}
|
||||
|
||||
// Return results in a stable manner.
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if result[i].AlertRuleGroup.FolderUID == result[j].AlertRuleGroup.FolderUID {
|
||||
return result[i].AlertRuleGroup.Title < result[j].AlertRuleGroup.Title
|
||||
}
|
||||
return result[i].AlertRuleGroup.FolderUID < result[j].AlertRuleGroup.FolderUID
|
||||
})
|
||||
|
||||
models.SortAlertRuleGroupWithFolderTitle(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user