Alerting: Update rule API to address folders by UID (#74600)

* Change ruler API to expect the folder UID as namespace

* Update example requests

* Fix tests

* Update swagger

* Modify FIle field in /api/prometheus/grafana/api/v1/rules

* Fix ruler export

* Modify folder in responses to be formatted as <parent UID>/<title>

* Add alerting test with nested folders

* Apply suggestion from code review

* Alerting: use folder UID instead of title in rule API (#77166)

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>

* Drop a few more latent uses of namespace_id

* move getNamespaceKey to models package

* switch GetAlertRulesForScheduling to use folder table

* update GetAlertRulesForScheduling to return folder titles in format `parent_uid/title`.

* fi tests

* add tests for GetAlertRulesForScheduling when parent uid

* fix integration tests after merge

* fix test after merge

* change format of the namespace to JSON array

this is needed for forward compatibility, when we migrate to full paths

* update EF code to decode nested folder

---------

Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>
Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
Co-authored-by: Alex Weaver <weaver.alex.d@gmail.com>
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sofia Papagiannaki 2024-01-17 11:07:39 +02:00 committed by GitHub
parent ec1d4274ed
commit d1dab5828d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1216 additions and 273 deletions

View File

@ -304,7 +304,7 @@ func (srv PrometheusSrv) toRuleGroup(groupKey ngmodels.AlertRuleGroupKey, folder
newGroup := &apimodels.RuleGroup{
Name: groupKey.RuleGroup,
// file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana.
File: folder.Title,
File: ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title),
}
rulesTotals := make(map[string]int64, len(rules))

View File

@ -52,8 +52,8 @@ var (
// or, if non-empty, a specific group of rules in the namespace.
// Returns http.StatusForbidden if user does not have access to any of the rules that match the filter.
// Returns http.StatusBadRequest if all rules that match the filter and the user is authorized to delete are provisioned.
func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceUID string, group string) response.Response {
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
@ -144,8 +144,8 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceT
}
// RouteGetNamespaceRulesConfig returns all rules in a specific folder that user has access to
func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceUID string) response.Response {
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
@ -162,8 +162,9 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
result := apimodels.NamespaceConfigResponse{}
for groupKey, rules := range ruleGroups {
key := ngmodels.GetNamespaceKey(namespace.ParentUID, namespace.Title)
// nolint:staticcheck
result[namespaceTitle] = append(result[namespaceTitle], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
}
return response.JSON(http.StatusAccepted, result)
@ -171,8 +172,8 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
// RouteGetRulesGroupConfig returns rules that belong to a specific group in a specific namespace (folder).
// If user does not have access to at least one of the rule in the group, returns status 403 Forbidden
func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceTitle string, ruleGroup string) response.Response {
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceUID string, ruleGroup string) response.Response {
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
@ -241,15 +242,15 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res
srv.log.Error("Namespace not visible to the user", "user", id, "userNamespace", userNamespace, "namespace", groupKey.NamespaceUID)
continue
}
namespace := folder.Title
key := ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title)
// nolint:staticcheck
result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
}
return response.JSON(http.StatusOK, result)
}
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response {
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}

View File

@ -13,9 +13,9 @@ import (
)
// 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.GetOrgID(), c.SignedInUser)
// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceUID`
func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}

View File

@ -52,7 +52,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Header.Add("Accept", "application/yaml")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
@ -64,7 +64,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Form.Set("format", "yaml")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -75,7 +75,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Form.Set("format", "foo")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -86,7 +86,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Header.Add("Accept", "application/json")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -97,7 +97,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Header.Add("Accept", "application/json, application/yaml")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -108,7 +108,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Form.Set("download", "true")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -119,7 +119,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Form.Set("download", "false")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -129,7 +129,7 @@ func TestExportFromPayload(t *testing.T) {
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 := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -143,7 +143,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Header.Add("Accept", "application/json")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
t.Log(string(response.Body()))
@ -158,7 +158,7 @@ func TestExportFromPayload(t *testing.T) {
rc := createRequest()
rc.Context.Req.Header.Add("Accept", "application/yaml")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
require.Equal(t, string(expectedResponse), string(response.Body()))
@ -172,7 +172,7 @@ func TestExportFromPayload(t *testing.T) {
rc.Context.Req.Form.Set("format", "hcl")
rc.Context.Req.Form.Set("download", "false")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())
@ -184,7 +184,7 @@ func TestExportFromPayload(t *testing.T) {
rc.Context.Req.Form.Set("format", "hcl")
rc.Context.Req.Form.Set("download", "true")
response := srv.ExportFromPayload(rc, body, folder.Title)
response := srv.ExportFromPayload(rc, body, folder.UID)
response.WriteTo(rc)
require.Equal(t, 200, response.Status())

View File

@ -79,7 +79,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
request := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil)
response := createService(ruleStore).RouteDeleteAlertRules(request, folder.Title, "")
response := createService(ruleStore).RouteDeleteAlertRules(request, folder.UID, "")
require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body()))
require.Empty(t, getRecordedCommand(ruleStore))
@ -102,7 +102,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
permissions := createPermissionsForRules(append(authorizedRulesInFolder, provisionedRulesInFolder...), orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body()))
assertRulesDeleted(t, authorizedRulesInFolder, ruleStore)
@ -122,7 +122,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
permissions := createPermissionsForRules(provisionedRulesInFolder, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body()))
require.Empty(t, getRecordedCommand(ruleStore))
@ -131,7 +131,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
ruleStore := initFakeRuleStore(t)
requestCtx := createRequestContext(orgID, nil)
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body()))
require.Empty(t, getRecordedCommand(ruleStore))
@ -150,7 +150,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
permissions := createPermissionsForRules(authorizedRulesInGroup, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName)
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName)
require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body()))
deleteCommands := getRecordedCommand(ruleStore)
@ -169,7 +169,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
permissions := createPermissionsForRules(provisionedRulesInFolder, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName)
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName)
require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body()))
deleteCommands := getRecordedCommand(ruleStore)
@ -193,14 +193,14 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
permissions := createPermissionsForRules(expectedRules, orgID)
req := createRequestContextWithPerms(orgID, permissions, nil)
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title)
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.NamespaceConfigResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.NotNil(t, result)
for namespace, groups := range *result {
require.Equal(t, folder.Title, namespace)
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
for _, group := range groups {
grouploop:
for _, actualRule := range group.Rules {
@ -235,7 +235,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
require.NoError(t, err)
req := createRequestContext(orgID, nil)
response := svc.RouteGetNamespaceRulesConfig(req, folder.Title)
response := svc.RouteGetNamespaceRulesConfig(req, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.NamespaceConfigResponse{}
@ -243,7 +243,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
require.NotNil(t, result)
found := false
for namespace, groups := range *result {
require.Equal(t, folder.Title, namespace)
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
for _, group := range groups {
for _, actualRule := range group.Rules {
if actualRule.GrafanaManagedAlert.UID == expectedRules[0].UID {
@ -269,7 +269,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
ruleStore.PutRule(context.Background(), expectedRules...)
req := createRequestContext(orgID, nil)
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title)
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.NamespaceConfigResponse{}
@ -278,8 +278,8 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
models.RulesGroup(expectedRules).SortByGroupIndex()
require.Contains(t, *result, folder.Title)
groups := (*result)[folder.Title]
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
require.True(t, ok)
require.Len(t, groups, 1)
group := groups[0]
require.Equal(t, groupKey.RuleGroup, group.Name)
@ -329,10 +329,10 @@ func TestRouteGetRulesConfig(t *testing.T) {
require.NoError(t, json.Unmarshal(response.Body(), result))
require.NotNil(t, result)
require.Contains(t, *result, folder1.Title)
require.NotContains(t, *result, folder2.Title)
require.Contains(t, *result, models.GetNamespaceKey(folder1.ParentUID, folder1.Title))
require.NotContains(t, *result, folder2.UID)
groups := (*result)[folder1.Title]
groups := (*result)[models.GetNamespaceKey(folder1.ParentUID, folder1.Title)]
require.Len(t, groups, 1)
require.Equal(t, group1Key.RuleGroup, groups[0].Name)
require.Len(t, groups[0].Rules, len(group1))
@ -361,8 +361,8 @@ func TestRouteGetRulesConfig(t *testing.T) {
models.RulesGroup(expectedRules).SortByGroupIndex()
require.Contains(t, *result, folder.Title)
groups := (*result)[folder.Title]
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
require.True(t, ok)
require.Len(t, groups, 1)
group := groups[0]
require.Equal(t, groupKey.RuleGroup, group.Name)
@ -399,20 +399,20 @@ func TestRouteGetRulesGroupConfig(t *testing.T) {
t.Run("and return Forbidden if user does not have access one of rules", func(t *testing.T) {
permissions := createPermissionsForRules(expectedRules[1:], orgID)
request := createRequestContextWithPerms(orgID, permissions, map[string]string{
":Namespace": folder.Title,
":Namespace": folder.UID,
":Groupname": groupKey.RuleGroup,
})
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup)
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup)
require.Equal(t, http.StatusForbidden, response.Status())
})
t.Run("and return rules if user has access to all of them", func(t *testing.T) {
permissions := createPermissionsForRules(expectedRules, orgID)
request := createRequestContextWithPerms(orgID, permissions, map[string]string{
":Namespace": folder.Title,
":Namespace": folder.UID,
":Groupname": groupKey.RuleGroup,
})
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup)
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.RuleGroupConfigResponse{}
@ -435,7 +435,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) {
ruleStore.PutRule(context.Background(), expectedRules...)
req := createRequestContext(orgID, nil)
response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.Title, groupKey.RuleGroup)
response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.UID, groupKey.RuleGroup)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.RuleGroupConfigResponse{}

View File

@ -104,7 +104,8 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext,
now,
rule,
results,
state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder),
// TODO remove when switched to full path https://github.com/grafana/grafana/issues/80324
state.GetRuleExtraLabels(rule, ngmodels.GetNamespaceKey("", body.NamespaceTitle), includeFolder),
)
alerts := make([]*amv2.PostableAlert, 0, len(transitions))

View File

@ -10,8 +10,9 @@ import (
// RuleStore is the interface for persisting alert rules and instances
type RuleStore interface {
// TODO after deprecating namespace_id field in GettableGrafanaRule we can simplify this interface
// by returning map[string]struct{} instead of map[string]*folder.Folder
GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error)
GetNamespaceByTitle(context.Context, string, int64, identity.Requester) (*folder.Folder, error)
GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error)
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error)
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)

View File

@ -1,7 +1,7 @@
@grafanaRecipient = grafana
// should point to an existing folder named alerting
@namespace1 = foo%20bar
// should point to an existing folder UID
@namespace1 = baf2c548-5e1e-42e8-8fde-8320e50d801e
// create group42 under unknown namespace - it should fail
POST http://admin:admin@localhost:3000/api/ruler/{{grafanaRecipient}}/api/v1/rules/unknown

View File

@ -4652,6 +4652,7 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},

View File

@ -166,6 +166,7 @@ import (
// swagger:parameters RoutePostNameRulesConfig RoutePostNameGrafanaRulesConfig RoutePostRulesGroupForExport
type NamespaceConfig struct {
// The UID of the rule folder
// in:path
Namespace string
// in:body
@ -174,12 +175,14 @@ type NamespaceConfig struct {
// swagger:parameters RouteGetNamespaceRulesConfig RouteDeleteNamespaceRulesConfig RouteGetNamespaceGrafanaRulesConfig RouteDeleteNamespaceGrafanaRulesConfig
type PathNamespaceConfig struct {
// The UID of the rule folder
// in: path
Namespace string
}
// swagger:parameters RouteGetRulegGroupConfig RouteDeleteRuleGroupConfig RouteGetGrafanaRuleGroupConfig RouteDeleteGrafanaRuleGroupConfig
type PathRouleGroupConfig struct {
// The UID of the rule folder
// in: path
Namespace string
// in: path

View File

@ -6254,6 +6254,7 @@
"operationId": "RouteDeleteNamespaceGrafanaRulesConfig",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6283,6 +6284,7 @@
"operationId": "RouteGetNamespaceGrafanaRulesConfig",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6319,6 +6321,7 @@
"operationId": "RoutePostNameGrafanaRulesConfig",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6361,6 +6364,7 @@
"operationId": "RoutePostRulesGroupForExport",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6416,6 +6420,7 @@
"operationId": "RouteDeleteGrafanaRuleGroupConfig",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6451,6 +6456,7 @@
"operationId": "RouteGetGrafanaRuleGroupConfig",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6550,6 +6556,7 @@
"type": "string"
},
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6592,6 +6599,7 @@
"type": "string"
},
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6641,6 +6649,7 @@
"type": "string"
},
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6692,6 +6701,7 @@
"type": "string"
},
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
@ -6740,6 +6750,7 @@
"type": "string"
},
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,

View File

@ -1316,6 +1316,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1349,6 +1350,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1385,6 +1387,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1420,6 +1423,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1478,6 +1482,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1513,6 +1518,7 @@
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1612,6 +1618,7 @@
},
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1658,6 +1665,7 @@
},
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1707,6 +1715,7 @@
},
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1754,6 +1763,7 @@
},
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
@ -1802,6 +1812,7 @@
},
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true

