Alerting: Update API to use folders' full paths (#81214)

* update GetUserVisibleNamespaces to use FolderSeriver
* update GetNamespaceByUID to use FolderService.GetFolders
* update GetAlertRulesForScheduling to use FolderService.GetFolders 

* Update API and GetAlertRulesForScheduling to use the folder's full path
* get full path of folder in RouteTestGrafanaRuleConfig

* fix escaping of titles for MySQL
This commit is contained in:
Yuri Tseretyan 2024-02-06 17:12:13 -05:00 committed by GitHub
parent 6f8852095e
commit 47546a4c72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 217 additions and 222 deletions

View File

@ -530,10 +530,14 @@ func (ss *sqlStore) GetDescendants(ctx context.Context, orgID int64, ancestor_ui
}
func getFullpathSQL(dialect migrator.Dialect) string {
escaped := "\\/"
if dialect.DriverName() == migrator.MySQL {
escaped = "\\\\/"
}
concatCols := make([]string, 0, folder.MaxNestedFolderDepth)
concatCols = append(concatCols, "COALESCE(REPLACE(f0.title, '/', '\\/'), '')")
concatCols = append(concatCols, fmt.Sprintf("COALESCE(REPLACE(f0.title, '/', '%s'), '')", escaped))
for i := 1; i <= folder.MaxNestedFolderDepth; i++ {
concatCols = append([]string{fmt.Sprintf("COALESCE(REPLACE(f%d.title, '/', '\\/'), '')", i), "'/'"}, concatCols...)
concatCols = append([]string{fmt.Sprintf("COALESCE(REPLACE(f%d.title, '/', '%s'), '')", i, escaped), "'/'"}, concatCols...)
}
return dialect.Concat(concatCols...)
}

View File

@ -129,6 +129,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
featureManager: api.FeatureManager,
appUrl: api.AppUrl,
tracer: api.Tracer,
folderService: api.RuleStore,
}), m)
api.RegisterConfigurationApiEndpoints(NewConfiguration(
&ConfigSrv{

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: ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title),
File: folder.Fullpath,
}
rulesTotals := make(map[string]int64, len(rules))

View File

@ -162,9 +162,7 @@ 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[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
}
return response.JSON(http.StatusAccepted, result)
@ -242,9 +240,7 @@ 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
}
key := ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title)
// nolint:staticcheck
result[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
}
return response.JSON(http.StatusOK, result)
}

View File

