mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 09:52:12 +08:00
Git sync: Implement folder deletion
This commit is contained in:

committed by
Roberto Jiménez Sánchez

parent
b41b233d7d
commit
bd81243bbb
@ -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
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package provisioning
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -651,3 +652,126 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) {
|
||||
require.Nil(t, obj["status"], "should not have a status element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_DeleteResources(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
helper := runGrafana(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const repo = "delete-test-repo"
|
||||
localTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
|
||||
"Name": repo,
|
||||
"SyncEnabled": true,
|
||||
"SyncTarget": "instance",
|
||||
})
|
||||
_, err := helper.Repositories.Resource.Create(ctx, localTmp, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// create the structure:
|
||||
// dashboard1.json
|
||||
// folder/
|
||||
// dashboard2.json
|
||||
// nested/
|
||||
// dashboard3.json
|
||||
dashboard1 := helper.LoadFile("testdata/all-panels.json")
|
||||
result := helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "dashboard1.json").
|
||||
Body(dashboard1).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
require.NoError(t, result.Error())
|
||||
dashboard2 := helper.LoadFile("testdata/text-options.json")
|
||||
result = helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "folder", "dashboard2.json").
|
||||
Body(dashboard2).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
require.NoError(t, result.Error())
|
||||
dashboard3 := helper.LoadFile("testdata/timeline-demo.json")
|
||||
result = helper.AdminREST.Post().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "folder", "nested", "dashboard3.json").
|
||||
Body(dashboard3).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
Do(ctx)
|
||||
require.NoError(t, result.Error())
|
||||
|
||||
helper.SyncAndWait(t, repo, nil)
|
||||
|
||||
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, len(dashboards.Items))
|
||||
|
||||
folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, len(folders.Items))
|
||||
|
||||
t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) {
|
||||
result := helper.AdminREST.Delete().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "dashboard1.json").
|
||||
Do(ctx)
|
||||
require.NoError(t, result.Error())
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json")
|
||||
require.Error(t, err)
|
||||
dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, len(dashboards.Items))
|
||||
})
|
||||
|
||||
t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) {
|
||||
// need to delete directly through the url, because the k8s client doesn't support `/` in a subresource
|
||||
// but that is needed by gitsync to know that it is a folder
|
||||
addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String()
|
||||
url := fmt.Sprintf("http://admin:admin@%s/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/%s/files/folder/", addr, repo)
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// should be deleted from the repo
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder")
|
||||
require.Error(t, err)
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json")
|
||||
require.Error(t, err)
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested")
|
||||
require.Error(t, err)
|
||||
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json")
|
||||
require.Error(t, err)
|
||||
|
||||
// all should be deleted from grafana
|
||||
for _, d := range dashboards.Items {
|
||||
_, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
for _, f := range folders.Items {
|
||||
_, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleting a non-existent file should fail", func(t *testing.T) {
|
||||
result := helper.AdminREST.Delete().
|
||||
Namespace("default").
|
||||
Resource("repositories").
|
||||
Name(repo).
|
||||
SubResource("files", "non-existent.json").
|
||||
Do(ctx)
|
||||
require.Error(t, result.Error())
|
||||
})
|
||||
}
|
||||
|
1
pkg/tests/apis/provisioning/testdata/timeline-demo.json
vendored
Symbolic link
1
pkg/tests/apis/provisioning/testdata/timeline-demo.json
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../../devenv/dev-dashboards/panel-timeline/timeline-demo.json
|
Reference in New Issue
Block a user