View File

@ -7,6 +7,7 @@ import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/google/go-cmp/cmp"
@ -545,7 +546,8 @@ type GetAlertRulesForSchedulingQuery struct {
PopulateFolders bool
RuleGroups []string
ResultRules []*AlertRule
ResultRules []*AlertRule
// A map of folder UID to folder Title in NamespaceKey format (see GetNamespaceKey)
ResultFoldersTitles map[string]string
}
@ -683,3 +685,29 @@ func GroupByAlertRuleGroupKey(rules []*AlertRule) map[AlertRuleGroupKey]RulesGro
}
return result
}
// GetNamespaceKey concatenates two strings with / as separator. If the latter string contains '/' it gets escaped with \/
func GetNamespaceKey(parentUID, title string) string {
if parentUID == "" {
return title
}
b, err := json.Marshal([]string{parentUID, title})
if err != nil {
return title // this should not really happen
}
return string(b)
}
// GetNamespaceTitleFromKey extracts the latter part from the string produced by GetNamespaceKey
func GetNamespaceTitleFromKey(ns string) string {
// the expected format of the string is a JSON array ["parentUID","title"]
if !strings.HasPrefix(ns, "[") {
return ns
}
var arr []string
err := json.Unmarshal([]byte(ns), &arr)
if err != nil || len(arr) != 2 {
return ns
}
return arr[1]
}

View File

@ -729,3 +729,66 @@ func TestTimeRangeYAML(t *testing.T) {
require.NoError(t, err)
require.Equal(t, yamlRaw, string(serialized))
}
func TestGetNamespaceTitleFromKey(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{"just title", "title with space", "title with space"},
{"title and uid", `["parentUID","title"]`, "title"},
{"wrong input-empty array", "[]", "[]"},
{"wrong input-incorrect json", "[", "["},
{"wrong input-long array", `["parentUID","title","title"]`, `["parentUID","title","title"]`},
{"empty string", "", ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := GetNamespaceTitleFromKey(tc.input)
require.Equal(t, actual, tc.expected)
})
}
}
func TestGetNamespaceKey(t *testing.T) {
cases := []struct {
name string
parentUID string
title string
expected string
}{
{
name: "Parent UID and title",
parentUID: "parentUID",
title: "Title/Title",
expected: `["parentUID","Title/Title"]`,
},
{
name: "EmptyTitle",
parentUID: "parentUID",
title: "",
expected: `["parentUID",""]`,
},
{
name: "EmptyParentUID",
parentUID: "",
title: "Title",
expected: "Title",
},
{
name: "BothEmpty",
parentUID: "",
title: "",
expected: "",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
actual := GetNamespaceKey(tt.parentUID, tt.title)
require.Equal(t, actual, tt.expected)
})
}
}

View File

@ -512,7 +512,8 @@ func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolde
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
if includeFolder {
extraLabels[models.FolderTitleLabel] = folderTitle
// TODO remove when title will contain the full path https://github.com/grafana/grafana/issues/80324
extraLabels[models.FolderTitleLabel] = models.GetNamespaceTitleFromKey(folderTitle)
}
return extraLabels
}

View File

