570 lines
18 KiB
Go
570 lines
18 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
|
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
|
)
|
|
|
|
func TestChanges(t *testing.T) {
|
|
t.Run("start the same", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "simplelocal/dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "simplelocal/dashboard.json", Hash: "xyz"},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes)
|
|
})
|
|
|
|
t.Run("create a source file", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "muta.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 1)
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "muta.json",
|
|
}, changes[0])
|
|
})
|
|
t.Run("empty file path", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
|
|
},
|
|
}
|
|
_, err := Changes(source, target)
|
|
require.EqualError(t, err, "empty path on a non folder")
|
|
})
|
|
|
|
t.Run("empty path with folder resource", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes)
|
|
})
|
|
|
|
t.Run("create empty folder structure for folders with unsupported file types", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "one/two/first.md", Hash: "xyz", Blob: true},
|
|
{Path: "other/second.md", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
target := &provisioning.ResourceList{}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 2)
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "one/two/",
|
|
}, changes[0])
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "other/",
|
|
}, changes[1])
|
|
})
|
|
|
|
t.Run("keep empty folders when unsupported file types are present", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "one/two/first.md", Hash: "xyz", Blob: true},
|
|
{Path: "other/second.md", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "one/two/", Resource: "folders"},
|
|
{Path: "other/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes)
|
|
})
|
|
t.Run("keep common path to unsupported file types", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "common/first.md", Hash: "xyz", Blob: true},
|
|
{Path: "alsocommon/second.md", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "common/", Resource: "folders"},
|
|
{Path: "common/not-common/", Resource: "folders", Name: "uncommon-name", Hash: "xyz"},
|
|
{Path: "alsocommon/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 1)
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionDeleted,
|
|
Path: "common/not-common/",
|
|
Existing: &provisioning.ResourceListItem{
|
|
Path: "common/not-common/",
|
|
Resource: "folders",
|
|
Name: "uncommon-name",
|
|
Hash: "xyz",
|
|
},
|
|
}, changes[0], "the uncommon path should be deleted")
|
|
})
|
|
|
|
t.Run("delete a source file", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "adsl62h.yaml", Hash: "xyz", Group: "dashboard.grafana.app", Resource: "dashboards", Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c"},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 1)
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionDeleted,
|
|
Path: "adsl62h.yaml",
|
|
Existing: &provisioning.ResourceListItem{
|
|
Path: "adsl62h.yaml",
|
|
Group: "dashboard.grafana.app",
|
|
Resource: "dashboards",
|
|
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
|
|
Hash: "xyz",
|
|
},
|
|
}, changes[0])
|
|
})
|
|
|
|
t.Run("folder deletion order", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "x/y/z/ignored.md", Blob: true}, // ignored
|
|
{Path: "aaa/bbb.yaml", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "a.json"},
|
|
{Path: "x/y/file.json"},
|
|
{Path: "aaa/", Resource: "folders"}, // Folder... no action required
|
|
{Path: "aaa/bbb.yaml", Hash: "xyz"},
|
|
{Path: "zzz/longest/path/here.json"},
|
|
{Path: "short/file.yml"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
|
|
order := make([]string, len(changes))
|
|
for i := range changes {
|
|
order[i] = changes[i].Path
|
|
}
|
|
require.Equal(t, []string{
|
|
"zzz/longest/path/here.json", // not sorted yet
|
|
"x/y/file.json",
|
|
"x/y/z/",
|
|
"short/file.yml",
|
|
"a.json",
|
|
}, order)
|
|
})
|
|
|
|
t.Run("modify a file", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "adsl62h.yaml", Hash: "modified", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{
|
|
Path: "adsl62h.yaml",
|
|
Group: "dashboard.grafana.app",
|
|
Resource: "dashboards",
|
|
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
|
|
Hash: "original",
|
|
},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 1)
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionUpdated,
|
|
Path: "adsl62h.yaml",
|
|
Existing: &provisioning.ResourceListItem{
|
|
Path: "adsl62h.yaml",
|
|
Group: "dashboard.grafana.app",
|
|
Resource: "dashboards",
|
|
Name: "adsl62h-hrw-f-fvlt2dghp-gufrc4lisksgmq-c",
|
|
Hash: "original",
|
|
},
|
|
}, changes[0])
|
|
})
|
|
|
|
t.Run("keep folder with hidden files", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder/.hidden.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes, "folder should be kept when it contains hidden files")
|
|
})
|
|
|
|
t.Run("keep folder with invalid hidden paths", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder/.invalid/path.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes, "folder should be kept when it contains invalid hidden paths")
|
|
})
|
|
|
|
t.Run("keep folder with hidden folders", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder/.hidden/valid.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes, "folder should be kept when it contains hidden folders")
|
|
})
|
|
|
|
t.Run("unhidden path from hidden file", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder/.hidden/dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder/", Resource: "folders"},
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes, "hidden file should not be unhidden")
|
|
})
|
|
t.Run("hidden path to file", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "one/two/.hidden/dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{}
|
|
expected := []ResourceFileChange{
|
|
{
|
|
Action: repository.FileActionCreated,
|
|
Path: "one/two/",
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, changes)
|
|
})
|
|
t.Run("hidden path to folder", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "one/two/.hidden/folder/", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{}
|
|
expected := []ResourceFileChange{
|
|
{
|
|
Action: repository.FileActionCreated,
|
|
Path: "one/two/",
|
|
},
|
|
}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, changes)
|
|
})
|
|
t.Run("hidden at the root", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: ".hidden/dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
target := &provisioning.ResourceList{}
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes)
|
|
})
|
|
|
|
t.Run("nested folder with space is created correctly", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "abc/dash.json", Hash: "abc", Blob: true},
|
|
{Path: "abc/nested folder/nested-dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
target := &provisioning.ResourceList{}
|
|
|
|
expected := []ResourceFileChange{
|
|
{
|
|
Action: repository.FileActionCreated,
|
|
Path: "abc/nested folder/nested-dashboard.json",
|
|
},
|
|
{
|
|
Action: repository.FileActionCreated,
|
|
Path: "abc/dash.json",
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, changes, "Expected diff to correctly include nested folder contents")
|
|
})
|
|
|
|
t.Run("error on empty path for non-folder resource", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "", Hash: "xyz", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "", Resource: "dashboard", Group: "dashboard.grafana.app"},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "empty path on a non folder")
|
|
require.Nil(t, changes)
|
|
})
|
|
|
|
t.Run("complex nested folder hierarchy with mixed file types", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "root/folder1/dashboard.json", Hash: "abc", Blob: true},
|
|
{Path: "root/folder1/subfolder/.gitkeep", Hash: "def", Blob: true},
|
|
{Path: "root/folder2/alert.json", Hash: "ghi", Blob: true},
|
|
{Path: "root/folder2/README.md", Hash: "jkl", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "root/", Resource: "folders"},
|
|
{Path: "root/folder1/", Resource: "folders"},
|
|
{Path: "root/folder2/", Resource: "folders"},
|
|
{Path: "root/folder1/dashboard.json", Hash: "abc", Resource: "dashboard"},
|
|
{Path: "root/folder2/alert.json", Hash: "old", Resource: "alert"},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 2)
|
|
require.Equal(t, "root/folder1/subfolder/", changes[0].Path)
|
|
require.Equal(t, "root/folder2/alert.json", changes[1].Path)
|
|
})
|
|
|
|
t.Run("folder path suffix handling", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder1/", Hash: "abc", Blob: false},
|
|
{Path: "folder2/", Hash: "def", Blob: false},
|
|
{Path: "folder3", Hash: "ghi", Blob: false},
|
|
{Path: "folder4/", Hash: "jkl", Blob: false},
|
|
}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder1", Resource: resources.FolderResource.Resource, Group: resources.FolderResource.Group},
|
|
{Path: "folder2/", Resource: "folders", Group: resources.FolderResource.Group},
|
|
{Path: "folder3", Resource: "folders", Group: resources.FolderResource.Group},
|
|
{Path: "folder4/", Resource: "folders", Group: resources.FolderResource.Group},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Empty(t, changes, "Should handle folder paths with and without trailing slash")
|
|
})
|
|
|
|
t.Run("empty source with populated target", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{}
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "folder1/", Resource: "folders"},
|
|
{Path: "folder1/dashboard.json", Resource: "dashboard"},
|
|
{Path: "folder2/", Resource: "folders"},
|
|
},
|
|
}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 3)
|
|
|
|
// Verify deletion order (deepest first)
|
|
require.Equal(t, "folder1/dashboard.json", changes[0].Path)
|
|
require.Equal(t, "folder1/", changes[1].Path)
|
|
require.Equal(t, "folder2/", changes[2].Path)
|
|
|
|
for _, change := range changes {
|
|
require.Equal(t, repository.FileActionDeleted, change.Action)
|
|
}
|
|
})
|
|
|
|
t.Run("empty target with populated source", func(t *testing.T) {
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder1/", Hash: "abc", Blob: false},
|
|
{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
|
|
{Path: "folder2/", Hash: "ghi", Blob: false},
|
|
}
|
|
target := &provisioning.ResourceList{}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
require.Len(t, changes, 3) // Only non-blob entries should create changes
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder1/dashboard.json",
|
|
}, changes[0])
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder1/",
|
|
}, changes[1])
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder2/",
|
|
}, changes[2])
|
|
})
|
|
|
|
t.Run("report correct changes with .keep files", func(t *testing.T) {
|
|
// Replicating how `source` is actually being passed in `Changes` function
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "folder1/", Hash: "abc", Blob: false},
|
|
{Path: "folder1/.keep", Hash: "abc", Blob: true},
|
|
{Path: "folder1/dashboard.json", Hash: "def", Blob: true},
|
|
{Path: "folder2/", Hash: "ghi", Blob: false},
|
|
{Path: "folder2/.keep", Hash: "ghi", Blob: true},
|
|
}
|
|
target := &provisioning.ResourceList{}
|
|
|
|
changes, err := Changes(source, target)
|
|
require.NoError(t, err)
|
|
// 2 folders and 1 file, so 3 changes in total
|
|
require.Len(t, changes, 3)
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder1/dashboard.json",
|
|
}, changes[0])
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder1/",
|
|
}, changes[1])
|
|
|
|
require.Equal(t, ResourceFileChange{
|
|
Action: repository.FileActionCreated,
|
|
Path: "folder2/",
|
|
}, changes[2])
|
|
})
|
|
}
|
|
|
|
func TestCompare(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources)
|
|
expectedError string
|
|
expectedChanges []ResourceFileChange
|
|
description string
|
|
}{
|
|
{
|
|
name: "error listing current resources",
|
|
description: "Should return error when listing current resources fails",
|
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
|
|
repoResources.On("List", mock.Anything).Return(nil, fmt.Errorf("listing failed"))
|
|
},
|
|
expectedError: "error listing current: listing failed",
|
|
},
|
|
{
|
|
name: "error reading tree",
|
|
description: "Should return error when reading tree fails",
|
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
|
|
repoResources.On("List", mock.Anything).Return(&provisioning.ResourceList{}, nil)
|
|
repo.On("ReadTree", mock.Anything, "current-ref").Return(nil, fmt.Errorf("read tree failed"))
|
|
},
|
|
expectedError: "error reading tree: read tree failed",
|
|
},
|
|
{
|
|
name: "no changes between source and target",
|
|
description: "Should return empty changes when source and target are identical",
|
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
{Path: "dashboard.json", Hash: "xyz"},
|
|
},
|
|
}
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
|
|
repoResources.On("List", mock.Anything).Return(target, nil)
|
|
repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
|
|
},
|
|
expectedChanges: []ResourceFileChange{},
|
|
},
|
|
{
|
|
name: "compare function error",
|
|
description: "Should return error when comparing fails",
|
|
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources) {
|
|
target := &provisioning.ResourceList{
|
|
Items: []provisioning.ResourceListItem{
|
|
// Empty path to trigger error
|
|
{Path: "", Hash: "xyz", Resource: "dashboard", Group: "dashboard.grafana.app"},
|
|
},
|
|
}
|
|
source := []repository.FileTreeEntry{
|
|
{Path: "dashboard.json", Hash: "xyz", Blob: true},
|
|
}
|
|
repoResources.On("List", mock.Anything).Return(target, nil)
|
|
repo.On("ReadTree", mock.Anything, "current-ref").Return(source, nil)
|
|
},
|
|
expectedError: "calculate changes: empty path on a non folder",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
repo := repository.NewMockRepository(t)
|
|
repoResources := resources.NewMockRepositoryResources(t)
|
|
|
|
tt.setupMocks(repo, repoResources)
|
|
|
|
changes, err := Compare(context.Background(), repo, repoResources, "current-ref")
|
|
|
|
if tt.expectedError != "" {
|
|
require.EqualError(t, err, tt.expectedError, tt.description)
|
|
require.Nil(t, changes)
|
|
} else {
|
|
require.NoError(t, err, tt.description)
|
|
require.Equal(t, tt.expectedChanges, changes, tt.description)
|
|
}
|
|
})
|
|
}
|
|
}
|