317 lines
9.4 KiB
Go
317 lines
9.4 KiB
Go
package folders
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
"k8s.io/apiserver/pkg/registry/rest"
|
|
|
|
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
|
"github.com/grafana/grafana/pkg/util"
|
|
)
|
|
|
|
func validateOnCreate(ctx context.Context, f *folders.Folder, getter parentsGetter, maxDepth int) error {
|
|
id := f.Name
|
|
|
|
if slices.Contains([]string{
|
|
folder.GeneralFolderUID,
|
|
folder.SharedWithMeFolderUID,
|
|
}, id) {
|
|
return dashboards.ErrFolderInvalidUID
|
|
}
|
|
|
|
meta, err := utils.MetaAccessor(f)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to read metadata from object: %w", err)
|
|
}
|
|
|
|
if !util.IsValidShortUID(id) {
|
|
return dashboards.ErrDashboardInvalidUid
|
|
}
|
|
|
|
if util.IsShortUIDTooLong(id) {
|
|
return dashboards.ErrDashboardUidTooLong
|
|
}
|
|
|
|
if f.Spec.Title == "" {
|
|
return dashboards.ErrFolderTitleEmpty
|
|
}
|
|
|
|
parentName := meta.GetFolder()
|
|
if parentName == "" {
|
|
return nil // OK, we do not need to validate the tree
|
|
}
|
|
|
|
if parentName == f.Name {
|
|
return folder.ErrFolderCannotBeParentOfItself
|
|
}
|
|
|
|
// note: `parents` will include itself as the last item
|
|
parents, err := getter(ctx, f)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create folder inside parent: %w", err)
|
|
}
|
|
|
|
// Can not create a folder that will be too deep.
|
|
// We need to add +1 as we also have the root folder as part of the parents.
|
|
if len(parents.Items) > maxDepth+1 {
|
|
return fmt.Errorf("folder max depth exceeded, max depth is %d", maxDepth)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateOnUpdate(ctx context.Context,
|
|
obj *folders.Folder,
|
|
old *folders.Folder,
|
|
getter rest.Getter,
|
|
parents parentsGetter,
|
|
searcher resourcepb.ResourceIndexClient,
|
|
maxDepth int,
|
|
) error {
|
|
folderObj, err := utils.MetaAccessor(obj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
oldFolder, err := utils.MetaAccessor(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if obj.Spec.Title == "" {
|
|
return dashboards.ErrFolderTitleEmpty
|
|
}
|
|
|
|
if folderObj.GetFolder() == oldFolder.GetFolder() {
|
|
return nil
|
|
}
|
|
|
|
// Validate the move operation
|
|
newParent := folderObj.GetFolder()
|
|
|
|
// If we move to root, we don't need to validate the depth, because the folder already existed
|
|
// before and wasn't too deep. This move will make it more shallow.
|
|
//
|
|
// We also don't need to validate circular references because the root folder cannot have a parent.
|
|
if newParent == folder.RootFolderUID {
|
|
return nil
|
|
}
|
|
|
|
// folder cannot be moved to a k6 folder
|
|
if newParent == accesscontrol.K6FolderUID {
|
|
return fmt.Errorf("k6 project may not be moved")
|
|
}
|
|
|
|
parentObj, err := getter.Get(ctx, newParent, &metav1.GetOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("move target not found %w", err)
|
|
}
|
|
parent, ok := parentObj.(*folders.Folder)
|
|
if !ok {
|
|
return fmt.Errorf("expected folder, found %T", parentObj)
|
|
}
|
|
info, err := parents(ctx, parent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check that the folder being moved is not an ancestor of the target parent.
|
|
// This prevents circular references (e.g., moving A under B when B is already under A).
|
|
for _, ancestor := range info.Items {
|
|
if ancestor.Name == obj.Name {
|
|
return fmt.Errorf("cannot move folder under its own descendant, this would create a circular reference")
|
|
}
|
|
}
|
|
|
|
// if by moving a folder we exceed the max depth just from its parents + itself, return an error
|
|
if len(info.Items) > maxDepth+1 {
|
|
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached")
|
|
}
|
|
// To try to save some computation, get the parents of the old parent (this is typically cheaper
|
|
// than looking at the children of the folder). If the old parent has more parents or the same
|
|
// number of parents as the new parent, we can return early, because we know the folder had to be
|
|
// safe from the creation validation. If we cannot access the older parent, we will continue to check the children.
|
|
if canSkipChildrenCheck(ctx, oldFolder, getter, parents, len(info.Items)) {
|
|
return nil
|
|
}
|
|
|
|
// Now comes the more expensive part: we need to check if moving this folder will cause
|
|
// any descendant folders to exceed the max depth.
|
|
//
|
|
// Calculate the maximum allowed subtree depth after the move.
|
|
allowedDepth := (maxDepth + 1) - len(info.Items)
|
|
if allowedDepth <= 0 {
|
|
return nil
|
|
}
|
|
|
|
return checkSubtreeDepth(ctx, searcher, obj.Namespace, obj.Name, allowedDepth, maxDepth)
|
|
}
|
|
|
|
// canSkipChildrenCheck determines if we can skip the expensive children depth check.
|
|
// If the old parent depth is >= the new parent depth, the folder was already valid
|
|
// and this move won't make descendants exceed max depth.
|
|
func canSkipChildrenCheck(ctx context.Context, oldFolder utils.GrafanaMetaAccessor, getter rest.Getter, parents parentsGetter, newParentDepth int) bool {
|
|
if oldFolder.GetFolder() == folder.RootFolderUID {
|
|
return false
|
|
}
|
|
|
|
oldParentObj, err := getter.Get(ctx, oldFolder.GetFolder(), &metav1.GetOptions{})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
oldParent, ok := oldParentObj.(*folders.Folder)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
oldInfo, err := parents(ctx, oldParent)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
oldParentDepth := len(oldInfo.Items)
|
|
levelDifference := newParentDepth - oldParentDepth
|
|
return levelDifference <= 0
|
|
}
|
|
|
|
// checkSubtreeDepth uses a hybrid DFS+batching approach:
|
|
// 1. fetches one page of children for the current folder(s)
|
|
// 2. batches all those children into one request to get their children
|
|
// 3. continues depth-first (batching still) until max depth or violation
|
|
// 4. only fetches more siblings after fully exploring current batch
|
|
func checkSubtreeDepth(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, folderUID string, remainingDepth int, maxDepth int) error {
|
|
if remainingDepth <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Start with the folder being moved
|
|
return checkSubtreeDepthBatched(ctx, searcher, namespace, []string{folderUID}, remainingDepth, maxDepth)
|
|
}
|
|
|
|
// checkSubtreeDepthBatched checks depth for a batch of folders at the same level
|
|
func checkSubtreeDepthBatched(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, remainingDepth int, maxDepth int) error {
|
|
if remainingDepth <= 0 || len(parentUIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
const pageSize int64 = 1000
|
|
var offset int64
|
|
totalPages := 0
|
|
hasMore := true
|
|
|
|
// Using an upper limit to ensure no infinite loops can happen
|
|
for hasMore && totalPages < 1000 {
|
|
totalPages++
|
|
|
|
var err error
|
|
var children []string
|
|
children, hasMore, err = getChildrenBatch(ctx, searcher, namespace, parentUIDs, pageSize, offset)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get children: %w", err)
|
|
}
|
|
|
|
if len(children) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// if we are at the last allowed depth and children exist, we will hit the max
|
|
if remainingDepth == 1 {
|
|
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth %d would be exceeded after move", maxDepth)
|
|
}
|
|
|
|
if err := checkSubtreeDepthBatched(ctx, searcher, namespace, children, remainingDepth-1, maxDepth); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !hasMore {
|
|
return nil
|
|
}
|
|
|
|
offset += pageSize
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getChildrenBatch fetches children for multiple parents
|
|
func getChildrenBatch(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, limit int64, offset int64) ([]string, bool, error) {
|
|
if len(parentUIDs) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
|
|
resp, err := searcher.Search(ctx, &resourcepb.ResourceSearchRequest{
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Namespace: namespace,
|
|
Group: folders.FolderResourceInfo.GroupVersionResource().Group,
|
|
Resource: folders.FolderResourceInfo.GroupVersionResource().Resource,
|
|
},
|
|
Fields: []*resourcepb.Requirement{{
|
|
Key: resource.SEARCH_FIELD_FOLDER,
|
|
Operator: string(selection.In),
|
|
Values: parentUIDs,
|
|
}},
|
|
},
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to search folders: %w", err)
|
|
}
|
|
|
|
if resp.Error != nil {
|
|
return nil, false, fmt.Errorf("search error: %s", resp.Error.Message)
|
|
}
|
|
|
|
if resp.Results == nil || len(resp.Results.Rows) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
|
|
children := make([]string, 0, len(resp.Results.Rows))
|
|
for _, row := range resp.Results.Rows {
|
|
if row.Key != nil {
|
|
children = append(children, row.Key.Name)
|
|
}
|
|
}
|
|
|
|
hasMore := resp.Results.NextPageToken != ""
|
|
return children, hasMore, nil
|
|
}
|
|
|
|
func validateOnDelete(ctx context.Context,
|
|
f *folders.Folder,
|
|
searcher resourcepb.ResourceIndexClient,
|
|
) error {
|
|
resp, err := searcher.GetStats(ctx, &resourcepb.ResourceStatsRequest{Namespace: f.Namespace, Folder: f.Name})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp != nil && resp.Error != nil {
|
|
return fmt.Errorf("could not verify if folder is empty: %v", resp.Error)
|
|
}
|
|
|
|
if resp.Stats == nil {
|
|
return fmt.Errorf("could not verify if folder is empty: %v", resp.Error)
|
|
}
|
|
|
|
allowedResourceTypes := []string{"alertrules", "dashboards", "library_elements", "folders"}
|
|
|
|
for _, v := range resp.Stats {
|
|
if slices.Contains(allowedResourceTypes, v.Resource) && v.Count > 0 {
|
|
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s", v.Count, v.Resource)
|
|
}
|
|
}
|
|
return nil
|
|
}
|