@ -461,8 +461,9 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use
continue
}
namespaceMap[hit.UID] = &folder.Folder{
UID: hit.UID,
Title: hit.Title,
UID: hit.UID,
Title: hit.Title,
ParentUID: hit.FolderUID,
}
}
page += 1
@ -470,16 +471,6 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use
return namespaceMap, nil
}
// GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces.
func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user identity.Requester) (*folder.Folder, error) {
folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace, SignedInUser: user})
if err != nil {
return nil, err
}
return folder, nil
}
// GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces.
func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) {
folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &uid, SignedInUser: user})
@ -516,8 +507,9 @@ func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodel
// GetAlertRulesForScheduling returns a short version of all alert rules except those that belong to an excluded list of organizations
func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error {
var folders []struct {
Uid string
Title string
Uid string
Title string
ParentUid string
}
var rules []*ngmodels.AlertRule
return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
@ -566,7 +558,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
query.ResultRules = rules
if query.PopulateFolders {
foldersSql := sess.Table("folder").Alias("d").Select("d.uid, d.title").
foldersSql := sess.Table("folder").Alias("d").Select("d.uid, d.title, d.parent_uid").
Where(`EXISTS (SELECT 1 FROM alert_rule a WHERE d.uid = a.namespace_uid AND d.org_id = a.org_id)`)
if len(disabledOrgs) > 0 {
foldersSql.NotIn("org_id", disabledOrgs)
@ -577,7 +569,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
}
query.ResultFoldersTitles = make(map[string]string, len(folders))
for _, folder := range folders {
query.ResultFoldersTitles[folder.Uid] = folder.Title
query.ResultFoldersTitles[folder.Uid] = ngmodels.GetNamespaceKey(folder.ParentUid, folder.Title)
}
}
return nil

View File

@ -339,10 +339,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID())
rule1 := createRule(t, store, generator)
rule2 := createRule(t, store, generator)
createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID)
createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID)
createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID) // create a folder with the same UID but in the different org
parentFolderUid := uuid.NewString()
createFolder(t, store, parentFolderUid, "Very Parent Folder", rule1.OrgID, "")
createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID, parentFolderUid)
createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID, "")
createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID, "") // create a folder with the same UID but in the different org
tc := []struct {
name string
@ -368,13 +371,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
{
name: "with populate folders enabled, it returns them",
rules: []string{rule1.Title, rule2.Title},
folders: map[string]string{rule1.NamespaceUID: rule1.Title, rule2.NamespaceUID: rule2.Title},
folders: map[string]string{rule1.NamespaceUID: models.GetNamespaceKey(parentFolderUid, rule1.Title), rule2.NamespaceUID: rule2.Title},
},
{
name: "with populate folders enabled and a filter on orgs, it only returns selected information",
rules: []string{rule1.Title},
disabledOrgs: []int64{rule2.OrgID},
folders: map[string]string{rule1.NamespaceUID: rule1.Title},
folders: map[string]string{rule1.NamespaceUID: models.GetNamespaceKey(parentFolderUid, rule1.Title)},
},
}
@ -522,7 +525,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) {
uid := uuid.NewString()
title := "folder-title"
createFolder(t, store, uid, title, 1)
createFolder(t, store, uid, title, 1, "")
actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u)
require.NoError(t, err)
@ -604,7 +607,7 @@ func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule)
return rule
}
func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64) {
func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, parentUID string) {
t.Helper()
u := &user.SignedInUser{
UserID: 1,
@ -619,6 +622,7 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64)
Title: title,
Description: "",
SignedInUser: u,
ParentUID: parentUID,
})
require.NoError(t, err)

View File

