* Implement hierarchical error handling for folder creation failures This commit implements hierarchical error handling to improve sync robustness when folder creation fails. Instead of failing the entire sync, the system now: 1. Tracks failed folder creations and automatically skips nested resources 2. Records skipped resources with FileActionIgnored (doesn't count toward error limits) 3. Allows other folder hierarchies to continue processing 4. Prevents folder deletion when child resource deletions fail Key Changes: - Add PathCreationError type to track which folder path failed - Modify progress recorder to automatically detect and track failures via Record() - Add IsNestedUnderFailedCreation() and HasFailedDeletionsUnder() checks - Update full and incremental sync to skip nested resources after folder failures - Deletions proceed even if parent folder creation failed (resource may exist from previous sync) - FileActionIgnored results don't count toward error limits Example behavior improvement: Before: /monitoring folder creation fails → all nested resources fail → other folders never processed After: /monitoring folder creation fails → nested resources ignored → /applications folder succeeds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * provisioning: refactor hierarchical errors in folder management. * Move test to the corresponding package * Refactor timeout handling in applyChanges functions - Introduced wrapWithTimeout function to streamline timeout context management for applyChange calls. - Updated applyFoldersSerially and applyIncrementalChanges to utilize the new timeout wrapper. - Removed redundant logging and error handling code related to timeout in favor of centralized handling in wrapWithTimeout. - Adjusted test expectations to reflect changes in error reporting for context deadlines. --------- Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
234 lines
6.8 KiB
Go
234 lines
6.8 KiB
Go
package resources
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/client-go/dynamic"
|
|
|
|
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
|
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
|
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
|
)
|
|
|
|
const MaxNumberOfFolders = 10000
|
|
|
|
// PathCreationError represents an error that occurred while creating a folder path.
|
|
// It contains the path that failed and the underlying error.
|
|
type PathCreationError struct {
|
|
Path string
|
|
Err error
|
|
}
|
|
|
|
func (e *PathCreationError) Unwrap() error {
|
|
return e.Err
|
|
}
|
|
|
|
func (e *PathCreationError) Error() string {
|
|
return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err)
|
|
}
|
|
|
|
type FolderManager struct {
|
|
repo repository.ReaderWriter
|
|
tree FolderTree
|
|
client dynamic.ResourceInterface
|
|
}
|
|
|
|
func NewFolderManager(repo repository.ReaderWriter, client dynamic.ResourceInterface, lookup FolderTree) *FolderManager {
|
|
return &FolderManager{
|
|
repo: repo,
|
|
tree: lookup,
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
func (fm *FolderManager) Client() dynamic.ResourceInterface {
|
|
return fm.client
|
|
}
|
|
|
|
func (fm *FolderManager) Tree() FolderTree {
|
|
return fm.tree
|
|
}
|
|
|
|
func (fm *FolderManager) SetTree(tree FolderTree) {
|
|
fm.tree = tree
|
|
}
|
|
|
|
// EnsureFoldersExist creates the folder structure in the cluster.
|
|
func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath string) (parent string, err error) {
|
|
cfg := fm.repo.Config()
|
|
parent = RootFolder(cfg)
|
|
|
|
dir := filePath
|
|
if !safepath.IsDir(filePath) {
|
|
dir = safepath.Dir(filePath)
|
|
}
|
|
|
|
if dir == "" {
|
|
return parent, nil
|
|
}
|
|
|
|
f := ParseFolder(dir, cfg.Name)
|
|
if fm.tree.In(f.ID) {
|
|
return f.ID, nil
|
|
}
|
|
|
|
err = safepath.Walk(ctx, f.Path, func(ctx context.Context, traverse string) error {
|
|
f := ParseFolder(traverse, cfg.GetName())
|
|
if fm.tree.In(f.ID) {
|
|
parent = f.ID
|
|
return nil
|
|
}
|
|
|
|
if err := fm.EnsureFolderExists(ctx, f, parent); err != nil {
|
|
// Wrap in PathCreationError to indicate which path failed
|
|
return &PathCreationError{
|
|
Path: f.Path,
|
|
Err: fmt.Errorf("ensure folder exists: %w", err),
|
|
}
|
|
}
|
|
|
|
fm.tree.Add(f, parent)
|
|
parent = f.ID
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return f.ID, nil
|
|
}
|
|
|
|
// EnsureFolderExists creates the folder if it doesn't exist.
|
|
// If the folder already exists:
|
|
// - it will error if the folder is not owned by this repository
|
|
func (fm *FolderManager) EnsureFolderExists(ctx context.Context, folder Folder, parent string) error {
|
|
cfg := fm.repo.Config()
|
|
obj, err := fm.client.Get(ctx, folder.ID, metav1.GetOptions{})
|
|
if err == nil {
|
|
current, ok := obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
|
if !ok {
|
|
return fmt.Errorf("target folder is not managed by a repository")
|
|
}
|
|
if current != cfg.Name {
|
|
return fmt.Errorf("target folder is managed by a different repository (%s)", current)
|
|
}
|
|
return nil
|
|
} else if !apierrors.IsNotFound(err) {
|
|
return fmt.Errorf("failed to check if folder exists: %w", err)
|
|
}
|
|
|
|
// Always use the provisioning identity when writing
|
|
ctx, _, err = identity.WithProvisioningIdentity(ctx, cfg.GetNamespace())
|
|
if err != nil {
|
|
return fmt.Errorf("unable to use provisioning identity %w", err)
|
|
}
|
|
|
|
obj = &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]any{
|
|
"title": folder.Title,
|
|
},
|
|
},
|
|
}
|
|
obj.SetAPIVersion(folders.APIVERSION)
|
|
obj.SetKind(folders.FolderResourceInfo.GroupVersionKind().Kind)
|
|
obj.SetNamespace(cfg.GetNamespace())
|
|
obj.SetName(folder.ID)
|
|
|
|
meta, err := utils.MetaAccessor(obj)
|
|
if err != nil {
|
|
return fmt.Errorf("create meta accessor for the object: %w", err)
|
|
}
|
|
|
|
if parent != "" {
|
|
meta.SetFolder(parent)
|
|
} else {
|
|
meta.SetAnnotation(utils.AnnoKeyGrantPermissions, utils.AnnoGrantPermissionsDefault)
|
|
}
|
|
meta.SetManagerProperties(utils.ManagerProperties{
|
|
Kind: utils.ManagerKindRepo,
|
|
Identity: cfg.GetName(),
|
|
})
|
|
meta.SetSourceProperties(utils.SourceProperties{
|
|
Path: folder.Path,
|
|
})
|
|
|
|
if _, err := fm.client.Create(ctx, obj, metav1.CreateOptions{}); err != nil {
|
|
// there is a potential race here where two syncs can be triggered
|
|
// if we try to create and there is an error, check if it is from another sync
|
|
// job for this repo that created it
|
|
if apierrors.IsAlreadyExists(err) || err.Error() == dashboards.ErrFolderVersionMismatch.Error() {
|
|
obj, err2 := fm.client.Get(ctx, folder.ID, metav1.GetOptions{})
|
|
if err2 != nil {
|
|
return fmt.Errorf("failed to get folder: %w", err2)
|
|
} else if obj == nil {
|
|
return fmt.Errorf("failed to create folder: %w", err)
|
|
}
|
|
|
|
current, ok := obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
|
|
if !ok {
|
|
return fmt.Errorf("target folder is not managed by a repository")
|
|
}
|
|
if current != cfg.Name {
|
|
return fmt.Errorf("target folder is managed by a different repository (%s)", current)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("failed to create folder: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (fm *FolderManager) GetFolder(ctx context.Context, name string) (*unstructured.Unstructured, error) {
|
|
return fm.client.Get(ctx, name, metav1.GetOptions{})
|
|
}
|
|
|
|
func (fm *FolderManager) RemoveFolder(ctx context.Context, name string) error {
|
|
return fm.client.Delete(ctx, name, metav1.DeleteOptions{})
|
|
}
|
|
|
|
// ReplicateTree replicates the folder tree to the repository.
|
|
// The function fn is called for each folder.
|
|
// If the folder already exists, the function is called with created set to false.
|
|
// If the folder is created, the function is called with created set to true.
|
|
func (fm *FolderManager) EnsureFolderTreeExists(ctx context.Context, ref, path string, tree FolderTree, fn func(folder Folder, created bool, err error) error) error {
|
|
return tree.Walk(ctx, func(ctx context.Context, folder Folder, parent string) error {
|
|
p := folder.Path
|
|
if path != "" {
|
|
p = safepath.Join(path, p)
|
|
}
|
|
if !safepath.IsDir(p) {
|
|
p = p + "/" // trailing slash indicates folder
|
|
}
|
|
|
|
_, err := fm.repo.Read(ctx, p, ref)
|
|
if err != nil && (!errors.Is(err, repository.ErrFileNotFound) && !apierrors.IsNotFound(err)) {
|
|
return fn(folder, false, fmt.Errorf("check if folder exists before writing: %w", err))
|
|
} else if err == nil {
|
|
// Folder already exists in repository, add it to tree so resources can find it
|
|
fm.tree.Add(folder, parent)
|
|
return fn(folder, false, nil)
|
|
}
|
|
|
|
msg := fmt.Sprintf("Add folder %s", p)
|
|
if err := fm.repo.Create(ctx, p, ref, nil, msg); err != nil {
|
|
return fn(folder, true, fmt.Errorf("write folder in repo: %w", err))
|
|
}
|
|
// Add it to the existing tree
|
|
fm.tree.Add(folder, parent)
|
|
|
|
return fn(folder, true, nil)
|
|
})
|
|
}
|