Git sync: Implement folder deletion

This commit is contained in:
Stephanie Hingtgen
2025-07-10 12:45:35 +02:00
committed by Roberto Jiménez Sánchez
parent b41b233d7d
commit bd81243bbb
6 changed files with 313 additions and 9 deletions
-6
View File
@@ -128,12 +128,6 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
return
}
// TODO: Implement folder delete
if r.Method == http.MethodDelete && isDir {
responder.Error(apierrors.NewBadRequest("folder navigation not yet supported"))
return
}
var obj *provisioning.ResourceWrapper
code := http.StatusOK
switch r.Method {
@@ -360,5 +360,12 @@ func (r *localRepository) Delete(ctx context.Context, path string, ref string, c
return err
}
return os.Remove(safepath.Join(r.path, path))
fullPath := safepath.Join(r.path, path)
if safepath.IsDir(path) {
// if it is a folder, delete all of its contents
return os.RemoveAll(fullPath)
}
return os.Remove(fullPath)
}
@@ -542,6 +542,41 @@ func TestLocalRepository_Delete(t *testing.T) {
comment: "test delete with ref",
expectedErr: apierrors.NewBadRequest("local repository does not support ref"),
},
{
name: "delete folder with nested files",
setup: func(t *testing.T) (string, *localRepository) {
tempDir := t.TempDir()
nestedFolderPath := filepath.Join(tempDir, "folder")
err := os.MkdirAll(nestedFolderPath, 0700)
require.NoError(t, err)
subFolderPath := filepath.Join(nestedFolderPath, "nested-folder")
err = os.MkdirAll(subFolderPath, 0700)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(nestedFolderPath, "nested-dash.txt"), []byte("content1"), 0600)
require.NoError(t, err)
// Create repository with the temp directory as permitted prefix
repo := &localRepository{
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Local: &provisioning.LocalRepositoryConfig{
Path: tempDir,
},
},
},
resolver: &LocalFolderResolver{
PermittedPrefixes: []string{tempDir},
},
path: tempDir,
}
return tempDir, repo
},
path: "folder/",
ref: "",
comment: "test delete folder with nested content",
expectedErr: nil,
},
}
for _, tc := range testCases {
@@ -3,9 +3,12 @@ package resources
import (
"context"
"fmt"
"sort"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
@@ -76,9 +79,8 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return nil, err
}
// TODO: implement this
if safepath.IsDir(opts.Path) {
return nil, fmt.Errorf("folder delete not supported")
return r.deleteFolder(ctx, opts)
}
// Read the file from the default branch as it won't exist in the possibly new branch
@@ -131,6 +133,24 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
return parsed, err
}
func (r *DualReadWriter) getConfiguredBranch() string {
cfg := r.repo.Config()
switch cfg.Spec.Type {
case provisioning.GitHubRepositoryType:
if cfg.Spec.GitHub != nil {
return cfg.Spec.GitHub.Branch
}
case provisioning.GitRepositoryType:
if cfg.Spec.Git != nil {
return cfg.Spec.Git.Branch
}
case provisioning.LocalRepositoryType:
// branches are not supported for local repositories
return ""
}
return ""
}
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
@@ -317,3 +337,126 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) er
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
fmt.Errorf("must be admin or editor to access folders with provisioning"))
}
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// if the ref is not the active branch, just delete the files from the branch
// do not delete the items from grafana itself
if opts.Ref != "" && opts.Ref != r.getConfiguredBranch() {
err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("error deleting folder from repository: %w", err)
}
return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
}
// before deleting from the repo, first get all children resources to delete from grafana afterwards
treeEntries, err := r.repo.ReadTree(ctx, "")
if err != nil {
return nil, fmt.Errorf("read repository tree: %w", err)
}
// note: parsedFolders will include the folder itself
parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries)
if err != nil {
return nil, fmt.Errorf("parse resources in folder: %w", err)
}
// delete from the repo
err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message)
if err != nil {
return nil, fmt.Errorf("delete folder from repository: %w", err)
}
// delete from grafana
ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, err
}
if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil {
return nil, fmt.Errorf("delete folder from grafana: %w", err)
}
return folderDeleteResponse(opts.Path, opts.Ref, r.repo.Config()), nil
}
func folderDeleteResponse(path, ref string, cfg *provisioning.Repository) *ParsedResource {
return &ParsedResource{
Action: provisioning.ResourceActionDelete,
Info: &repository.FileInfo{
Path: path,
Ref: ref,
},
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: cfg.Spec.Type,
Namespace: cfg.Namespace,
Name: cfg.Name,
Title: cfg.Spec.Title,
},
}
}
func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) {
var resourcesInFolder []repository.FileTreeEntry
var foldersInFolder []Folder
for _, entry := range treeEntries {
// the folder itself should be included in this, to do that, trim the suffix of the folder path and see if it matches exactly
if !strings.HasPrefix(entry.Path, folderPath) && entry.Path != strings.TrimSuffix(folderPath, "/") {
continue
}
// folders cannot be parsed as resources, so handle them separately
if entry.Blob {
resourcesInFolder = append(resourcesInFolder, entry)
} else {
folder := ParseFolder(entry.Path, r.repo.Config().Name)
foldersInFolder = append(foldersInFolder, folder)
}
}
var parsedResources []*ParsedResource
for _, entry := range resourcesInFolder {
fileInfo, err := r.repo.Read(ctx, entry.Path, "")
if err != nil && !apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf("could not find resource in repository: %w", err)
}
parsed, err := r.parser.Parse(ctx, fileInfo)
if err != nil {
return nil, nil, fmt.Errorf("could not parse resource: %w", err)
}
parsedResources = append(parsedResources, parsed)
}
return parsedResources, foldersInFolder, nil
}
func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error {
for _, parsed := range childrenResources {
err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to delete nested resource from grafana: %w", err)
}
}
// we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it
sort.Slice(folders, func(i, j int) bool {
depthI := strings.Count(folders[i].Path, "/")
depthJ := strings.Count(folders[j].Path, "/")
return depthI > depthJ
})
for _, f := range folders {
err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete folder from grafana: %w", err)
}
}
return nil
}