@ -245,16 +245,6 @@ func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ i
return namespacesMap, nil
}
func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ identity.Requester) (*folder.Folder, error) {
folders := f.Folders[orgID]
for _, folder := range folders {
if folder.Title == title {
return folder, nil
}
}
return nil, fmt.Errorf("not found")
}
func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ identity.Requester) (*folder.Folder, error) {
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{
Name: "GetNamespaceByUID",

View File

@ -321,6 +321,362 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
})
}
func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
// Setup Grafana and its Database
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, p)
permissionsStore := resourcepermissions.NewStore(store, featuremgmt.WithFeatures())
// Create a user to make authenticated requests
userID := createUser(t, store, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleEditor),
Password: "password",
Login: "grafana",
})
apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
// Create the namespace we'll save our alerts to.
apiClient.CreateFolder(t, "folder1", "folder1")
// Create the namespace we'll save our alerts to.
apiClient.CreateFolder(t, "folder2", "folder2")
// Create a subfolder
apiClient.CreateFolder(t, "subfolder", "subfolder", "folder1")
postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-post.json"))
require.NoError(t, err)
var group1 apimodels.PostableRuleGroupConfig
require.NoError(t, json.Unmarshal(postGroupRaw, &group1))
// Create rule under folder1
_, status, response := apiClient.PostRulesGroupWithStatus(t, "folder1", &group1)
require.Equalf(t, http.StatusAccepted, status, response)
postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-2-post.json"))
require.NoError(t, err)
var group2 apimodels.PostableRuleGroupConfig
require.NoError(t, json.Unmarshal(postGroupRaw, &group2))
// Create rule under folder2
_, status, response = apiClient.PostRulesGroupWithStatus(t, "folder2", &group2)
require.Equalf(t, http.StatusAccepted, status, response)
postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-3-post.json"))
require.NoError(t, err)
var group3 apimodels.PostableRuleGroupConfig
require.NoError(t, json.Unmarshal(postGroupRaw, &group3))
// Create rule under subfolder
_, status, response = apiClient.PostRulesGroupWithStatus(t, "subfolder", &group3)
require.Equalf(t, http.StatusAccepted, status, response)
// With the rules created, let's make sure that rule definitions are stored.
allRules, status, _ := apiClient.GetAllRulesWithStatus(t)
require.Equal(t, http.StatusOK, status)
status, allExportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
})
require.Equal(t, http.StatusOK, status)
var allExport apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(allExportRaw), &allExport))
t.Run("when user has all permissions", func(t *testing.T) {
t.Run("Get all returns all rules", func(t *testing.T) {
var group1, group2, group3 apimodels.GettableRuleGroupConfig
getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-get.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup1Raw, &group1))
getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-get.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup2Raw, &group2))
getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-get.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup3Raw, &group3))
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
expected := apimodels.NamespaceConfigResponse{
"folder1": []apimodels.GettableRuleGroupConfig{
group1,
},
"folder2": []apimodels.GettableRuleGroupConfig{
group2,
},
nestedKey: []apimodels.GettableRuleGroupConfig{
group3,
},
}
pathsToIgnore := []string{
"GrafanaManagedAlert.Updated",
"GrafanaManagedAlert.UID",
"GrafanaManagedAlert.ID",
"GrafanaManagedAlert.Data.Model",
"GrafanaManagedAlert.NamespaceUID",
"GrafanaManagedAlert.NamespaceID",
}
// compare expected and actual and ignore the dynamic fields
diff := cmp.Diff(expected, allRules, cmp.FilterPath(func(path cmp.Path) bool {
for _, s := range pathsToIgnore {
if strings.Contains(path.String(), s) {
return true
}
}
return false
}, cmp.Ignore()))
require.Empty(t, diff)
for _, rule := range allRules["folder1"][0].Rules {
assert.Equal(t, "folder1", rule.GrafanaManagedAlert.NamespaceUID)
}
for _, rule := range allRules["folder2"][0].Rules {
assert.Equal(t, "folder2", rule.GrafanaManagedAlert.NamespaceUID)
}
for _, rule := range allRules[nestedKey][0].Rules {
assert.Equal(t, "subfolder", rule.GrafanaManagedAlert.NamespaceUID)
}
})
t.Run("Get by folder returns groups in folder", func(t *testing.T) {
rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder1")
require.Equal(t, http.StatusAccepted, status)
require.Contains(t, rules, "folder1")
require.Len(t, rules["folder1"], 1)
require.Equal(t, allRules["folder1"], rules["folder1"])
})
t.Run("Get group returns a single group", func(t *testing.T) {
rules := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
cmp.Diff(allRules["folder2"][0], rules.GettableRuleGroupConfig)
})
t.Run("Get by folder returns groups in folder with nested folder format", func(t *testing.T) {
rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "subfolder")
require.Equal(t, http.StatusAccepted, status)
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
require.Contains(t, rules, nestedKey)
require.Len(t, rules[nestedKey], 1)
require.Equal(t, allRules[nestedKey], rules[nestedKey])
})
t.Run("Export returns all rules", func(t *testing.T) {
var group1File, group2File, group3File apimodels.AlertingFileExport
getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-export.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup1Raw, &group1File))
getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-export.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup2Raw, &group2File))
getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-export.json"))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup3Raw, &group3File))
group1File.Groups = append(group1File.Groups, group2File.Groups...)
group1File.Groups = append(group1File.Groups, group3File.Groups...)
expected := group1File
pathsToIgnore := []string{
"Groups.Rules.UID",
"Groups.Folder",
}
// compare expected and actual and ignore the dynamic fields
diff := cmp.Diff(expected, allExport, cmp.FilterPath(func(path cmp.Path) bool {
for _, s := range pathsToIgnore {
if strings.Contains(path.String(), s) {
return true
}
}
return false
}, cmp.Ignore()))
require.Empty(t, diff)
require.Equal(t, "folder1", allExport.Groups[0].Folder)
require.Equal(t, "folder2", allExport.Groups[1].Folder)
require.Equal(t, "subfolder", allExport.Groups[2].Folder)
})
t.Run("Export from one folder", func(t *testing.T) {
expected := allExport.Groups[0]
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"folder1"},
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
t.Run("Export from a subfolder", func(t *testing.T) {
expected := allExport.Groups[2]
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"subfolder"},
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
t.Run("Export from one group", func(t *testing.T) {
expected := allExport.Groups[0]
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"folder1"},
GroupName: expected.Name,
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
t.Run("Export from one group under subfolder", func(t *testing.T) {
expected := allExport.Groups[2]
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"subfolder"},
GroupName: expected.Name,
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
t.Run("Export single rule", func(t *testing.T) {
expected := allExport.Groups[0]
expected.Rules = []apimodels.AlertRuleExport{
expected.Rules[0],
}
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
RuleUID: expected.Rules[0].UID,
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
t.Log(exportRaw)
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
})
t.Run("when permissions for folder2 removed", func(t *testing.T) {
// remove permissions for folder2
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder2")
// remove permissions for subfolder (inherits from folder1)
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "subfolder")
apiClient.ReloadCachedPermissions(t)
t.Run("Get all returns all rules", func(t *testing.T) {
newAll, status, _ := apiClient.GetAllRulesWithStatus(t)
require.Equal(t, http.StatusOK, status)
require.Contains(t, newAll, "folder1")
require.NotContains(t, newAll, "folder2")
require.Contains(t, newAll, ngmodels.GetNamespaceKey("folder1", "subfolder"))
})
t.Run("Get by folder returns groups in folder", func(t *testing.T) {
_, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder2")
require.Equal(t, http.StatusForbidden, status)
})
t.Run("Get group returns a single group", func(t *testing.T) {
u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/folder2/arulegroup", apiClient.url)
// nolint:gosec
resp, err := http.Get(u)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Export returns all rules", func(t *testing.T) {
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
})
require.Equal(t, http.StatusOK, status)
var export apimodels.AlertingFileExport
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
require.Equal(t, http.StatusOK, status)
require.Len(t, export.Groups, 2)
require.Equal(t, "folder1", export.Groups[0].Folder)
require.Equal(t, "subfolder", export.Groups[1].Folder)
})
t.Run("Export from one folder", func(t *testing.T) {
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"folder2"},
})
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("Export from one group", func(t *testing.T) {
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
FolderUID: []string{"folder2"},
GroupName: "arulegroup",
})
assert.Equal(t, http.StatusForbidden, status)
})
t.Run("Export single rule", func(t *testing.T) {
uid := allRules["folder2"][0].Rules[0].GrafanaManagedAlert.UID
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
RuleUID: uid,
})
require.Equal(t, http.StatusForbidden, status)
})
t.Run("when all permissions are revoked", func(t *testing.T) {
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder1")
apiClient.ReloadCachedPermissions(t)
rules, status, _ := apiClient.GetAllRulesWithStatus(t)
require.Equal(t, http.StatusOK, status)
require.Empty(t, rules)
status, _ = apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
})
require.Equal(t, http.StatusNotFound, status)
})
})
}
func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) {
t.Helper()
@ -895,19 +1251,21 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
})
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
folder1Title := "folder1"
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
parentFolderUID := util.GenerateShortUID()
client.CreateFolder(t, parentFolderUID, "parent")
folderUID := util.GenerateShortUID()
client.CreateFolder(t, folderUID, "folder1", parentFolderUID)
group1 := generateAlertRuleGroup(5, alertRuleGen())
group2 := generateAlertRuleGroup(5, alertRuleGen())
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &group1)
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &group1)
require.Equal(t, http.StatusAccepted, status)
_, status, _ = client.PostRulesGroupWithStatus(t, folder1Title, &group2)
_, status, _ = client.PostRulesGroupWithStatus(t, folderUID, &group2)
require.Equal(t, http.StatusAccepted, status)
t.Run("should persist order of the rules in a group", func(t *testing.T) {
group1Get := client.GetRulesGroup(t, folder1Title, group1.Name)
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
assert.Equal(t, group1.Name, group1Get.Name)
assert.Equal(t, group1.Interval, group1Get.Interval)
assert.Len(t, group1Get.Rules, len(group1.Rules))
@ -926,10 +1284,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
for _, rule := range postableGroup1.Rules {
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
}
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1)
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
require.Equal(t, http.StatusAccepted, status)
group1Get = client.GetRulesGroup(t, folder1Title, group1.Name)
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
@ -941,8 +1299,8 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
})
t.Run("should be able to move a rule from another group in a specific position", func(t *testing.T) {
group1Get := client.GetRulesGroup(t, folder1Title, group1.Name)
group2Get := client.GetRulesGroup(t, folder1Title, group2.Name)
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
group2Get := client.GetRulesGroup(t, folderUID, group2.Name)
movedRule := convertGettableRuleToPostable(group2Get.Rules[3])
// now shuffle the rules
@ -952,10 +1310,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
for _, rule := range postableGroup1.Rules {
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
}
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1)
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
require.Equal(t, http.StatusAccepted, status)
group1Get = client.GetRulesGroup(t, folder1Title, group1.Name)
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
@ -965,7 +1323,7 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
}
assert.Equal(t, expectedUids, actualUids)
group2Get = client.GetRulesGroup(t, folder1Title, group2.Name)
group2Get = client.GetRulesGroup(t, folderUID, group2.Name)
assert.Len(t, group2Get.Rules, len(group2.Rules)-1)
for _, rule := range group2Get.Rules {
require.NotEqual(t, movedRule.GrafanaManagedAlert.UID, rule.GrafanaManagedAlert.UID)
@ -1019,26 +1377,26 @@ func TestIntegrationRuleUpdate(t *testing.T) {
adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
folder1Title := "folder1"
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
folderUID := util.GenerateShortUID()
client.CreateFolder(t, folderUID, "folder1")
t.Run("should be able to reset 'for' to 0", func(t *testing.T) {
group := generateAlertRuleGroup(1, alertRuleGen())
expected := model.Duration(10 * time.Second)
group.Rules[0].ApiRuleNode.For = &expected
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
expected = 0
group.Rules[0].ApiRuleNode.For = &expected
_, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
})
t.Run("when data source missing", func(t *testing.T) {
@ -1047,10 +1405,10 @@ func TestIntegrationRuleUpdate(t *testing.T) {
ds1 := adminClient.CreateTestDatasource(t)
group := generateAlertRuleGroup(3, alertRuleGen(withDatasourceQuery(ds1.Body.Datasource.UID)))
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
require.Len(t, group.Rules, 3)
@ -1064,59 +1422,59 @@ func TestIntegrationRuleUpdate(t *testing.T) {
}
t.Run("noop should not fail", func(t *testing.T) {
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
getGroup := client.GetRulesGroup(t, folderUID, groupName)
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
})
t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) {
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
getGroup := client.GetRulesGroup(t, folderUID, groupName)
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString()
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
if status == http.StatusAccepted {
assert.Len(t, resp.Deleted, 1)
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated")
}
require.Equalf(t, http.StatusBadRequest, status, "expected BadRequest. Response: %s", body)
assert.Contains(t, body, "data source not found")
})
t.Run("should let delete broken rule", func(t *testing.T) {
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
getGroup := client.GetRulesGroup(t, folderUID, groupName)
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
// remove the last rule.
group.Rules = group.Rules[0 : len(group.Rules)-1]
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body)
assert.Len(t, resp.Deleted, 1)
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
require.Len(t, group.Rules, 2)
})
t.Run("should let fix single rule", func(t *testing.T) {
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
getGroup := client.GetRulesGroup(t, folderUID, groupName)
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
ds2 := adminClient.CreateTestDatasource(t)
withDatasourceQuery(ds2.Body.Datasource.UID)(&group.Rules[0])
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
assert.Len(t, resp.Deleted, 0)
assert.Len(t, resp.Updated, 2)
assert.Len(t, resp.Created, 0)
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
require.Equal(t, ds2.Body.Datasource.UID, group.Rules[0].GrafanaManagedAlert.Data[0].DatasourceUID)
})
t.Run("should let delete group", func(t *testing.T) {
status, body := client.DeleteRulesGroup(t, folder1Title, groupName)
status, body := client.DeleteRulesGroup(t, folderUID, groupName)
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
})
})
@ -1210,18 +1568,18 @@ func TestIntegrationRulePause(t *testing.T) {
})
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
folder1Title := "folder1"
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
folderUID := util.GenerateShortUID()
client.CreateFolder(t, folderUID, "folder1")
t.Run("should create a paused rule if isPaused is true", func(t *testing.T) {
group := generateAlertRuleGroup(1, alertRuleGen())
expectedIsPaused := true
group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
require.Len(t, resp.Created, 1)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
})
@ -1231,10 +1589,10 @@ func TestIntegrationRulePause(t *testing.T) {
expectedIsPaused := false
group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
require.Len(t, resp.Created, 1)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
})
@ -1243,10 +1601,10 @@ func TestIntegrationRulePause(t *testing.T) {
group := generateAlertRuleGroup(1, alertRuleGen())
group.Rules[0].GrafanaManagedAlert.IsPaused = nil
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
require.Len(t, resp.Created, 1)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
require.False(t, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
})
@ -1301,17 +1659,17 @@ func TestIntegrationRulePause(t *testing.T) {
group := generateAlertRuleGroup(1, alertRuleGen())
group.Rules[0].GrafanaManagedAlert.IsPaused = &tc.isPausedInDb
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
group.Rules[0].GrafanaManagedAlert.IsPaused = tc.isPausedInBody
_, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group)
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
require.Equal(t, tc.expectedIsPausedInDb, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
})
}

