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

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
}