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
|
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
|
var obj *provisioning.ResourceWrapper
|
||||||
code := http.StatusOK
|
code := http.StatusOK
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|||||||
@@ -360,5 +360,12 @@ func (r *localRepository) Delete(ctx context.Context, path string, ref string, c
|
|||||||
return err
|
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",
|
comment: "test delete with ref",
|
||||||
expectedErr: apierrors.NewBadRequest("local repository does not support 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 {
|
for _, tc := range testCases {
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package resources
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
|
||||||
authlib "github.com/grafana/authlib/types"
|
authlib "github.com/grafana/authlib/types"
|
||||||
"github.com/grafana/grafana-app-sdk/logging"
|
"github.com/grafana/grafana-app-sdk/logging"
|
||||||
@@ -76,9 +79,8 @@ func (r *DualReadWriter) Delete(ctx context.Context, opts DualWriteOptions) (*Pa
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement this
|
|
||||||
if safepath.IsDir(opts.Path) {
|
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
|
// 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
|
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
|
// CreateFolder creates a new folder in the repository
|
||||||
// FIXME: fix signature to return ParsedResource
|
// FIXME: fix signature to return ParsedResource
|
||||||
func (r *DualReadWriter) CreateFolder(ctx context.Context, opts DualWriteOptions) (*provisioning.ResourceWrapper, error) {
|
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(), "",
|
return apierrors.NewForbidden(FolderResource.GroupResource(), "",
|
||||||
fmt.Errorf("must be admin or editor to access folders with provisioning"))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -651,3 +652,126 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) {
|
|||||||
require.Nil(t, obj["status"], "should not have a status element")
|
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
@@ -0,0 +1 @@
|
|||||||
|
../../../../../devenv/dev-dashboards/panel-timeline/timeline-demo.json
|
||||||
Reference in New Issue
Block a user