View File

@ -0,0 +1,75 @@
{
"apiVersion": 1,
"groups": [
{
"orgId": 1,
"name": "Group3",
"folder": "<dynamic>",
"interval": "1m",
"rules": [
{
"uid": "<dynamic>",
"title": "Rule1",
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "0 \u003e 0",
"intervalMs": 1000,
"maxDataPoints": 43200,
"type": "math"
}
}
],
"noDataState": "NoData",
"execErrState": "Alerting",
"for": "5m",
"annotations": {
"annotation": "test-annotation"
},
"labels": {
"label1": "test-label"
},
"isPaused": false
},
{
"uid": "<dynamic>",
"title": "Rule2",
"condition": "A",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "0 == 0",
"intervalMs": 1000,
"maxDataPoints": 43200,
"type": "math"
}
}
],
"noDataState": "NoData",
"execErrState": "Alerting",
"for": "5m",
"annotations": {
"annotation": "test-annotation"
},
"labels": {
"label1": "test-label"
},
"isPaused": false
}
]
}
]
}

View File

@ -0,0 +1,90 @@
{
"name": "Group3",
"interval": "1m",
"rules": [
{
"expr": "",
"for": "5m",
"labels": {
"label1": "test-label"
},
"annotations": {
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 1,
"orgId": 1,
"title": "Rule1",
"condition": "A",
"data": [
{
"refId": "A",
"queryType": "",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "0 > 0",
"intervalMs": 1000,
"maxDataPoints": 43200,
"type": "math"
}
}
],
"updated": "2023-09-29T17:37:19Z",
"intervalSeconds": 60,
"version": 1,
"uid": "<dynamic>",
"namespace_uid": "<dynamic>",
"rule_group": "Group3",
"no_data_state": "NoData",
"exec_err_state": "Alerting",
"is_paused": false
}
},
{
"expr": "",
"for": "5m",
"labels": {
"label1": "test-label"
},
"annotations": {
"annotation": "test-annotation"
},
"grafana_alert": {
"id": 2,
"orgId": 1,
"title": "Rule2",
"condition": "A",
"data": [
{
"refId": "A",
"queryType": "",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"expression": "0 == 0",
"intervalMs": 1000,
"maxDataPoints": 43200,
"type": "math"
}
}
],
"updated": "2023-09-29T17:37:19Z",
"intervalSeconds": 60,
"version": 1,
"uid": "<dynamic>",
"namespace_uid": "<dynamic>",
"rule_group": "Group3",
"no_data_state": "NoData",
"exec_err_state": "Alerting",
"is_paused": false
}
}
]
}

View File

@ -0,0 +1,56 @@
{
"name": "Group3",
"interval": "1m",
"rules": [
{
"for": "5m",
"labels": {
"label1": "test-label"
},
"annotations": {
"annotation": "test-annotation"
},
"grafana_alert": {
"title": "Rule1",
"condition": "A",
"data": [
{
"refId": "A",
"datasourceUid": "__expr__",
"model": {
"expression": "0 > 0",
"type": "math"
}
}
],
"no_data_state": "NoData",
"exec_err_state": "Alerting"
}
},
{
"for": "5m",
"labels": {
"label1": "test-label"
},
"annotations": {
"annotation": "test-annotation"
},
"grafana_alert": {
"title": "Rule2",
"condition": "A",
"data": [
{
"refId": "A",
"datasourceUid": "__expr__",
"model": {
"expression": "0 == 0",
"type": "math"
}
}
],
"no_data_state": "NoData",
"exec_err_state": "Alerting"
}
}
]
}

View File

@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/api"
"github.com/grafana/grafana/pkg/expr"
"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/quota"
@ -260,9 +261,20 @@ func (a apiClient) ReloadCachedPermissions(t *testing.T) {
}
// CreateFolder creates a folder for storing our alerts, and then refreshes the permission cache to make sure that following requests will be accepted
func (a apiClient) CreateFolder(t *testing.T, uID string, title string) {
func (a apiClient) CreateFolder(t *testing.T, uID string, title string, parentUID ...string) {
t.Helper()
payload := fmt.Sprintf(`{"uid": "%s","title": "%s"}`, uID, title)
cmd := folder.CreateFolderCommand{
UID: uID,
Title: title,
}
if len(parentUID) > 0 {
cmd.ParentUID = parentUID[0]
}
blob, err := json.Marshal(cmd)
require.NoError(t, err)
payload := string(blob)
u := fmt.Sprintf("%s/api/folders", a.url)
r := strings.NewReader(payload)
// nolint:gosec

View File

@ -21720,6 +21720,7 @@
}
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"

View File

@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import debounce from 'debounce-promise';
import React, { useState, useEffect, useMemo, useCallback, FormEvent } from 'react';
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect } from '@grafana/ui';
import { ActionMeta, AsyncVirtualizedSelect, Input, InputActionMeta, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
@ -54,6 +54,7 @@ export interface Props {
skipInitialLoad?: boolean;
/** The id of the search input. Use this to set a matching label with htmlFor */
inputId?: string;
invalid?: boolean;
}
export type SelectedFolder = SelectableValue<string>;
@ -78,6 +79,7 @@ export function OldFolderPicker(props: Props) {
searchQueryType,
customAdd,
folderWarning,
invalid,
} = props;
const rootName = rootNameProp ?? 'Dashboards';
@ -349,6 +351,7 @@ export function OldFolderPicker(props: Props) {
loadOptions={debouncedSearch}
onChange={onFolderChange}
onCreateOption={createNewFolder}
invalid={invalid}
isClearable={isClearable}
/>
</div>

View File

@ -8,9 +8,10 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import 'whatwg-fetch';
import { RuleWithLocation } from 'app/types/unified-alerting';
import 'whatwg-fetch';
import {
RulerAlertingRuleDTO,
@ -156,7 +157,9 @@ describe('CloneRuleEditor', function () {
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
});
mockSearchApi(server).search([]);
mockSearchApi(server).search([
mockDashboardSearchItem({ title: 'folder-one', uid: '123', type: DashboardSearchItemType.DashDB }),
]);
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, {
@ -209,7 +212,15 @@ describe('CloneRuleEditor', function () {
rules: [originRule],
});
mockSearchApi(server).search([]);
mockSearchApi(server).search([
mockDashboardSearchItem({
title: 'folder-one',
uid: '123',
type: DashboardSearchItemType.DashDB,
folderTitle: 'folder-one',
folderUid: '123',
}),
]);
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
render(
@ -362,3 +373,18 @@ describe('CloneRuleEditor', function () {
});
});
});
function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) {
return {
title: '',
uid: '',
type: DashboardSearchItemType.DashDB,
url: '',
uri: '',
items: [],
tags: [],
slug: '',
isStarred: false,
...searchItem,
};
}

View File