@ -200,7 +200,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
require.NoError(t, json.Unmarshal(response.Body(), result))
require.NotNil(t, result)
for namespace, groups := range *result {
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
require.Equal(t, folder.Fullpath, namespace)
for _, group := range groups {
grouploop:
for _, actualRule := range group.Rules {
@ -243,7 +243,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
require.NotNil(t, result)
found := false
for namespace, groups := range *result {
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
require.Equal(t, folder.Fullpath, namespace)
for _, group := range groups {
for _, actualRule := range group.Rules {
if actualRule.GrafanaManagedAlert.UID == expectedRules[0].UID {
@ -278,7 +278,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
models.RulesGroup(expectedRules).SortByGroupIndex()
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
groups, ok := (*result)[folder.Fullpath]
require.True(t, ok)
require.Len(t, groups, 1)
group := groups[0]
@ -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, models.GetNamespaceKey(folder1.ParentUID, folder1.Title))
require.Contains(t, *result, folder1.Fullpath)
require.NotContains(t, *result, folder2.UID)
groups := (*result)[models.GetNamespaceKey(folder1.ParentUID, folder1.Title)]
groups := (*result)[folder1.Fullpath]
require.Len(t, groups, 1)
require.Equal(t, group1Key.RuleGroup, groups[0].Name)
require.Len(t, groups[0].Rules, len(group1))
@ -361,7 +361,7 @@ func TestRouteGetRulesConfig(t *testing.T) {
models.RulesGroup(expectedRules).SortByGroupIndex()
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
groups, ok := (*result)[folder.Fullpath]
require.True(t, ok)
require.Len(t, groups, 1)
group := groups[0]

View File

@ -2,6 +2,7 @@ package api
import (
"fmt"
"path"
"strconv"
"testing"
"time"
@ -84,9 +85,10 @@ func validGroup(cfg *setting.UnifiedAlertingSettings, rules ...apimodels.Postabl
}
func randFolder() *folder.Folder {
title := "TEST-FOLDER-" + util.GenerateShortUID()
return &folder.Folder{
UID: util.GenerateShortUID(),
Title: "TEST-FOLDER-" + util.GenerateShortUID(),
Title: title,
// URL: "",
// Version: 0,
Created: time.Time{},
@ -94,6 +96,8 @@ func randFolder() *folder.Folder {
// UpdatedBy: 0,
// CreatedBy: 0,
// HasACL: false,
ParentUID: uuid.NewString(),
Fullpath: path.Join("parent-folder", title),
}
}

View File

@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"net/http"
"net/url"
@ -18,7 +19,9 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@ -32,6 +35,10 @@ import (
"github.com/grafana/grafana/pkg/util"
)
type folderService interface {
GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error)
}
type TestingApiSrv struct {
*AlertingProxy
DatasourceCache datasources.CacheService
@ -43,22 +50,23 @@ type TestingApiSrv struct {
featureManager featuremgmt.FeatureToggles
appUrl *url.URL
tracer tracing.Tracer
folderService folderService
}
// RouteTestGrafanaRuleConfig returns a list of potential alerts for a given rule configuration. This is intended to be
// as true as possible to what would be generated by the ruler except that the resulting alerts are not filtered to
// only Resolved / Firing and ready to send.
func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext, body apimodels.PostableExtendedRuleNodeExtended) response.Response {
folder, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), body.NamespaceUID, c.OrgID, c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(dashboards.ErrFolderAccessDenied)
}
rule, err := validateRuleNode(
&body.Rule,
body.RuleGroup,
srv.cfg.BaseInterval,
c.SignedInUser.GetOrgID(),
&folder.Folder{
OrgID: c.SignedInUser.GetOrgID(),
UID: body.NamespaceUID,
Title: body.NamespaceTitle,
},
folder,
srv.cfg,
)
if err != nil {
@ -103,8 +111,7 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext,
now,
rule,
results,
// TODO remove when switched to full path https://github.com/grafana/grafana/issues/80324
state.GetRuleExtraLabels(rule, ngmodels.GetNamespaceKey("", body.NamespaceTitle), includeFolder),
state.GetRuleExtraLabels(rule, folder.Fullpath, includeFolder),
)
alerts := make([]*amv2.PostableAlert, 0, len(transitions))

View File

@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/mock"
@ -15,14 +16,17 @@ import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
fakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks"
"github.com/grafana/grafana/pkg/services/ngalert/models"
fakes2 "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
)
@ -139,6 +143,36 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
},
}
t.Run("should return Forbidden if user cannot access folder", func(t *testing.T) {
ac := acMock.New().WithPermissions([]ac.Permission{
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceAllScope()},
})
ruleStore := fakes2.NewRuleStore(t)
ruleStore.Hook = func(cmd any) error {
q, ok := cmd.(fakes2.GenericRecordedQuery)
if !ok {
return nil
}
if q.Name == "GetNamespaceByUID" {
return dashboards.ErrFolderAccessDenied
}
return nil
}
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}), &featuremgmt.FeatureManager{}, ruleStore)
rule := validRule()
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: uuid.NewString(),
NamespaceTitle: "test-folder",
})
require.Equal(t, http.StatusForbidden, response.Status())
})
t.Run("should return Forbidden if user cannot query a data source", func(t *testing.T) {
data1 := models.GenerateAlertQuery()
data2 := models.GenerateAlertQuery()
@ -147,15 +181,18 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
{Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)},
})
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}), &featuremgmt.FeatureManager{})
f := randFolder()
ruleStore := fakes2.NewRuleStore(t)
ruleStore.Folders[rc.OrgID] = []*folder.Folder{f}
srv := createTestingApiSrv(t, nil, ac, eval_mocks.NewEvaluatorFactory(&eval_mocks.ConditionEvaluatorMock{}), &featuremgmt.FeatureManager{}, ruleStore)
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
rule.GrafanaManagedAlert.Condition = data2.RefID
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
NamespaceUID: f.UID,
NamespaceTitle: f.Title,
})
require.Equal(t, http.StatusForbidden, response.Status())
@ -181,15 +218,19 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) {
evalFactory := eval_mocks.NewEvaluatorFactory(evaluator)
srv := createTestingApiSrv(t, ds, ac, evalFactory, &featuremgmt.FeatureManager{})
f := randFolder()
ruleStore := fakes2.NewRuleStore(t)
ruleStore.Folders[rc.OrgID] = []*folder.Folder{f}
srv := createTestingApiSrv(t, ds, ac, evalFactory, &featuremgmt.FeatureManager{}, ruleStore)
rule := validRule()
rule.GrafanaManagedAlert.Data = ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2})
rule.GrafanaManagedAlert.Condition = data2.RefID
response := srv.RouteTestGrafanaRuleConfig(rc, definitions.PostableExtendedRuleNodeExtended{
Rule: rule,
NamespaceUID: "test-folder",
NamespaceTitle: "test-folder",
NamespaceUID: f.UID,
NamespaceTitle: f.Title,
})
require.Equal(t, http.StatusOK, response.Status())
@ -256,7 +297,9 @@ func TestRouteEvalQueries(t *testing.T) {
}
evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil)
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), &featuremgmt.FeatureManager{})
ruleStore := fakes2.NewRuleStore(t)
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), &featuremgmt.FeatureManager{}, ruleStore)
response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
Data: ApiAlertQueriesFromAlertQueries([]models.AlertQuery{data1, data2}),
@ -316,7 +359,9 @@ func TestRouteEvalQueries(t *testing.T) {
}
evaluator.EXPECT().EvaluateRaw(mock.Anything, mock.Anything).Return(result, nil)
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), featuremgmt.WithManager(featuremgmt.FlagAlertingQueryOptimization))
ruleStore := fakes2.NewRuleStore(t)
srv := createTestingApiSrv(t, ds, ac, eval_mocks.NewEvaluatorFactory(evaluator), featuremgmt.WithManager(featuremgmt.FlagAlertingQueryOptimization), ruleStore)
response := srv.RouteEvalQueries(rc, definitions.EvalQueriesPayload{
Data: ApiAlertQueriesFromAlertQueries(queries),
@ -342,7 +387,7 @@ func TestRouteEvalQueries(t *testing.T) {
})
}
func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory, featureManager *featuremgmt.FeatureManager) *TestingApiSrv {
func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mock, evaluator eval.EvaluatorFactory, featureManager *featuremgmt.FeatureManager, ruleStore RuleStore) *TestingApiSrv {
if ac == nil {
ac = acMock.New()
}
@ -354,5 +399,6 @@ func createTestingApiSrv(t *testing.T, ds *fakes.FakeCacheService, ac *acMock.Mo
cfg: config(t),
tracer: tracing.InitializeTracerForTest(),
featureManager: featureManager,
folderService: ruleStore,
}
}

