Provisioning: unit test export job (#103620)
* Add repository resources interface for export worker * Add mocks for repository resources * Add unit tests for ExportWorker's IsSupported method * Add unit tests for ExportWorker's Process method, covering scenarios for missing export settings, write permissions, branch restrictions, and client creation failures. * Fix unit tests * Single function * Add more unit tests * Add test for failed folder * Fail export folder errors * Add another test * Positive folder export * Too many folder export errors * Too many errors on folder export * Partial folder errors * Add test for nested folder * Add test dashboard export * More cases * Ignore existing dashboards * Fix folder tests * Fix clonable test * Add clone failure test * Add test clean up without push * Working tests * Use mock clonable * Add unit tests for IsWriteAllowed * Add behaviour to cover ref equal to configured branch * Fix worker test * Fix linting * Split clone and push * Wrapper for clone and push * Separate methods for resources export * Separate folder export * Simplify single signature * Refactor a bit more * Separate folder export function * Split it into different files * Add FIXME * Export function mock * Export Resources tests * Add test for cannot find client * Check for branch * Fix registry * Move folder export tests * Pass wrapper function * Add worker tests * Fail if branch is passed for clonable repositories * Fix merge issues
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
func ExportAll(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, folderClient dynamic.ResourceInterface, progress jobs.JobProgressRecorder) error {
|
||||
if err := ExportFolders(ctx, repoName, options, folderClient, repositoryResources, progress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ExportResources(ctx, options, clients, repositoryResources, progress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
func ExportFolders(ctx context.Context, repoName string, options provisioning.ExportJobOptions, folderClient dynamic.ResourceInterface, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
|
||||
// Load and write all folders
|
||||
// FIXME: we load the entire tree in memory
|
||||
progress.SetMessage(ctx, "read folder tree from API server")
|
||||
|
||||
tree := resources.NewEmptyFolderTree()
|
||||
if err := resources.ForEach(ctx, folderClient, func(item *unstructured.Unstructured) error {
|
||||
if tree.Count() >= resources.MaxNumberOfFolders {
|
||||
return errors.New("too many folders")
|
||||
}
|
||||
|
||||
// FIXME: repoName should be part of skip folder export
|
||||
return tree.AddUnstructured(item, repoName)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("load folder tree: %w", err)
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, "write folders to repository")
|
||||
err := repositoryResources.EnsureFolderTreeExists(ctx, options.Branch, options.Path, tree, func(folder resources.Folder, created bool, err error) error {
|
||||
result := jobs.JobResourceResult{
|
||||
Action: repository.FileActionCreated,
|
||||
Name: folder.ID,
|
||||
Resource: resources.FolderResource.Resource,
|
||||
Group: resources.FolderResource.Group,
|
||||
Path: folder.Path,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
if !created {
|
||||
result.Action = repository.FileActionIgnored
|
||||
}
|
||||
|
||||
progress.Record(ctx, result)
|
||||
if err := progress.TooManyErrors(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write folders to repository: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
k8testing "k8s.io/client-go/testing"
|
||||
)
|
||||
|
||||
func TestExportFolders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reactorFunc func(action k8testing.Action) (bool, runtime.Object, error)
|
||||
expectedError string
|
||||
setupProgress func(progress *jobs.MockJobProgressRecorder)
|
||||
setupResources func(repoResources *resources.MockRepositoryResources)
|
||||
}{
|
||||
{
|
||||
name: "list folders error",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("failed to list folders")
|
||||
},
|
||||
expectedError: "load folder tree: error executing list: failed to list folders",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many folders",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
list := &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
Items: make([]metav1.PartialObjectMetadata, resources.MaxNumberOfFolders+1),
|
||||
}
|
||||
for i := 0; i <= resources.MaxNumberOfFolders; i++ {
|
||||
list.Items[i] = metav1.PartialObjectMetadata{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("folder-%d", i),
|
||||
},
|
||||
}
|
||||
}
|
||||
return true, list, nil
|
||||
},
|
||||
expectedError: "load folder tree: too many folders",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ensure folder tree error",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
// Return empty list to get past the folder loading
|
||||
return true, &metav1.PartialObjectMetadataList{}, nil
|
||||
},
|
||||
expectedError: "write folders to repository: failed to ensure folder tree",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.Anything, mock.Anything).Return(fmt.Errorf("failed to ensure folder tree"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful folder migration",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
list := &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folder-1",
|
||||
Annotations: map[string]string{
|
||||
"folder.grafana.app/uid": "folder-1-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folder-2",
|
||||
Annotations: map[string]string{
|
||||
"folder.grafana.app/uid": "folder-2-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return true, list, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return()
|
||||
progress.On("SetMessage", mock.Anything, "write folders to repository").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "folder-1-uid" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "folder-2-uid" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool {
|
||||
return tree.Count() == 2
|
||||
}), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool {
|
||||
require.NoError(t, fn(resources.Folder{ID: "folder-1-uid", Path: "grafana/folder-1"}, true, nil))
|
||||
require.NoError(t, fn(resources.Folder{ID: "folder-2-uid", Path: "grafana/folder-2"}, true, nil))
|
||||
|
||||
return true
|
||||
})).Return(nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful folder migration with resource export errors",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
list := &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folder-1",
|
||||
Annotations: map[string]string{
|
||||
"folder.grafana.app/uid": "folder-1-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folder-2",
|
||||
Annotations: map[string]string{
|
||||
"folder.grafana.app/uid": "folder-2-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return true, list, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return()
|
||||
progress.On("SetMessage", mock.Anything, "write folders to repository").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "folder-1-uid" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "didn't work"
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "folder-2-uid" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool {
|
||||
return tree.Count() == 2
|
||||
}), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool {
|
||||
require.NoError(t, fn(resources.Folder{ID: "folder-1-uid", Path: "grafana/folder-1"}, false, errors.New("didn't work")))
|
||||
require.NoError(t, fn(resources.Folder{ID: "folder-2-uid", Path: "grafana/folder-2"}, true, nil))
|
||||
|
||||
return true
|
||||
})).Return(nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many errors",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
list := &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folder-1",
|
||||
Annotations: map[string]string{
|
||||
"folder.grafana.app/uid": "folder-1-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return true, list, nil
|
||||
},
|
||||
expectedError: "write folders to repository: too many errors encountered",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return()
|
||||
progress.On("SetMessage", mock.Anything, "write folders to repository").Return()
|
||||
progress.On("Record", mock.Anything, mock.Anything).Return()
|
||||
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered"))
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool {
|
||||
return tree.Count() == 1
|
||||
}), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool {
|
||||
require.Error(t, fn(resources.Folder{ID: "folder-1-uid", Path: "grafana/folder-1"}, true, nil), "too many errors encountered")
|
||||
return true
|
||||
})).Return(fmt.Errorf("too many errors encountered"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "successful nested folder migration",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
if action.GetResource() == resources.DashboardResource {
|
||||
// Return empty dashboard list
|
||||
return true, &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
list := &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "FolderList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "parent-folder",
|
||||
},
|
||||
},
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.FolderResource.GroupVersion().String(),
|
||||
Kind: "Folder",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "child-folder",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "parent-folder",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return true, list, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "read folder tree from API server").Return()
|
||||
progress.On("SetMessage", mock.Anything, "write folders to repository").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "parent-uid" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "child-uid" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources) {
|
||||
repoResources.On("EnsureFolderTreeExists", mock.Anything, "feature/branch", "grafana", mock.MatchedBy(func(tree resources.FolderTree) bool {
|
||||
expectedFolders := []resources.Folder{
|
||||
{ID: "parent-folder", Path: "parent-folder"},
|
||||
{ID: "child-folder", Path: "parent-folder/child-folder"},
|
||||
}
|
||||
|
||||
if tree.Count() != len(expectedFolders) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, folder := range expectedFolders {
|
||||
dir, ok := tree.DirPath(folder.ID, "")
|
||||
if !ok || dir.Path != folder.Path {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}), mock.MatchedBy(func(fn func(folder resources.Folder, created bool, err error) error) bool {
|
||||
// Parent folder should be processed first
|
||||
require.NoError(t, fn(resources.Folder{ID: "parent-uid", Path: "grafana/parent-folder"}, true, nil))
|
||||
// Then child folder with nested path
|
||||
require.NoError(t, fn(resources.Folder{ID: "child-uid", Path: "grafana/parent-folder/child-folder"}, true, nil))
|
||||
return true
|
||||
})).Return(nil)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||
listGVK := schema.GroupVersionKind{
|
||||
Group: resources.FolderResource.Group,
|
||||
Version: resources.FolderResource.Version,
|
||||
Kind: "FolderList",
|
||||
}
|
||||
|
||||
scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
|
||||
scheme.AddKnownTypeWithName(schema.GroupVersionKind{
|
||||
Group: resources.FolderResource.Group,
|
||||
Version: resources.FolderResource.Version,
|
||||
Kind: resources.FolderResource.Resource,
|
||||
}, &metav1.PartialObjectMetadata{})
|
||||
|
||||
fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
|
||||
resources.FolderResource: listGVK.Kind,
|
||||
})
|
||||
fakeFolderClient := fakeDynamicClient.Resource(resources.FolderResource)
|
||||
fakeDynamicClient.PrependReactor("list", "folders", tt.reactorFunc)
|
||||
mockProgress := jobs.NewMockJobProgressRecorder(t)
|
||||
tt.setupProgress(mockProgress)
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
tt.setupResources(repoResources)
|
||||
|
||||
err := ExportFolders(context.Background(), "test-repo", v0alpha1.ExportJobOptions{
|
||||
Path: "grafana",
|
||||
Branch: "feature/branch",
|
||||
}, fakeFolderClient, repoResources, mockProgress)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
require.EqualError(t, err, tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
repoResources.AssertExpectations(t)
|
||||
mockProgress.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package export
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
jobs "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
dynamic "k8s.io/client-go/dynamic"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
resources "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
// MockExportFn is an autogenerated mock type for the ExportFn type
|
||||
type MockExportFn struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockExportFn_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockExportFn) EXPECT() *MockExportFn_Expecter {
|
||||
return &MockExportFn_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Execute provides a mock function with given fields: ctx, repoName, options, clients, repositoryResources, folderClient, progress
|
||||
func (_m *MockExportFn) Execute(ctx context.Context, repoName string, options v0alpha1.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, folderClient dynamic.ResourceInterface, progress jobs.JobProgressRecorder) error {
|
||||
ret := _m.Called(ctx, repoName, options, clients, repositoryResources, folderClient, progress)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Execute")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, v0alpha1.ExportJobOptions, resources.ResourceClients, resources.RepositoryResources, dynamic.ResourceInterface, jobs.JobProgressRecorder) error); ok {
|
||||
r0 = rf(ctx, repoName, options, clients, repositoryResources, folderClient, progress)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockExportFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
|
||||
type MockExportFn_Execute_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Execute is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repoName string
|
||||
// - options v0alpha1.ExportJobOptions
|
||||
// - clients resources.ResourceClients
|
||||
// - repositoryResources resources.RepositoryResources
|
||||
// - folderClient dynamic.ResourceInterface
|
||||
// - progress jobs.JobProgressRecorder
|
||||
func (_e *MockExportFn_Expecter) Execute(ctx interface{}, repoName interface{}, options interface{}, clients interface{}, repositoryResources interface{}, folderClient interface{}, progress interface{}) *MockExportFn_Execute_Call {
|
||||
return &MockExportFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repoName, options, clients, repositoryResources, folderClient, progress)}
|
||||
}
|
||||
|
||||
func (_c *MockExportFn_Execute_Call) Run(run func(ctx context.Context, repoName string, options v0alpha1.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, folderClient dynamic.ResourceInterface, progress jobs.JobProgressRecorder)) *MockExportFn_Execute_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(v0alpha1.ExportJobOptions), args[3].(resources.ResourceClients), args[4].(resources.RepositoryResources), args[5].(dynamic.ResourceInterface), args[6].(jobs.JobProgressRecorder))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExportFn_Execute_Call) Return(_a0 error) *MockExportFn_Execute_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockExportFn_Execute_Call) RunAndReturn(run func(context.Context, string, v0alpha1.ExportJobOptions, resources.ResourceClients, resources.RepositoryResources, dynamic.ResourceInterface, jobs.JobProgressRecorder) error) *MockExportFn_Execute_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockExportFn creates a new instance of MockExportFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockExportFn(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockExportFn {
|
||||
mock := &MockExportFn{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Code generated by mockery v2.52.4. DO NOT EDIT.
|
||||
|
||||
package export
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
repository "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockWrapWithCloneFn is an autogenerated mock type for the WrapWithCloneFn type
|
||||
type MockWrapWithCloneFn struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockWrapWithCloneFn_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockWrapWithCloneFn) EXPECT() *MockWrapWithCloneFn_Expecter {
|
||||
return &MockWrapWithCloneFn_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Execute provides a mock function with given fields: ctx, repo, cloneOptions, pushOptions, fn
|
||||
func (_m *MockWrapWithCloneFn) Execute(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error) error {
|
||||
ret := _m.Called(ctx, repo, cloneOptions, pushOptions, fn)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Execute")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error); ok {
|
||||
r0 = rf(ctx, repo, cloneOptions, pushOptions, fn)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockWrapWithCloneFn_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
|
||||
type MockWrapWithCloneFn_Execute_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Execute is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - repo repository.Repository
|
||||
// - cloneOptions repository.CloneOptions
|
||||
// - pushOptions repository.PushOptions
|
||||
// - fn func(repository.Repository , bool) error
|
||||
func (_e *MockWrapWithCloneFn_Expecter) Execute(ctx interface{}, repo interface{}, cloneOptions interface{}, pushOptions interface{}, fn interface{}) *MockWrapWithCloneFn_Execute_Call {
|
||||
return &MockWrapWithCloneFn_Execute_Call{Call: _e.mock.On("Execute", ctx, repo, cloneOptions, pushOptions, fn)}
|
||||
}
|
||||
|
||||
func (_c *MockWrapWithCloneFn_Execute_Call) Run(run func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repository.Repository, bool) error)) *MockWrapWithCloneFn_Execute_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(repository.Repository), args[2].(repository.CloneOptions), args[3].(repository.PushOptions), args[4].(func(repository.Repository, bool) error))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWrapWithCloneFn_Execute_Call) Return(_a0 error) *MockWrapWithCloneFn_Execute_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockWrapWithCloneFn_Execute_Call) RunAndReturn(run func(context.Context, repository.Repository, repository.CloneOptions, repository.PushOptions, func(repository.Repository, bool) error) error) *MockWrapWithCloneFn_Execute_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockWrapWithCloneFn creates a new instance of MockWrapWithCloneFn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockWrapWithCloneFn(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockWrapWithCloneFn {
|
||||
mock := &MockWrapWithCloneFn{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
|
||||
progress.SetMessage(ctx, "start resource export")
|
||||
for _, kind := range resources.SupportedProvisioningResources {
|
||||
// skip from folders as we do them first... so only dashboards
|
||||
if kind == resources.FolderResource {
|
||||
continue
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, fmt.Sprintf("export %s", kind.Resource))
|
||||
client, _, err := clients.ForResource(kind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client for %s: %w", kind.Resource, err)
|
||||
}
|
||||
|
||||
if err := exportResource(ctx, options, client, repositoryResources, progress); err != nil {
|
||||
return fmt.Errorf("export %s: %w", kind.Resource, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportResource(ctx context.Context, options provisioning.ExportJobOptions, client dynamic.ResourceInterface, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
|
||||
return resources.ForEach(ctx, client, func(item *unstructured.Unstructured) error {
|
||||
fileName, err := repositoryResources.CreateResourceFileFromObject(ctx, item, resources.WriteOptions{
|
||||
Path: options.Path,
|
||||
Ref: options.Branch,
|
||||
})
|
||||
|
||||
gvk := item.GroupVersionKind()
|
||||
result := jobs.JobResourceResult{
|
||||
Name: item.GetName(),
|
||||
Resource: gvk.Kind,
|
||||
Group: gvk.Group,
|
||||
Action: repository.FileActionCreated,
|
||||
Path: fileName,
|
||||
}
|
||||
|
||||
if errors.Is(err, resources.ErrAlreadyInRepository) {
|
||||
result.Action = repository.FileActionIgnored
|
||||
} else if err != nil {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = err
|
||||
}
|
||||
|
||||
progress.Record(ctx, result)
|
||||
if err := progress.TooManyErrors(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||
k8testing "k8s.io/client-go/testing"
|
||||
)
|
||||
|
||||
func TestExportResources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reactorFunc func(action k8testing.Action) (bool, runtime.Object, error)
|
||||
expectedError string
|
||||
setupProgress func(progress *jobs.MockJobProgressRecorder)
|
||||
setupResources func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind)
|
||||
}{
|
||||
{
|
||||
name: "successful dashboard export",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
// Return dashboard list
|
||||
return true, &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "DashboardList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, nil)
|
||||
options := resources.WriteOptions{
|
||||
Path: "grafana",
|
||||
Ref: "feature/branch",
|
||||
}
|
||||
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "dashboard-1"
|
||||
}), options).Return("dashboard-1.json", nil)
|
||||
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "dashboard-2"
|
||||
}), options).Return("dashboard-2.json", nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client error",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("shouldn't happen")
|
||||
},
|
||||
expectedError: "get client for dashboards: didn't work",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, fmt.Errorf("didn't work"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard list error",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("failed to list dashboards")
|
||||
},
|
||||
expectedError: "export dashboards: error executing list: failed to list dashboards",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard export with errors",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "DashboardList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
|
||||
})).Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, nil)
|
||||
options := resources.WriteOptions{
|
||||
Path: "grafana",
|
||||
Ref: "feature/branch",
|
||||
}
|
||||
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "dashboard-1"
|
||||
}), options).Return("", fmt.Errorf("failed to export dashboard"))
|
||||
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "dashboard-2"
|
||||
}), options).Return("dashboard-2.json", nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard export too many errors",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "DashboardList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "dashboard-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedError: "export dashboards: too many errors encountered",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard"
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered"))
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, nil)
|
||||
options := resources.WriteOptions{
|
||||
Path: "grafana",
|
||||
Ref: "feature/branch",
|
||||
}
|
||||
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "dashboard-1"
|
||||
}), options).Return("", fmt.Errorf("failed to export dashboard"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores existing dashboards",
|
||||
reactorFunc: func(action k8testing.Action) (bool, runtime.Object, error) {
|
||||
return true, &metav1.PartialObjectMetadataList{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "DashboardList",
|
||||
},
|
||||
Items: []metav1.PartialObjectMetadata{
|
||||
{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: resources.DashboardResource.GroupVersion().String(),
|
||||
Kind: "Dashboard",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "existing-dashboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
expectedError: "",
|
||||
setupProgress: func(progress *jobs.MockJobProgressRecorder) {
|
||||
progress.On("SetMessage", mock.Anything, "start resource export").Return()
|
||||
progress.On("SetMessage", mock.Anything, "export dashboards").Return()
|
||||
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
|
||||
return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored
|
||||
})).Return()
|
||||
progress.On("TooManyErrors").Return(nil)
|
||||
},
|
||||
setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, dynamicClient *dynamicfake.FakeDynamicClient, gvk schema.GroupVersionKind) {
|
||||
resourceClients.On("ForResource", resources.DashboardResource).Return(dynamicClient.Resource(resources.DashboardResource), gvk, nil)
|
||||
options := resources.WriteOptions{
|
||||
Path: "grafana",
|
||||
Ref: "feature/branch",
|
||||
}
|
||||
|
||||
// Return true to indicate the file already exists, and provide the updated path
|
||||
repoResources.On("CreateResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
|
||||
return obj.GetName() == "existing-dashboard"
|
||||
}), options).Return("", resources.ErrAlreadyInRepository)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
require.NoError(t, metav1.AddMetaToScheme(scheme))
|
||||
listGVK := schema.GroupVersionKind{
|
||||
Group: resources.DashboardResource.Group,
|
||||
Version: resources.DashboardResource.Version,
|
||||
Kind: "DashboardList",
|
||||
}
|
||||
|
||||
scheme.AddKnownTypeWithName(listGVK, &metav1.PartialObjectMetadataList{})
|
||||
scheme.AddKnownTypeWithName(schema.GroupVersionKind{
|
||||
Group: resources.DashboardResource.Group,
|
||||
Version: resources.DashboardResource.Version,
|
||||
Kind: resources.DashboardResource.Resource,
|
||||
}, &metav1.PartialObjectMetadata{})
|
||||
|
||||
fakeDynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, map[schema.GroupVersionResource]string{
|
||||
resources.DashboardResource: listGVK.Kind,
|
||||
})
|
||||
|
||||
resourceClients := resources.NewMockResourceClients(t)
|
||||
fakeDynamicClient.PrependReactor("list", "dashboards", tt.reactorFunc)
|
||||
|
||||
mockProgress := jobs.NewMockJobProgressRecorder(t)
|
||||
tt.setupProgress(mockProgress)
|
||||
|
||||
repoResources := resources.NewMockRepositoryResources(t)
|
||||
tt.setupResources(repoResources, resourceClients, fakeDynamicClient, listGVK)
|
||||
|
||||
options := v0alpha1.ExportJobOptions{
|
||||
Path: "grafana",
|
||||
Branch: "feature/branch",
|
||||
}
|
||||
|
||||
err := ExportResources(context.Background(), options, resourceClients, repoResources, mockProgress)
|
||||
if tt.expectedError != "" {
|
||||
require.EqualError(t, err, tt.expectedError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
mockProgress.AssertExpectations(t)
|
||||
repoResources.AssertExpectations(t)
|
||||
resourceClients.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,37 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
//go:generate mockery --name ExportFn --structname MockExportFn --inpackage --filename mock_export_fn.go --with-expecter
|
||||
type ExportFn func(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, folderClient dynamic.ResourceInterface, progress jobs.JobProgressRecorder) error
|
||||
|
||||
//go:generate mockery --name WrapWithCloneFn --structname MockWrapWithCloneFn --inpackage --filename mock_wrap_with_clone_fn.go --with-expecter
|
||||
type WrapWithCloneFn func(ctx context.Context, repo repository.Repository, cloneOptions repository.CloneOptions, pushOptions repository.PushOptions, fn func(repo repository.Repository, cloned bool) error) error
|
||||
|
||||
type ExportWorker struct {
|
||||
clientFactory resources.ClientFactory
|
||||
repositoryResources resources.RepositoryResourcesFactory
|
||||
exportFn ExportFn
|
||||
wrapWithCloneFn WrapWithCloneFn
|
||||
}
|
||||
|
||||
func NewExportWorker(
|
||||
clientFactory resources.ClientFactory,
|
||||
repositoryResources resources.RepositoryResourcesFactory,
|
||||
exportFn ExportFn,
|
||||
wrapWithCloneFn WrapWithCloneFn,
|
||||
) *ExportWorker {
|
||||
return &ExportWorker{
|
||||
clientFactory: clientFactory,
|
||||
repositoryResources: repositoryResources,
|
||||
exportFn: exportFn,
|
||||
wrapWithCloneFn: wrapWithCloneFn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +63,11 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
|
||||
PushOnWrites: false,
|
||||
BeforeFn: func() error {
|
||||
progress.SetMessage(ctx, "clone target")
|
||||
// :( the branch is now baked into the repo
|
||||
if options.Branch != "" {
|
||||
return fmt.Errorf("branch is not supported for clonable repositories")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -65,20 +81,12 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
|
||||
},
|
||||
}
|
||||
|
||||
fn := func(repo repository.Repository, cloned bool) error {
|
||||
if cloned {
|
||||
options.Branch = "" // :( the branch is now baked into the repo
|
||||
}
|
||||
|
||||
// Load and write all folders
|
||||
// FIXME: we load the entire tree in memory
|
||||
progress.SetMessage(ctx, "read folder tree from API server")
|
||||
fn := func(repo repository.Repository, _ bool) error {
|
||||
clients, err := r.clientFactory.Clients(ctx, cfg.Namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create clients: %w", err)
|
||||
}
|
||||
|
||||
tree := resources.NewEmptyFolderTree()
|
||||
folderClient, err := clients.Folder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create folder client: %w", err)
|
||||
@@ -94,88 +102,8 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
|
||||
return fmt.Errorf("create repository resource client: %w", err)
|
||||
}
|
||||
|
||||
if err := resources.ForEach(ctx, folderClient, func(item *unstructured.Unstructured) error {
|
||||
if tree.Count() >= resources.MaxNumberOfFolders {
|
||||
return errors.New("too many folders")
|
||||
}
|
||||
|
||||
return tree.AddUnstructured(item, cfg.Name)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("load folder tree: %w", err)
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, "write folders to repository")
|
||||
err = repositoryResources.EnsureFolderTreeExists(ctx, options.Branch, options.Path, tree, func(folder resources.Folder, created bool, err error) error {
|
||||
result := jobs.JobResourceResult{
|
||||
Action: repository.FileActionCreated,
|
||||
Name: folder.ID,
|
||||
Resource: resources.FolderResource.Resource,
|
||||
Group: resources.FolderResource.Group,
|
||||
Path: folder.Path,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
if !created {
|
||||
result.Action = repository.FileActionIgnored
|
||||
}
|
||||
|
||||
progress.Record(ctx, result)
|
||||
if err := progress.TooManyErrors(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("write folders to repository: %w", err)
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, "start resource export")
|
||||
for _, kind := range resources.SupportedProvisioningResources {
|
||||
// skip from folders as we do them first... so only dashboards
|
||||
if kind == resources.FolderResource {
|
||||
continue
|
||||
}
|
||||
|
||||
progress.SetMessage(ctx, fmt.Sprintf("export %s", kind.Resource))
|
||||
client, _, err := clients.ForResource(kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resources.ForEach(ctx, client, func(item *unstructured.Unstructured) error {
|
||||
result := jobs.JobResourceResult{
|
||||
Name: item.GetName(),
|
||||
Resource: kind.Resource,
|
||||
Group: kind.Group,
|
||||
Action: repository.FileActionCreated,
|
||||
}
|
||||
|
||||
fileName, err := repositoryResources.CreateResourceFileFromObject(ctx, item, resources.WriteOptions{
|
||||
Path: options.Path,
|
||||
Ref: options.Branch,
|
||||
})
|
||||
if errors.Is(err, resources.ErrAlreadyInRepository) {
|
||||
result.Action = repository.FileActionIgnored
|
||||
} else if err != nil {
|
||||
result.Action = repository.FileActionIgnored
|
||||
result.Error = err
|
||||
}
|
||||
result.Path = fileName
|
||||
progress.Record(ctx, result)
|
||||
|
||||
if err := progress.TooManyErrors(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("export %s: %w", kind.Resource, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, folderClient, progress)
|
||||
}
|
||||
|
||||
return repository.WrapWithCloneAndPushIfPossible(ctx, repo, cloneOptions, pushOptions, fn)
|
||||
return r.wrapWithCloneFn(ctx, repo, cloneOptions, pushOptions, fn)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -547,7 +547,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
|
||||
exportWorker := export.NewExportWorker(
|
||||
b.clients,
|
||||
b.repositoryResources,
|
||||
export.ExportAll,
|
||||
repository.WrapWithCloneAndPushIfPossible,
|
||||
)
|
||||
|
||||
syncWorker := sync.NewSyncWorker(
|
||||
b.GetClient(),
|
||||
b.parsers,
|
||||
|
||||
Reference in New Issue
Block a user