@ -1,4 +1,4 @@
import { render, waitFor, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Route } from 'react-router-dom';
@ -7,7 +7,7 @@ import { ui } from 'test/helpers/alertingRuleEditor';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
@ -101,6 +101,7 @@ describe('RuleEditor grafana managed rules', () => {
title: 'Folder A',
uid: 'abcd',
id: 1,
type: DashboardSearchItemType.DashDB,
};
const slashedFolder = {
@ -136,7 +137,7 @@ describe('RuleEditor grafana managed rules', () => {
[folder.title]: [
{
interval: '1m',
name: 'my great new rule',
name: 'group1',
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },
@ -199,10 +200,10 @@ describe('RuleEditor grafana managed rules', () => {
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'Folder A',
'abcd',
{
interval: '1m',
name: 'my great new rule',
name: 'group1',
rules: [
{
annotations: { description: 'some description', summary: 'some summary', custom: 'value' },

View File

@ -7,7 +7,7 @@ import { byRole } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { AccessControlAction } from 'app/types';
import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto';
@ -16,7 +16,7 @@ import { searchFolders } from '../../../../app/features/manage-dashboards/state/
import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
@ -105,29 +105,67 @@ describe('RuleEditor grafana managed rules', () => {
mocks.api.fetchRulerRules.mockResolvedValue({
'Folder A': [
{
interval: '1m',
name: 'group1',
rules: [],
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
grafana_alert: {
uid: '23',
namespace_uid: 'abcd',
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'my great new rule',
},
},
],
},
],
namespace2: [
{
name: 'group2',
rules: [],
interval: '1m',
name: 'group1',
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
grafana_alert: {
uid: '23',
namespace_uid: 'b',
condition: 'B',
data: getDefaultQueries(),
exec_err_state: GrafanaAlertStateDecision.Error,
no_data_state: GrafanaAlertStateDecision.NoData,
title: 'my great new rule',
},
},
],
},
],
});
mocks.searchFolders.mockResolvedValue([
{
title: 'Folder A',
uid: 'abcd',
id: 1,
type: DashboardSearchItemType.DashDB,
},
{
title: 'Folder B',
id: 2,
uid: 'b',
type: DashboardSearchItemType.DashDB,
},
{
title: 'Folder / with slash',
uid: 'c',
id: 2,
type: DashboardSearchItemType.DashDB,
},
] as DashboardSearchHit[]);
@ -163,7 +201,7 @@ describe('RuleEditor grafana managed rules', () => {
// 9seg
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
'Folder A',
'abcd',
{
interval: '1m',
name: 'group1',

View File

@ -229,10 +229,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({
}),
exportModifiedRuleGroup: build.mutation<
string,
{ payload: ModifyExportPayload; format: ExportFormats; nameSpace: string }
{ payload: ModifyExportPayload; format: ExportFormats; nameSpaceUID: string }
>({
query: ({ payload, format, nameSpace }) => ({
url: `/api/ruler/grafana/api/v1/rules/${nameSpace}/export/`,
query: ({ payload, format, nameSpaceUID }) => ({
url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/export/`,
params: { format: format },
responseType: 'text',
data: payload,

View File

@ -41,8 +41,8 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) {
path: `${rulerPath}/${encodeURIComponent(namespace)}`,
params: Object.fromEntries(rulerSearchParams),
}),
namespaceGroup: (namespace: string, group: string): RulerRequestUrl => ({
path: `${rulerPath}/${encodeURIComponent(namespace)}/${encodeURIComponent(group)}`,
namespaceGroup: (namespaceUID: string, group: string): RulerRequestUrl => ({
path: `${rulerPath}/${encodeURIComponent(namespaceUID)}/${encodeURIComponent(group)}`,
params: Object.fromEntries(rulerSearchParams),
}),
};
@ -51,10 +51,10 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) {
// upsert a rule group. use this to update rule
export async function setRulerRuleGroup(
rulerConfig: RulerDataSourceConfig,
namespace: string,
namespaceIdentifier: string,
group: PostableRulerRuleGroupDTO
): Promise<void> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier);
await lastValueFrom(
getBackendSrv().fetch<unknown>({
method: 'POST',
@ -102,10 +102,10 @@ export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise<
export async function fetchRulerRulesGroup(
rulerConfig: RulerDataSourceConfig,
namespace: string,
namespaceIdentifier: string, // can be the namespace name or namespace UID
group: string
): Promise<RulerRuleGroupDTO | null> {
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespaceIdentifier, group);
return rulerGetRequest<RulerRuleGroupDTO | null>(path, null, params);
}

View File

@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { TestProvider } from '../../../../../../test/helpers/TestProvider';
@ -36,7 +35,6 @@ const ui = {
form: {
nameInput: byRole('textbox', { name: 'name' }),
folder: byTestId('folder-picker'),
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
group: byTestId('group-picker'),
annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
@ -75,7 +73,7 @@ describe('GrafanaModifyExport', () => {
const grafanaRule = getGrafanaRule(undefined, {
uid: 'test-rule-uid',
title: 'cpu-usage',
namespace_uid: 'folder-test-uid',
namespace_uid: 'folderUID1',
data: [
{
refId: 'A',
@ -97,21 +95,23 @@ describe('GrafanaModifyExport', () => {
mockSearchApi(server).search([
mockDashboardSearchItem({
title: grafanaRule.namespace.name,
uid: 'folder-test-uid',
uid: 'folderUID1',
url: '',
tags: [],
type: DashboardSearchItemType.DashFolder,
}),
]);
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
[grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
});
mockAlertRuleApi(server).rulerRuleGroup(
GRAFANA_RULES_SOURCE_NAME,
grafanaRule.namespace.name,
grafanaRule.group.name,
{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }
);
mockExportApi(server).modifiedExport(grafanaRule.namespace.name, {
mockAlertRuleApi(server).rulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, 'folderUID1', grafanaRule.group.name, {
name: grafanaRule.group.name,
interval: '1m',
rules: [grafanaRule.rulerRule!],
});
mockExportApi(server).modifiedExport('folderUID1', {
yaml: 'Yaml Export Content',
json: 'Json Export Content',
});
const user = userEvent.setup();
@ -127,6 +127,7 @@ describe('GrafanaModifyExport', () => {
expect(drawer).toBeInTheDocument();
expect(ui.exportDrawer.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(ui.exportDrawer.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
});

View File

@ -1,6 +1,17 @@
import { DataFrame, FieldType, toDataFrame } from '@grafana/data';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { getSeriesName, formatLabels, getSeriesValue, isEmptySeries, getSeriesLabels } from './util';
import { mockDataSource } from '../../mocks';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import {
decodeGrafanaNamespace,
formatLabels,
getSeriesLabels,
getSeriesName,
getSeriesValue,
isEmptySeries,
} from './util';
const EMPTY_FRAME: DataFrame = toDataFrame([]);
const NAMED_FRAME: DataFrame = {
@ -34,6 +45,103 @@ describe('formatLabels', () => {
});
});
describe('decodeGrafanaNamespace', () => {
it('should work for regular Grafana namespaces', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: `my_rule_namespace`,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'group1',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
});
it('should work for Grafana namespaces in nested folders format', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: `["parentUID","my_rule_namespace"]`,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'group1',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
});
it('should default to name if format is invalid: invalid JSON', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: `["parentUID"`,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'group1',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`["parentUID"`);
});
it('should default to name if format is invalid: empty array', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: `[]`,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'group1',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`[]`);
});
it('grab folder name if format is long array', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: `["parentUID","my_rule_namespace","another_part"]`,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'group1',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('another_part');
});
it('should not change output for cloud namespaces', () => {
const cloudNamespace: CombinedRuleNamespace = {
name: `["parentUID","my_rule_namespace"]`,
rulesSource: mockDataSource(),
groups: [
{
name: 'Prom group',
rules: [],
totals: {},
},
],
};
expect(decodeGrafanaNamespace(cloudNamespace)).toBe(`["parentUID","my_rule_namespace"]`);
});
});
describe('isEmptySeries', () => {
it('should be true for empty series', () => {
expect(isEmptySeries([EMPTY_FRAME])).toBe(true);

View File

@ -1,4 +1,7 @@
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { isCloudRulesSource } from '../../utils/datasource';
/**
* `frame.fields` could be an empty array
@ -37,10 +40,29 @@ const formatLabels = (labels: Labels): string => {
.join(', ');
};
/**
* After https://github.com/grafana/grafana/pull/74600,
* Grafana folder names will be returned from the API as a combination of the folder name and parent UID in a format of JSON array,
* where first element is parent UID and the second element is Title.
*/
const decodeGrafanaNamespace = (namespace: CombinedRuleNamespace): string => {
const namespaceName = namespace.name;
if (isCloudRulesSource(namespace.rulesSource)) {
return namespaceName;
}
try {
return JSON.parse(namespaceName).at(-1) ?? namespaceName;
} catch {
return namespaceName;
}
};
const isEmptySeries = (series: DataFrame[]): boolean => {
const isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null)));
return isEmpty;
};
export { getSeriesName, getSeriesValue, getSeriesLabels, formatLabels, isEmptySeries };
export { decodeGrafanaNamespace, formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries };

View File

@ -39,7 +39,7 @@ import { checkForPathSeparator } from './util';
export const MAX_GROUP_RESULTS = 1000;
export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGroups: boolean) => {
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => {
const dispatch = useDispatch();
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
@ -52,7 +52,7 @@ export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGrou
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const folderGroups = grafanaFolders.find((f) => f.name === folderTitle)?.groups ?? [];
const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? [];
const groupOptions = folderGroups
.map<SelectableValue<string>>((group) => {
@ -105,7 +105,7 @@ export function FolderAndGroup({
const folder = watch('folder');
const group = watch('group');
const { groupOptions, loading } = useFolderGroupOptions(folder?.title ?? '', enableProvisionedGroups);
const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
@ -146,55 +146,62 @@ export function FolderAndGroup({
return (
<div className={styles.container}>
<Stack alignItems="center">
<Field
label={
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
Folder
</Label>
}
className={styles.formInput}
error={errors.folder?.message}
invalid={!!errors.folder?.message}
data-testid="folder-picker"
>
{(!isCreatingFolder && (
<InputControl
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<RuleFolderPicker
inputId="folder"
{...field}
enableReset={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
resetGroup();
{
<Field
label={
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
Folder
</Label>
}
className={styles.formInput}
error={errors.folder?.message}
data-testid="folder-picker"
>
<Stack direction="row" alignItems="center">
{(!isCreatingFolder && (
<>
<InputControl
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<RuleFolderPicker
inputId="folder"
invalid={!!errors.folder?.message}
{...field}
enableReset={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
resetGroup();
}}
/>
</div>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.uid),
},
}}
/>
</div>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}}
/>
)) || <div>Creating new folder...</div>}
</Field>
<Box marginTop={2.5} gap={1} display={'flex'} alignItems={'center'}>
<Text color="secondary">or</Text>
<Button
onClick={onOpenFolderCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
>
New folder
</Button>
</Box>
<Text color="secondary">or</Text>
<Button
onClick={onOpenFolderCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
>
New folder
</Button>
</>
)) || <div>Creating new folder...</div>}
</Stack>
</Field>
}
{isCreatingFolder && (
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
)}
</Stack>
{isCreatingFolder && (

View File

@ -92,16 +92,16 @@ function FolderGroupAndEvaluationInterval({
const { watch, setValue, getValues } = useFormContext<RuleFormValues>();
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupName, folderName] = watch(['group', 'folder.title']);
const [groupName, folderUid, folderName] = watch(['group', 'folder.uid', 'folder.title']);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const existingNamespace = grafanaNamespaces.find((ns) => ns.name === folderName);
const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid);
const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName);
const isNewGroup = useIsNewGroup(folderName ?? '', groupName);
const isNewGroup = useIsNewGroup(folderUid ?? '', groupName);
useEffect(() => {
if (!isNewGroup && existingGroup?.interval) {
@ -118,7 +118,7 @@ function FolderGroupAndEvaluationInterval({
const onOpenEditGroupModal = () => setIsEditingGroup(true);
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderName || !groupName;
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName;
const emptyNamespace: CombinedRuleNamespace = {
name: folderName,

View File

@ -2,11 +2,11 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { OldFolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/OldFolderPicker';
import { Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { Props as FolderPickerProps, OldFolderPicker } from 'app/core/components/Select/OldFolderPicker';
import { PermissionLevelString, SearchQueryType } from 'app/types';
import { FolderWarning, CustomAdd } from '../../../../../core/components/Select/OldFolderPicker';
import { CustomAdd, FolderWarning } from '../../../../../core/components/Select/OldFolderPicker';
export interface Folder {
title: string;
@ -15,6 +15,7 @@ export interface Folder {
export interface RuleFolderPickerProps extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> {
value?: Folder;
invalid?: boolean;
}
const SlashesWarning = () => {
@ -51,7 +52,6 @@ export function RuleFolderPicker(props: RuleFolderPickerProps) {
showRoot={false}
rootName=""
allowEmpty={true}
initialTitle={value?.title}
initialFolderUid={value?.uid}
searchQueryType={SearchQueryType.AlertFolder}
{...props}

View File

@ -111,14 +111,14 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
);
}
const useGetGroup = (nameSpace: string, group: string) => {
const useGetGroup = (nameSpaceUID: string, group: string) => {
const { dsFeatures } = useDataSourceFeatures(GRAFANA_RULES_SOURCE_NAME);
const rulerConfig = dsFeatures?.rulerConfig;
const targetGroup = useAsync(async () => {
return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpace, group) : undefined;
}, [rulerConfig, nameSpace, group]);
return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpaceUID, group) : undefined;
}, [rulerConfig, nameSpaceUID, group]);
return targetGroup;
};
@ -166,7 +166,7 @@ export const getPayloadToExport = (
};
const useGetPayloadToExport = (values: RuleFormValues, uid: string) => {
const rulerGroupDto = useGetGroup(values.folder?.title ?? '', values.group);
const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group);
const payload: ModifyExportPayload = useMemo(() => {
return getPayloadToExport(uid, values, rulerGroupDto?.value);
}, [uid, rulerGroupDto, values]);
@ -182,11 +182,11 @@ const GrafanaRuleDesignExportPreview = ({
const [getExport, exportData] = alertRuleApi.endpoints.exportModifiedRuleGroup.useMutation();
const { loadingGroup, payload } = useGetPayloadToExport(exportValues, uid);
const nameSpace = exportValues.folder?.title ?? '';
const nameSpaceUID = exportValues.folder?.uid ?? '';
useEffect(() => {
!loadingGroup && getExport({ payload, format: exportFormat, nameSpace: nameSpace });
}, [nameSpace, exportFormat, payload, getExport, loadingGroup]);
!loadingGroup && getExport({ payload, format: exportFormat, nameSpaceUID });
}, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]);
if (exportData.isLoading) {
return <LoadingPlaceholder text="Loading...." />;

View File

@ -28,6 +28,7 @@ import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { decodeGrafanaNamespace } from '../expressions/util';
import { RuleViewerLayout } from '../rule-viewer/RuleViewerLayout';
import { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons';
import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations';
@ -153,7 +154,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
{rule.namespace.name} / {rule.group.name}
{decodeGrafanaNamespace(rule.namespace)} / {rule.group.name}
</DetailsField>
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
</div>

View File

@ -29,6 +29,7 @@ import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import MoreButton from '../../MoreButton';
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
import { decodeGrafanaNamespace } from '../../expressions/util';
import { Details } from '../tabs/Details';
import { History } from '../tabs/History';
import { InstancesList } from '../tabs/Instances';
@ -336,6 +337,9 @@ function usePageNav(rule: CombinedRule) {
const isAlertType = isAlertingRule(promRule);
const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined;
const namespaceName = decodeGrafanaNamespace(rule.namespace);
const groupName = rule.group.name;
const pageNav: NavModelItem = {
...defaultPageNav,
text: rule.name,
@ -372,14 +376,14 @@ function usePageNav(rule: CombinedRule) {
},
],
parentItem: {
text: rule.group.name,
text: groupName,
url: createListFilterLink([
['namespace', rule.namespace.name],
['group', rule.group.name],
['namespace', namespaceName],
['group', groupName],
]),
parentItem: {
text: rule.namespace.name,
url: createListFilterLink([['namespace', rule.namespace.name]]),
text: namespaceName,
url: createListFilterLink([['namespace', namespaceName]]),
},
},
};

View File

@ -158,11 +158,12 @@ export interface ModalProps {
onClose: (saved?: boolean) => void;
intervalEditOnly?: boolean;
folderUrl?: string;
folderUid?: string;
hideFolder?: boolean;
}
export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const { namespace, group, onClose, intervalEditOnly } = props;
const { namespace, group, onClose, intervalEditOnly, folderUid } = props;
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
@ -201,6 +202,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
namespaceName: namespace.name,
newNamespaceName: values.namespaceName,
groupInterval: values.groupInterval || undefined,
folderUid,
})
);
};

View File

@ -27,12 +27,13 @@ interface ModalProps {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
onClose: () => void;
folderUid?: string;
}
type CombinedRuleWithUID = { uid: string } & CombinedRule;
export const ReorderCloudGroupModal = (props: ModalProps) => {
const { group, namespace, onClose } = props;
const { group, namespace, onClose, folderUid } = props;
const [pending, setPending] = useState<boolean>(false);
const [rulesList, setRulesList] = useState<CombinedRule[]>(group.rules);
@ -63,6 +64,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
groupName: group.name,
rulesSourceName: rulesSourceName,
newRules: rulerRules,
folderUid: folderUid || namespace.name,
})
)
.unwrap()
@ -70,7 +72,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
setPending(false);
});
},
[group.name, namespace.name, namespace.rulesSource, rulesList]
[group.name, namespace.name, namespace.rulesSource, rulesList, folderUid]
);
// assign unique but stable identifiers to each (alerting / recording) rule

View File

@ -3,11 +3,11 @@ import pluralize from 'pluralize';
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics';
import { LogMessages, logInfo } from '../../Analytics';
import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
@ -19,6 +19,7 @@ import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter';
import { decodeGrafanaNamespace } from '../expressions/util';
import { ActionIcon } from './ActionIcon';
import { EditCloudGroupModal } from './EditRuleGroupModal';
@ -204,9 +205,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
// ungrouped rules are rules that are in the "default" group name
const groupName = isListView ? (
<RuleLocation namespace={namespace.name} />
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} />
) : (
<RuleLocation namespace={namespace.name} group={group.name} />
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} group={group.name} />
);
const closeEditModal = (saved = false) => {
@ -278,10 +279,16 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
group={group}
onClose={() => closeEditModal()}
folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder) : undefined}
folderUid={folderUID}
/>
)}
{isReorderingGroup && (
<ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} />
<ReorderCloudGroupModal
group={group}
folderUid={folderUID}
namespace={namespace}
onClose={() => setIsReorderingGroup(false)}
/>
)}
<ConfirmModal
isOpen={isDeletingGroup}

View File

@ -91,6 +91,13 @@ export function useCombinedRuleNamespaces(
name: namespaceName,
groups: [],
};
// We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups
// All rules from all groups have the same namespace_uid so we're taking the first one.
if (isGrafanaRulerRule(groups[0].rules[0])) {
namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid;
}
namespaces[namespaceName] = namespace;
addRulerGroupsToCombinedNamespace(namespace, groups);
});

View File

@ -377,9 +377,9 @@ export function mockExportApi(server: SetupServer) {
})
);
},
modifiedExport: (namespace: string, response: Record<string, string>) => {
modifiedExport: (namespaceUID: string, response: Record<string, string>) => {
server.use(
rest.post(`/api/ruler/grafana/api/v1/rules/${namespace}/export`, (req, res, ctx) => {
rest.post(`/api/ruler/grafana/api/v1/rules/${namespaceUID}/export`, (req, res, ctx) => {
return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
})
);

View File

@ -745,6 +745,7 @@ interface UpdateNamespaceAndGroupOptions {
newNamespaceName: string;
newGroupName: string;
groupInterval?: string;
folderUid?: string;
}
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
@ -768,13 +769,22 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
return withAppEvents(
withSerializedError(
(async () => {
const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options;
const {
rulesSourceName,
namespaceName,
groupName,
newNamespaceName,
newGroupName,
groupInterval,
folderUid,
} = options;
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
// fetch rules and perform sanity checks
const rulesResult = await fetchRulerRules(rulerConfig);
const existingNamespace = Boolean(rulesResult[namespaceName]);
if (!existingNamespace) {
throw new Error(`Namespace "${namespaceName}" not found.`);
}
@ -834,19 +844,19 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
: group
);
}
await deleteNamespace(rulerConfig, namespaceName);
await deleteNamespace(rulerConfig, folderUid || namespaceName);
// if only modifying group...
} else {
// save updated group
await setRulerRuleGroup(rulerConfig, namespaceName, {
await setRulerRuleGroup(rulerConfig, folderUid || namespaceName, {
...existingGroup,
name: newGroupName,
interval: groupInterval,
});
// if group name was changed, delete old group
if (newGroupName !== groupName) {
await deleteRulerRulesGroup(rulerConfig, namespaceName, groupName);
await deleteRulerRulesGroup(rulerConfig, folderUid || namespaceName, groupName);
}
}
@ -867,6 +877,7 @@ interface UpdateRulesOrderOptions {
namespaceName: string;
groupName: string;
newRules: RulerRuleDTO[];
folderUid: string;
}
export const updateRulesOrder = createAsyncThunk(
@ -875,7 +886,7 @@ export const updateRulesOrder = createAsyncThunk(
return withAppEvents(
withSerializedError(
(async () => {
const { rulesSourceName, namespaceName, groupName, newRules } = options;
const { rulesSourceName, namespaceName, groupName, newRules, folderUid } = options;
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
const rulesResult = await fetchRulerRules(rulerConfig);
@ -891,7 +902,7 @@ export const updateRulesOrder = createAsyncThunk(
rules: newRules,
};
await setRulerRuleGroup(rulerConfig, namespaceName, payload);
await setRulerRuleGroup(rulerConfig, folderUid ?? namespaceName, payload);
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName }));
})()

View File

@ -6,7 +6,7 @@ import {
RulerRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { deleteRulerRulesGroup, fetchRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, setRulerRuleGroup } from '../api/ruler';
import { RuleFormValues } from '../types/rule-form';
import * as ruleId from '../utils/rule-id';
@ -41,6 +41,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
group,
ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
namespace: namespace,
namespace_uid: (isGrafanaRulerRule(rule) && rule.grafana_alert.namespace_uid) || undefined,
rule,
};
}
@ -81,15 +82,15 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
};
const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise<void> => {
const { namespace, group, rule } = ruleWithLocation;
const { namespace, group, rule, namespace_uid } = ruleWithLocation;
// it was the last rule, delete the entire group
if (group.rules.length === 1) {
await deleteRulerRulesGroup(rulerConfig, namespace, group.name);
await deleteRulerRulesGroup(rulerConfig, namespace_uid || namespace, group.name);
return;
}
// post the group with rule removed
await setRulerRuleGroup(rulerConfig, namespace, {
await setRulerRuleGroup(rulerConfig, namespace_uid || namespace, {
...group,
rules: group.rules.filter((r) => r !== rule),
});
@ -159,11 +160,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
}
const newRule = formValuesToRulerGrafanaRuleDTO(values);
const namespace = folder.title;
const namespaceUID = folder.uid;
const groupSpec = { name: group, interval: evaluateEvery };
if (!existingRule) {
return addRuleToNamespaceAndGroup(namespace, groupSpec, newRule);
return addRuleToNamespaceAndGroup(namespaceUID, groupSpec, newRule);
}
// we'll fetch the existing group again, someone might have updated it while we were editing a rule
@ -172,7 +173,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
throw new Error('Rule not found.');
}
const sameNamespace = freshExisting.namespace === namespace;
const sameNamespace = freshExisting.namespace_uid === namespaceUID;
const sameGroup = freshExisting.group.name === values.group;
const sameLocation = sameNamespace && sameGroup;
@ -181,16 +182,16 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
return updateGrafanaRule(freshExisting, newRule, evaluateEvery);
} else {
// we're moving a rule to either a different group or namespace
return moveGrafanaRule(namespace, groupSpec, freshExisting, newRule);
return moveGrafanaRule(namespaceUID, groupSpec, freshExisting, newRule);
}
};
const addRuleToNamespaceAndGroup = async (
namespace: string,
namespaceUID: string,
group: { name: string; interval: string },
newRule: PostableRuleGrafanaRuleDTO
): Promise<RuleIdentifier> => {
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group.name);
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name);
if (!existingGroup) {
throw new Error(`No group found with name "${group.name}"`);
}
@ -201,7 +202,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
rules: (existingGroup.rules ?? []).concat(newRule as RulerGrafanaRuleDTO),
};
await setRulerRuleGroup(rulerConfig, namespace, payload);
await setRulerRuleGroup(rulerConfig, namespaceUID, payload);
return { uid: newRule.grafana_alert.uid ?? '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
};
@ -242,7 +243,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
return rule;
});
await setRulerRuleGroup(rulerConfig, existingRule.namespace, {
await setRulerRuleGroup(rulerConfig, existingRule.namespace_uid ?? '', {
name: existingRule.group.name,
interval: interval,
rules: newRules,

View File

@ -128,6 +128,7 @@ export interface CombinedRuleNamespace {
rulesSource: RulesSource;
name: string;
groups: CombinedRuleGroup[];
uid?: string; //available only in grafana rules
}
export interface RuleWithLocation<T = RulerRuleDTO> {
@ -135,6 +136,7 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
namespace: string;
group: RulerRuleGroupDTO;
rule: T;
namespace_uid?: string;
}
export interface CombinedRuleWithLocation extends CombinedRule {

View File

@ -12232,6 +12232,7 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/components/schemas/gettableSilence"
},