View File

@ -7,7 +7,6 @@ import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/google/go-cmp/cmp"
@ -705,29 +704,3 @@ 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,66 +729,3 @@ 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,8 +512,7 @@ func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolde
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
if includeFolder {
// TODO remove when title will contain the full path https://github.com/grafana/grafana/issues/80324
extraLabels[models.FolderTitleLabel] = models.GetNamespaceTitleFromKey(folderTitle)
extraLabels[models.FolderTitleLabel] = folderTitle
}
return extraLabels
}

View File

@ -7,18 +7,17 @@ import (
"strings"
"github.com/google/uuid"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/util"
)
@ -434,58 +433,34 @@ func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespa
})
}
// GetUserVisibleNamespaces returns the folders that are visible to the user and have at least one alert in it
// GetUserVisibleNamespaces returns the folders that are visible to the user
func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user identity.Requester) (map[string]*folder.Folder, error) {
namespaceMap := make(map[string]*folder.Folder)
searchQuery := dashboards.FindPersistedDashboardsQuery{
OrgId: orgID,
folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{
OrgID: orgID,
WithFullpath: true,
SignedInUser: user,
Type: searchstore.TypeAlertFolder,
Limit: -1,
Permission: dashboardaccess.PERMISSION_VIEW,
Sort: model.SortOption{},
Filters: []any{
searchstore.FolderWithAlertsFilter{},
},
})
if err != nil {
return nil, err
}
var page int64 = 1
for {
query := searchQuery
query.Page = page
proj, err := st.DashboardService.FindDashboards(ctx, &query)
if err != nil {
return nil, err
}
if len(proj) == 0 {
break
}
for _, hit := range proj {
if !hit.IsFolder {
continue
}
namespaceMap[hit.UID] = &folder.Folder{
UID: hit.UID,
Title: hit.Title,
ParentUID: hit.FolderUID,
}
}
page += 1
namespaceMap := make(map[string]*folder.Folder)
for _, f := range folders {
namespaceMap[f.UID] = f
}
return namespaceMap, 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})
f, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{OrgID: orgID, UIDs: []string{uid}, WithFullpath: true, SignedInUser: user})
if err != nil {
return nil, err
}
return folder, nil
if len(f) == 0 {
return nil, dashboards.ErrFolderAccessDenied
}
return f[0], nil
}
func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodels.AlertRuleKeyWithVersion, error) {
@ -513,12 +488,6 @@ 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 {
OrgId int64
Uid string
Title string
ParentUid string
}
var rules []*ngmodels.AlertRule
return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
var disabledOrgs []int64
@ -566,22 +535,36 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
query.ResultRules = rules
if query.PopulateFolders {
foldersSql := sess.Table("folder").Alias("d").Select("d.org_id, 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)
}
if err := foldersSql.Find(&folders); err != nil {
return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err)
}
query.ResultFoldersTitles = make(map[ngmodels.FolderKey]string, len(folders))
for _, f := range folders {
key := ngmodels.FolderKey{
OrgID: f.OrgId,
UID: f.Uid,
query.ResultFoldersTitles = map[ngmodels.FolderKey]string{}
uids := map[int64]map[string]struct{}{}
for _, r := range rules {
om, ok := uids[r.OrgID]
if !ok {
om = make(map[string]struct{})
uids[r.OrgID] = om
}
om[r.NamespaceUID] = struct{}{}
}
for orgID, uids := range uids {
schedulerUser := accesscontrol.BackgroundUser("grafana_scheduler", orgID, org.RoleAdmin,
[]accesscontrol.Permission{
{
Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll,
},
})
folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{
OrgID: orgID,
UIDs: maps.Keys(uids),
WithFullpath: true,
SignedInUser: schedulerUser,
})
if err != nil {
return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err)
}
for _, f := range folders {
query.ResultFoldersTitles[ngmodels.FolderKey{OrgID: f.OrgID, UID: f.UID}] = f.Fullpath
}
query.ResultFoldersTitles[key] = ngmodels.GetNamespaceKey(f.ParentUid, f.Title)
}
}
return nil

