mirror of https://github.com/grafana/grafana
prometheushacktoberfestmetricsmonitoringalertinggrafanagoinfluxdbmysqlpostgresanalyticsdata-visualizationdashboardbusiness-intelligenceelasticsearch
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
530 lines
18 KiB
530 lines
18 KiB
package folderimpl
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1"
|
|
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1"
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/bus"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/apiserver"
|
|
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
|
"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"
|
|
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
|
"github.com/grafana/grafana/pkg/services/search/model"
|
|
"github.com/grafana/grafana/pkg/services/search/sort"
|
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
|
"github.com/grafana/grafana/pkg/services/supportbundles"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
|
)
|
|
|
|
const FULLPATH_SEPARATOR = "/"
|
|
|
|
var (
|
|
_ folder.Service = (*Service)(nil)
|
|
)
|
|
|
|
type Service struct {
|
|
store folder.Store
|
|
unifiedStore folder.Store
|
|
db db.DB
|
|
log *slog.Logger
|
|
dashboardFolderStore *DashboardFolderStoreImpl
|
|
features featuremgmt.FeatureToggles
|
|
accessControl accesscontrol.AccessControl
|
|
k8sclient client.K8sHandler
|
|
maxNestedFolderDepth int
|
|
dashboardK8sClient client.K8sHandler
|
|
publicDashboardService publicdashboards.ServiceWrapper
|
|
// bus is currently used to publish event in case of folder full path change.
|
|
// For example when a folder is moved to another folder or when a folder is renamed.
|
|
bus bus.Bus
|
|
|
|
mutex sync.RWMutex
|
|
registry map[string]folder.RegistryService
|
|
metrics *foldersMetrics
|
|
tracer trace.Tracer
|
|
}
|
|
|
|
func ProvideService(
|
|
store *FolderStoreImpl,
|
|
ac accesscontrol.AccessControl,
|
|
bus bus.Bus,
|
|
userService user.Service,
|
|
db db.DB, // DB for the (new) nested folder store
|
|
features featuremgmt.FeatureToggles,
|
|
supportBundles supportbundles.Service,
|
|
publicDashboardService publicdashboards.ServiceWrapper,
|
|
cfg *setting.Cfg,
|
|
r prometheus.Registerer,
|
|
tracer trace.Tracer,
|
|
resourceClient resource.ResourceClient,
|
|
dual dualwrite.Service,
|
|
sorter sort.Service,
|
|
restConfig apiserver.RestConfigProvider,
|
|
) *Service {
|
|
srv := &Service{
|
|
log: slog.Default().With("logger", "folder-service"),
|
|
dashboardFolderStore: newDashboardFolderStore(db, cfg.MaxNestedFolderDepth),
|
|
store: store,
|
|
features: features,
|
|
accessControl: ac,
|
|
bus: bus,
|
|
db: db,
|
|
registry: make(map[string]folder.RegistryService),
|
|
metrics: newFoldersMetrics(r),
|
|
tracer: tracer,
|
|
publicDashboardService: publicDashboardService,
|
|
maxNestedFolderDepth: cfg.MaxNestedFolderDepth,
|
|
}
|
|
srv.DBMigration(db)
|
|
|
|
supportBundles.RegisterSupportItemCollector(srv.supportBundleCollector())
|
|
|
|
ac.RegisterScopeAttributeResolver(dashboards.NewFolderIDScopeResolver(srv.getUIDFromLegacyID, srv))
|
|
ac.RegisterScopeAttributeResolver(dashboards.NewFolderUIDScopeResolver(srv))
|
|
|
|
k8sHandler := client.NewK8sHandler(
|
|
request.GetNamespaceMapper(cfg),
|
|
folderv1.FolderResourceInfo.GroupVersionResource(),
|
|
restConfig.GetRestConfig,
|
|
userService,
|
|
resourceClient,
|
|
)
|
|
|
|
unifiedStore := ProvideUnifiedStore(k8sHandler, userService, tracer, cfg)
|
|
|
|
srv.unifiedStore = unifiedStore
|
|
srv.k8sclient = k8sHandler
|
|
|
|
dashHandler := client.NewK8sHandler(
|
|
request.GetNamespaceMapper(cfg),
|
|
dashboardv1.DashboardResourceInfo.GroupVersionResource(),
|
|
restConfig.GetRestConfig,
|
|
userService,
|
|
resourceClient,
|
|
)
|
|
srv.dashboardK8sClient = dashHandler
|
|
|
|
return srv
|
|
}
|
|
|
|
func (s *Service) DBMigration(db db.DB) {
|
|
s.log.Debug("syncing dashboard and folder tables started")
|
|
|
|
ctx := context.Background()
|
|
err := db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
|
var err error
|
|
deleteOldFolders := true
|
|
if db.GetDialect().DriverName() == migrator.SQLite {
|
|
// covered by UQE_folder_org_id_uid
|
|
_, err = sess.Exec(`
|
|
INSERT INTO folder (uid, org_id, title, created, updated)
|
|
SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1
|
|
ON CONFLICT DO UPDATE SET title=excluded.title, updated=excluded.updated
|
|
`)
|
|
} else if db.GetDialect().DriverName() == migrator.Postgres {
|
|
// covered by UQE_folder_org_id_uid
|
|
_, err = sess.Exec(`
|
|
INSERT INTO folder (uid, org_id, title, created, updated)
|
|
SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = true
|
|
ON CONFLICT(uid, org_id) DO UPDATE SET title=excluded.title, updated=excluded.updated
|
|
`)
|
|
} else {
|
|
// covered by UQE_folder_org_id_uid
|
|
_, err = sess.Exec(`
|
|
INSERT INTO folder (uid, org_id, title, created, updated)
|
|
SELECT * FROM (SELECT uid, org_id, title, created, updated FROM dashboard WHERE is_folder = 1) AS derived
|
|
ON DUPLICATE KEY UPDATE title=derived.title, updated=derived.updated
|
|
`)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if deleteOldFolders {
|
|
// covered by UQE_folder_org_id_uid
|
|
_, err = sess.Exec(`
|
|
DELETE FROM folder WHERE NOT EXISTS
|
|
(SELECT 1 FROM dashboard WHERE dashboard.uid = folder.uid AND dashboard.org_id = folder.org_id AND dashboard.is_folder = true)
|
|
`)
|
|
}
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.log.Error("DB migration on folder service start failed.", "err", err)
|
|
}
|
|
|
|
s.log.Debug("syncing dashboard and folder tables finished")
|
|
}
|
|
|
|
func (s *Service) getUIDFromLegacyID(ctx context.Context, orgID int64, id int64) (string, error) {
|
|
f, err := s.dashboardFolderStore.GetFolderByID(ctx, orgID, id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return f.UID, nil
|
|
}
|
|
|
|
func (s *Service) CountFoldersInOrg(ctx context.Context, orgID int64) (int64, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.CountFoldersInOrg")
|
|
defer span.End()
|
|
return s.unifiedStore.CountInOrg(ctx, orgID)
|
|
}
|
|
|
|
func (s *Service) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.SearchFolders")
|
|
defer span.End()
|
|
// TODO:
|
|
// - implement filtering by alerting folders and k6 folders (see the dashboards store `FindDashboards` method for reference)
|
|
// - implement fallback on search client in unistore to go to legacy store (will need to read from dashboard store)
|
|
return s.searchFoldersFromApiServer(ctx, q)
|
|
}
|
|
|
|
func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.GetFolders")
|
|
defer span.End()
|
|
return s.getFoldersFromApiServer(ctx, q)
|
|
}
|
|
|
|
func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.Get")
|
|
defer span.End()
|
|
return s.getFromApiServer(ctx, q)
|
|
}
|
|
|
|
func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, forceLegacy bool) (*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.setFullpath")
|
|
defer span.End()
|
|
|
|
if f.ParentUID == "" {
|
|
return f, nil
|
|
}
|
|
|
|
// Fetch the parent since the permissions for fetching the newly created folder
|
|
// are not yet present for the user--this requires a call to ClearUserPermissionCache
|
|
parents, err := s.GetParents(ctx, folder.GetParentsQuery{
|
|
UID: f.UID,
|
|
OrgID: f.OrgID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// #TODO revisit setting permissions so that we can centralise the logic for escaping slashes in titles
|
|
// Escape forward slashes in the title
|
|
f.Fullpath, f.FullpathUIDs = computeFullPath(append(parents, f))
|
|
return f, nil
|
|
}
|
|
|
|
func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.GetChildren")
|
|
defer span.End()
|
|
return s.getChildrenFromApiServer(ctx, q)
|
|
}
|
|
|
|
// GetSharedWithMe returns folders available to user, which cannot be accessed from the root folders
|
|
func (s *Service) GetSharedWithMe(ctx context.Context, q *folder.GetChildrenQuery, forceLegacy bool) ([]*folder.FolderReference, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.GetSharedWithMe")
|
|
defer span.End()
|
|
start := time.Now()
|
|
availableNonRootFolders, err := s.getAvailableNonRootFolders(ctx, q, forceLegacy)
|
|
if err != nil {
|
|
s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds())
|
|
return nil, folder.ErrInternal.Errorf("failed to fetch subfolders to which the user has explicit access: %w", err)
|
|
}
|
|
rootFolders, err := s.GetChildren(ctx, &folder.GetChildrenQuery{UID: "", OrgID: q.OrgID, SignedInUser: q.SignedInUser, Permission: q.Permission})
|
|
if err != nil {
|
|
s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds())
|
|
return nil, folder.ErrInternal.Errorf("failed to fetch root folders to which the user has access: %w", err)
|
|
}
|
|
|
|
dedupAvailableNonRootFolders := s.deduplicateAvailableFolders(ctx, availableNonRootFolders, rootFolders)
|
|
s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("success").Observe(time.Since(start).Seconds())
|
|
return dedupAvailableNonRootFolders, nil
|
|
}
|
|
|
|
func (s *Service) getAvailableNonRootFolders(ctx context.Context, q *folder.GetChildrenQuery, forceLegacy bool) ([]*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.getAvailableNonRootFolders")
|
|
defer span.End()
|
|
permissions := q.SignedInUser.GetPermissions()
|
|
var folderPermissions []string
|
|
if q.Permission == dashboardaccess.PERMISSION_EDIT {
|
|
folderPermissions = permissions[dashboards.ActionFoldersWrite]
|
|
folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsWrite]...)
|
|
} else {
|
|
folderPermissions = permissions[dashboards.ActionFoldersRead]
|
|
folderPermissions = append(folderPermissions, permissions[dashboards.ActionDashboardsRead]...)
|
|
}
|
|
|
|
if len(folderPermissions) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
nonRootFolders := make([]*folder.Folder, 0)
|
|
folderUids := make([]string, 0, len(folderPermissions))
|
|
for _, p := range folderPermissions {
|
|
if folderUid, found := strings.CutPrefix(p, dashboards.ScopeFoldersPrefix); found {
|
|
if !slices.Contains(folderUids, folderUid) {
|
|
folderUids = append(folderUids, folderUid)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(folderUids) == 0 {
|
|
return nonRootFolders, nil
|
|
}
|
|
|
|
dashFolders, err := s.GetFolders(ctx, folder.GetFoldersQuery{
|
|
UIDs: folderUids,
|
|
OrgID: q.OrgID,
|
|
SignedInUser: q.SignedInUser,
|
|
OrderByTitle: true,
|
|
WithFullpathUIDs: true,
|
|
})
|
|
if err != nil {
|
|
return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err)
|
|
}
|
|
|
|
for _, f := range dashFolders {
|
|
if f.ParentUID != "" {
|
|
nonRootFolders = append(nonRootFolders, f)
|
|
}
|
|
}
|
|
|
|
return nonRootFolders, nil
|
|
}
|
|
|
|
func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*folder.Folder, rootFolders []*folder.FolderReference) []*folder.FolderReference {
|
|
foldersRef := make([]*folder.FolderReference, len(folders))
|
|
for i, f := range folders {
|
|
foldersRef[i] = f.ToFolderReference()
|
|
}
|
|
|
|
_, span := s.tracer.Start(ctx, "folder.deduplicateAvailableFolders")
|
|
defer span.End()
|
|
allFolders := append(foldersRef, rootFolders...)
|
|
foldersDedup := make([]*folder.FolderReference, 0)
|
|
|
|
for _, f := range folders {
|
|
isSubfolder := slices.ContainsFunc(allFolders, func(folder *folder.FolderReference) bool {
|
|
return f.ParentUID == folder.UID
|
|
})
|
|
|
|
if !isSubfolder {
|
|
// Get parents UIDs
|
|
parentUIDs := make([]string, 0)
|
|
pathUIDs := strings.Split(f.FullpathUIDs, "/")
|
|
for _, p := range pathUIDs {
|
|
if p != "" && p != f.UID {
|
|
parentUIDs = append(parentUIDs, p)
|
|
}
|
|
}
|
|
|
|
for _, parentUID := range parentUIDs {
|
|
contains := slices.ContainsFunc(allFolders, func(f *folder.FolderReference) bool {
|
|
return f.UID == parentUID
|
|
})
|
|
if contains {
|
|
isSubfolder = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !isSubfolder {
|
|
foldersDedup = append(foldersDedup, f.ToFolderReference())
|
|
}
|
|
}
|
|
return foldersDedup
|
|
}
|
|
|
|
func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.GetParents")
|
|
defer span.End()
|
|
return s.getParentsFromApiServer(ctx, q)
|
|
}
|
|
|
|
func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) {
|
|
return s.createOnApiServer(ctx, cmd)
|
|
}
|
|
|
|
func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.Update")
|
|
defer span.End()
|
|
return s.updateOnApiServer(ctx, cmd)
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error {
|
|
ctx, span := s.tracer.Start(ctx, "folder.Delete")
|
|
defer span.End()
|
|
return s.deleteFromApiServer(ctx, cmd)
|
|
}
|
|
|
|
func (s *Service) deleteChildrenInFolder(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error {
|
|
ctx, span := s.tracer.Start(ctx, "folder.deleteChildrenInFolder")
|
|
defer span.End()
|
|
for _, v := range s.registry {
|
|
if err := v.DeleteInFolders(ctx, orgID, folderUIDs, user); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.Move")
|
|
defer span.End()
|
|
return s.moveOnApiServer(ctx, cmd)
|
|
}
|
|
|
|
func (s *Service) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) {
|
|
ctx, span := s.tracer.Start(ctx, "folder.GetDescendantCounts")
|
|
defer span.End()
|
|
return s.getDescendantCountsFromApiServer(ctx, q)
|
|
}
|
|
|
|
// SplitFullpath splits a string into an array of strings using the FULLPATH_SEPARATOR as the delimiter.
|
|
// It handles escape characters by appending the separator and the new string if the current string ends with an escape character.
|
|
// The resulting array does not contain empty strings.
|
|
func SplitFullpath(s string) []string {
|
|
splitStrings := strings.Split(s, FULLPATH_SEPARATOR)
|
|
|
|
result := make([]string, 0)
|
|
current := ""
|
|
|
|
for _, str := range splitStrings {
|
|
if strings.HasSuffix(current, "\\") {
|
|
// If the current string ends with an escape character, append the separator and the new string
|
|
current = current[:len(current)-1] + FULLPATH_SEPARATOR + str
|
|
} else {
|
|
// If the current string does not end with an escape character, append the current string to the result and start a new current string
|
|
if current != "" {
|
|
result = append(result, current)
|
|
}
|
|
current = str
|
|
}
|
|
}
|
|
|
|
// Append the last string to the result
|
|
if current != "" {
|
|
result = append(result, current)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func toFolderError(err error) error {
|
|
if errors.Is(err, dashboards.ErrDashboardTitleEmpty) {
|
|
return dashboards.ErrFolderTitleEmpty
|
|
}
|
|
|
|
if errors.Is(err, dashboards.ErrDashboardUpdateAccessDenied) {
|
|
return dashboards.ErrFolderAccessDenied
|
|
}
|
|
|
|
if errors.Is(err, dashboards.ErrDashboardWithSameUIDExists) {
|
|
return dashboards.ErrFolderWithSameUIDExists
|
|
}
|
|
|
|
if errors.Is(err, dashboards.ErrDashboardVersionMismatch) {
|
|
return dashboards.ErrFolderVersionMismatch
|
|
}
|
|
|
|
if errors.Is(err, dashboards.ErrDashboardNotFound) {
|
|
return dashboards.ErrFolderNotFound
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *Service) RegisterService(r folder.RegistryService) error {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
s.registry[r.Kind()] = r
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) supportBundleCollector() supportbundles.Collector {
|
|
collector := supportbundles.Collector{
|
|
UID: "folder-stats",
|
|
DisplayName: "Folder information",
|
|
Description: "Folder information for the Grafana instance",
|
|
IncludedByDefault: false,
|
|
Default: true,
|
|
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
|
s.log.Info("Generating folder support bundle")
|
|
folders, err := s.GetFolders(ctx, folder.GetFoldersQuery{
|
|
OrgID: 0,
|
|
SignedInUser: &user.SignedInUser{
|
|
Login: "sa-supportbundle",
|
|
OrgRole: "Admin",
|
|
IsGrafanaAdmin: true,
|
|
IsServiceAccount: true,
|
|
Permissions: map[int64]map[string][]string{accesscontrol.GlobalOrgID: {dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.supportItemFromFolders(folders)
|
|
},
|
|
}
|
|
return collector
|
|
}
|
|
|
|
func (s *Service) supportItemFromFolders(folders []*folder.Folder) (*supportbundles.SupportItem, error) {
|
|
stats := struct {
|
|
Total int `json:"total"` // how many folders?
|
|
Depths map[int]int `json:"depths"` // how deep they are?
|
|
Children map[int]int `json:"children"` // how many child folders they have?
|
|
Folders []*folder.Folder `json:"folders"` // what are they?
|
|
}{Total: len(folders), Folders: folders, Children: map[int]int{}, Depths: map[int]int{}}
|
|
|
|
// Build parent-child mapping
|
|
parents := map[string]string{}
|
|
children := map[string][]string{}
|
|
for _, f := range folders {
|
|
parents[f.UID] = f.ParentUID
|
|
children[f.ParentUID] = append(children[f.ParentUID], f.UID)
|
|
}
|
|
// Find depths of each folder
|
|
for _, f := range folders {
|
|
depth := 0
|
|
for uid := f.UID; uid != ""; uid = parents[uid] {
|
|
depth++
|
|
}
|
|
stats.Depths[depth] += 1
|
|
stats.Children[len(children[f.UID])] += 1
|
|
}
|
|
|
|
b, err := json.MarshalIndent(stats, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &supportbundles.SupportItem{
|
|
Filename: "folders.json",
|
|
FileBytes: b,
|
|
}, nil
|
|
}
|
|
|