View File

@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@ -44,7 +45,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
store := &DBstore{
SQLStore: sqlStore,
Cfg: cfg.UnifiedAlerting,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: &logtest.Fake{},
}
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID())
@ -98,7 +99,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
store := &DBstore{
SQLStore: sqlStore,
Cfg: cfg.UnifiedAlerting,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: &logtest.Fake{},
}
@ -332,7 +333,7 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
store := &DBstore{
SQLStore: sqlStore,
Cfg: cfg.UnifiedAlerting,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
FeatureToggles: featuremgmt.WithFeatures(),
}
@ -341,9 +342,12 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
rule2 := createRule(t, store, generator)
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, "")
parentFolderTitle := "Very Parent Folder"
createFolder(t, store, parentFolderUid, parentFolderTitle, rule1.OrgID, "")
rule1FolderTitle := "folder-" + rule1.Title
rule2FolderTitle := "folder-" + rule2.Title
createFolder(t, store, rule1.NamespaceUID, rule1FolderTitle, rule1.OrgID, parentFolderUid)
createFolder(t, store, rule2.NamespaceUID, rule2FolderTitle, rule2.OrgID, "")
createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID, "") // create a folder with the same UID but in the different org
@ -353,6 +357,7 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
ruleGroups []string
disabledOrgs []int64
folders map[models.FolderKey]string
flags []string
}{
{
name: "without a rule group filter, it returns all created rules",
@ -371,13 +376,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
{
name: "with populate folders enabled, it returns them",
rules: []string{rule1.Title, rule2.Title},
folders: map[models.FolderKey]string{rule1.GetFolderKey(): models.GetNamespaceKey(parentFolderUid, rule1.Title), rule2.GetFolderKey(): rule2.Title},
folders: map[models.FolderKey]string{rule1.GetFolderKey(): rule1FolderTitle, rule2.GetFolderKey(): rule2FolderTitle},
},
{
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[models.FolderKey]string{rule1.GetFolderKey(): models.GetNamespaceKey(parentFolderUid, rule1.Title)},
folders: map[models.FolderKey]string{rule1.GetFolderKey(): rule1FolderTitle},
},
}
@ -414,6 +419,20 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
}
})
}
t.Run("when nested folders are enabled folders should contain full path", func(t *testing.T) {
store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
query := &models.GetAlertRulesForSchedulingQuery{
PopulateFolders: true,
}
require.NoError(t, store.GetAlertRulesForScheduling(context.Background(), query))
expected := map[models.FolderKey]string{
rule1.GetFolderKey(): parentFolderTitle + "/" + rule1FolderTitle,
rule2.GetFolderKey(): rule2FolderTitle,
}
require.Equal(t, expected, query.ResultFoldersTitles)
})
}
func withIntervalMatching(baseInterval time.Duration) func(*models.AlertRule) {
@ -430,7 +449,7 @@ func TestIntegration_CountAlertRules(t *testing.T) {
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg)}
store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())}
rule := createRule(t, store, nil)
tests := map[string]struct {
@ -479,7 +498,7 @@ func TestIntegration_DeleteInFolder(t *testing.T) {
cfg := setting.NewCfg()
store := &DBstore{
SQLStore: sqlStore,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: log.New("test-dbstore"),
}
rule := createRule(t, store, nil)
@ -512,7 +531,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) {
cfg := setting.NewCfg()
store := &DBstore{
SQLStore: sqlStore,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: log.New("test-dbstore"),
}
@ -524,13 +543,36 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) {
}
uid := uuid.NewString()
title := "folder-title"
createFolder(t, store, uid, title, 1, "")
parentUid := uuid.NewString()
title := "folder/title"
parentTitle := "parent-title"
createFolder(t, store, parentUid, parentTitle, 1, "")
createFolder(t, store, uid, title, 1, parentUid)
actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u)
require.NoError(t, err)
require.Equal(t, title, actual.Title)
require.Equal(t, uid, actual.UID)
require.Equal(t, title, actual.Fullpath)
t.Run("error when user does not have permissions", func(t *testing.T) {
someUser := &user.SignedInUser{
UserID: 2,
OrgID: 1,
OrgRole: org.RoleViewer,
}
_, err = store.GetNamespaceByUID(context.Background(), uid, 1, someUser)
require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied)
})
t.Run("when nested folders are enabled full path should be populated with correct value", func(t *testing.T) {
store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders))
actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u)
require.NoError(t, err)
require.Equal(t, title, actual.Title)
require.Equal(t, uid, actual.UID)
require.Equal(t, "parent-title/folder\\/title", actual.Fullpath)
})
}
func TestIntegrationInsertAlertRules(t *testing.T) {
@ -543,7 +585,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
store := &DBstore{
SQLStore: sqlStore,
FolderService: setupFolderService(t, sqlStore, cfg),
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: log.New("test-dbstore"),
Cfg: cfg.UnifiedAlerting,
}
@ -630,11 +672,11 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64,
require.NoError(t, err)
}
func setupFolderService(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg) folder.Service {
func setupFolderService(t *testing.T, sqlStore *sqlstore.SQLStore, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service {
tracer := tracing.InitializeTracerForTest()
inProcBus := bus.ProvideBus(tracer)
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
_, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg)
return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus)
return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{})
}

View File

@ -247,12 +247,18 @@ func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ i
return namespacesMap, nil
}
func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ identity.Requester) (*folder.Folder, error) {
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{
func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) {
q := GenericRecordedQuery{
Name: "GetNamespaceByUID",
Params: []any{orgID, uid},
})
Params: []any{orgID, uid, user},
}
defer func() {
f.RecordedOps = append(f.RecordedOps, q)
}()
err := f.Hook(q)
if err != nil {
return nil, err
}
folders := f.Folders[orgID]
for _, folder := range folders {
if folder.UID == uid {

View File

@ -61,17 +61,18 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG,
bus := bus.ProvideBus(tracer)
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg)
folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus)
features := featuremgmt.WithFeatures()
folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, features, ac)
ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}, ac)
require.NoError(tb, err)
ng, err := ngalert.ProvideService(
cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil),
cfg, features, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, quotatest.New(false, nil),
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac,
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, migration.NewFakeMigrationService(tb), nil,
)
require.NoError(tb, err)
return ng, &store.DBstore{
FeatureToggles: ng.FeatureToggles,
FeatureToggles: features,
SQLStore: ng.SQLStore,
Cfg: setting.UnifiedAlertingSettings{
BaseInterval: baseInterval * time.Second,

View File

@ -24,12 +24,8 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStore dashboards.Store, folderStore *folderimpl.DashboardFolderStoreImpl, bus *bus.InProcBus) folder.Service {
func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStore dashboards.Store, folderStore *folderimpl.DashboardFolderStoreImpl, bus *bus.InProcBus, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl) folder.Service {
tb.Helper()
ac := acmock.New()
features := featuremgmt.WithFeatures()
return folderimpl.ProvideService(ac, bus, cfg, dashboardStore, folderStore, db, features, nil)
}

View File

@ -403,8 +403,6 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
require.NoError(t, err)
require.NoError(t, json.Unmarshal(getGroup3Raw, &group3))
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
expected := apimodels.NamespaceConfigResponse{
"folder1": []apimodels.GettableRuleGroupConfig{
group1,
@ -412,7 +410,7 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
"folder2": []apimodels.GettableRuleGroupConfig{
group2,
},
nestedKey: []apimodels.GettableRuleGroupConfig{
"folder1/subfolder": []apimodels.GettableRuleGroupConfig{
group3,
},
}
@ -446,7 +444,7 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
assert.Equal(t, "folder2", rule.GrafanaManagedAlert.NamespaceUID)
}
for _, rule := range allRules[nestedKey][0].Rules {
for _, rule := range allRules["folder1/subfolder"][0].Rules {
assert.Equal(t, "subfolder", rule.GrafanaManagedAlert.NamespaceUID)
}
})
@ -468,7 +466,7 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "subfolder")
require.Equal(t, http.StatusAccepted, status)
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
nestedKey := "folder1/subfolder"
require.Contains(t, rules, nestedKey)
require.Len(t, rules[nestedKey], 1)
require.Equal(t, allRules[nestedKey], rules[nestedKey])
@ -602,7 +600,7 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.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"))
require.Contains(t, newAll, "folder1/subfolder")
})
t.Run("Get by folder returns groups in folder", func(t *testing.T) {

View File

@ -51,6 +51,8 @@ func TestGrafanaRuleConfig(t *testing.T) {
apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
apiCli.CreateFolder(t, "NamespaceUID", "NamespaceTitle")
dsCmd := &datasources.AddDataSourceCommand{
Name: "TestDatasource",
Type: "testdata",