Files
grafana/pkg/registry/apis/provisioning/jobs/sync/full_test.go
f0c95a0a10 Provisioning: Add new error framework to handle folder creation failures gracefully. (#114824)
* Implement hierarchical error handling for folder creation failures

This commit implements hierarchical error handling to improve sync robustness
when folder creation fails. Instead of failing the entire sync, the system now:

1. Tracks failed folder creations and automatically skips nested resources
2. Records skipped resources with FileActionIgnored (doesn't count toward error limits)
3. Allows other folder hierarchies to continue processing
4. Prevents folder deletion when child resource deletions fail

Key Changes:

- Add PathCreationError type to track which folder path failed
- Modify progress recorder to automatically detect and track failures via Record()
- Add IsNestedUnderFailedCreation() and HasFailedDeletionsUnder() checks
- Update full and incremental sync to skip nested resources after folder failures
- Deletions proceed even if parent folder creation failed (resource may exist from previous sync)
- FileActionIgnored results don't count toward error limits

Example behavior improvement:
Before: /monitoring folder creation fails → all nested resources fail → other folders never processed
After: /monitoring folder creation fails → nested resources ignored → /applications folder succeeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* provisioning: refactor hierarchical errors in folder management.

* Move test to the corresponding package

* Refactor timeout handling in applyChanges functions

- Introduced wrapWithTimeout function to streamline timeout context management for applyChange calls.
- Updated applyFoldersSerially and applyIncrementalChanges to utilize the new timeout wrapper.
- Removed redundant logging and error handling code related to timeout in favor of centralized handling in wrapWithTimeout.
- Adjusted test expectations to reflect changes in error reporting for context deadlines.

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 09:07:04 +00:00

764 lines
30 KiB
Go

package sync
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"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"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
func TestFullSync_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{{}}, nil)
progress.On("SetTotal", mock.Anything, 1).Return()
err := FullSync(ctx, repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.EqualError(t, err, "context canceled")
}
func TestFullSync_Error(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
})
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("some error"))
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.EqualError(t, err, "compare changes: some error")
}
func TestFullSync_NoChanges(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
})
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{}, nil)
progress.On("SetFinalMessage", mock.Anything, "no changes to sync").Return()
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.NoError(t, err)
}
func TestFullSync_SuccessfulFolderCreation(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeFolder,
},
},
})
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]ResourceFileChange{}, nil)
progress.On("SetFinalMessage", mock.Anything, "no changes to sync").Return()
repoResources.On("EnsureFolderExists", mock.Anything, resources.Folder{
ID: "test-repo",
Title: "Test Repo",
Path: "",
}, "").Return(nil)
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.NoError(t, err)
}
func TestFullSync_FolderCreationFailed(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeFolder,
},
},
})
repoResources.On("EnsureFolderExists", mock.Anything, resources.Folder{
ID: "test-repo",
Title: "Test Repo",
Path: "",
}, "").Return(fmt.Errorf("folder creation failed"))
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.Error(t, err)
require.Contains(t, err.Error(), "create root folder: folder creation failed")
}
func TestFullSync_FolderCreationFailedWithInstanceTarget(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Sync: provisioning.SyncOptions{
Target: provisioning.SyncTargetTypeInstance,
},
},
})
// No folder creation should be attempted with instance target
// But we should still test the error path for completeness
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, fmt.Errorf("compare error"))
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
require.Error(t, err)
require.Contains(t, err.Error(), "compare changes: compare error")
}
func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo
tests := []struct {
name string
setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *MockCompareFn)
changes []ResourceFileChange
expectedError string
description string
}{
{
name: "too many errors",
description: "Should return an error when too many errors occur",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "dashboards/one.json",
},
{
Action: repository.FileActionCreated,
Path: "dashboards/two.json",
},
{
Action: repository.FileActionCreated,
Path: "dashboards/three.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
var callCount int64 = 0
progress.On("TooManyErrors").Return(func() error {
if atomic.AddInt64(&callCount, 1) > 1 {
return fmt.Errorf("too many errors")
}
return nil
})
progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool {
return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
})).Return(false).Maybe()
repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool {
return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json"
}), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
(result.Path == "dashboards/one.json" || result.Path == "dashboards/two.json" || result.Path == "dashboards/three.json")
})).Return().Maybe()
},
expectedError: "too many errors",
},
{
name: "successful apply with file creation",
description: "Should successfully apply changes when creating a new file",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "dashboards/test.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Action: repository.FileActionCreated,
Path: "dashboards/test.json",
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboards",
}).Return()
},
},
{
name: "failed apply with file creation",
description: "Should record an error when creating a new file",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "dashboards/test.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Path == "dashboards/test.json" &&
result.Name == "test-dashboard" &&
result.Kind == "Dashboard" &&
result.Group == "dashboards" &&
result.Error != nil &&
result.Error.Error() == "writing resource from file dashboards/test.json: write error"
})).Return()
},
},
{
name: "successful apply with file update",
description: "Should successfully apply changes when updating an existing file",
changes: []ResourceFileChange{
{
Action: repository.FileActionUpdated,
Path: "dashboards/test.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil)
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Action: repository.FileActionUpdated,
Path: "dashboards/test.json",
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboards",
}).Return()
},
},
{
name: "failed apply with file update",
description: "Should record an error when updating a file",
changes: []ResourceFileChange{
{
Action: repository.FileActionUpdated,
Path: "dashboards/test.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false)
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "").
Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error"))
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionUpdated &&
result.Path == "dashboards/test.json" &&
result.Name == "test-dashboard" &&
result.Kind == "Dashboard" &&
result.Group == "dashboards" &&
result.Error != nil &&
result.Error.Error() == "writing resource from file dashboards/test.json: write error"
})).Return()
},
},
{
name: "successful apply with folder creation",
description: "Should successfully apply changes when creating a new folder",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "one/two/three/",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)
repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil)
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Action: repository.FileActionCreated,
Path: "one/two/three/",
Name: "some-folder",
Kind: "Folder",
Group: "folder.grafana.app",
}).Return()
},
},
{
name: "failed apply folder creation",
description: "Should record an error when creating a new folder",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "one/two/three/",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false)
repoResources.On(
"EnsureFolderPathExist",
mock.Anything,
"one/two/three/",
).Return("some-folder", errors.New("folder creation error"))
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Path == "one/two/three/" &&
result.Name == "" &&
result.Kind == "Folder" &&
result.Group == "folder.grafana.app" &&
result.Error != nil &&
result.Error.Error() == "ensuring folder exists at path one/two/three/: folder creation error"
})).Return()
},
},
{
name: "successful apply with file deletion",
description: "Should successfully apply changes when deleting an existing file",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Existing: &provisioning.ResourceListItem{
Name: "test-dashboard",
Resource: "dashboards",
Group: "dashboards",
},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
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,
})
fakeDynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
return true, nil, nil
})
clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboards",
Resource: "dashboards",
}).Return(fakeDynamicClient.Resource(resources.DashboardResource), schema.GroupVersionKind{
Kind: "Dashboard",
Group: "dashboards",
Version: "v1",
}, nil)
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboards",
Error: nil,
}).Return()
},
},
{
name: "file delete error",
description: "Should return an error when deleting a file",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Existing: &provisioning.ResourceListItem{
Name: "test-dashboard",
Resource: "dashboards",
Group: "dashboards",
},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
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,
})
fakeDynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) {
return true, nil, fmt.Errorf("delete failed")
})
clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboards",
Resource: "dashboards",
}).Return(fakeDynamicClient.Resource(resources.DashboardResource), schema.GroupVersionKind{
Kind: "Dashboard",
Group: "dashboards",
Version: "v1",
}, nil)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionDeleted &&
result.Path == "dashboards/test.json" &&
result.Name == "test-dashboard" &&
result.Kind == "Dashboard" &&
result.Group == "dashboards" &&
result.Error != nil &&
result.Error.Error() == "deleting resource dashboards/Dashboard test-dashboard: delete failed"
})).Return()
},
},
{
name: "without existing for delete",
description: "Should record an error when deleting a file",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Existing: nil,
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionDeleted &&
result.Path == "dashboards/test.json" &&
result.Error != nil &&
result.Error.Error() == "processing deletion for file dashboards/test.json: missing existing reference"
})).Return()
},
},
{
name: "without existing name for delete",
description: "Should record an error when deleting a file",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Existing: &provisioning.ResourceListItem{},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionDeleted &&
result.Path == "dashboards/test.json" &&
result.Error != nil &&
result.Error.Error() == "processing deletion for file dashboards/test.json: missing existing reference"
})).Return()
},
},
{
name: "error finding client for delete",
description: "Should record an error when deleting a file",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Existing: &provisioning.ResourceListItem{
Name: "test-dashboard",
Group: "dashboards",
Resource: "dashboards",
},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboards",
Resource: "dashboards",
}).Return(nil, schema.GroupVersionKind{}, errors.New("didn't work"))
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Name: "test-dashboard",
Group: "dashboards",
Kind: "dashboards", // could not find a real kind
Action: repository.FileActionDeleted,
Path: "dashboards/test.json",
Error: fmt.Errorf("get client for deleted object: %w", errors.New("didn't work")),
}).Return()
},
},
{
name: "successful apply with folder deletion",
description: "Should successfully apply changes when deleting an existing folder",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "to-be-deleted/",
Existing: &provisioning.ResourceListItem{
Name: "test-folder",
Resource: "Folder",
Group: "folders",
},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
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,
})
fakeDynamicClient.PrependReactor("delete", "folders", func(action k8testing.Action) (bool, runtime.Object, error) {
return true, nil, nil
})
clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "folders",
Resource: "Folder",
}).Return(fakeDynamicClient.Resource(resources.FolderResource), schema.GroupVersionKind{
Kind: "Folder",
Group: "folders",
Version: "v1",
}, nil)
progress.On("Record", mock.Anything, jobs.JobResourceResult{
Action: repository.FileActionDeleted,
Path: "to-be-deleted/",
Name: "test-folder",
Kind: "Folder",
Group: "folders",
Error: nil,
}).Return()
},
},
{
name: "failed to apply with folder deletion",
description: "Should record an error when deleting a folder",
changes: []ResourceFileChange{
{
Action: repository.FileActionDeleted,
Path: "to-be-deleted/",
Existing: &provisioning.ResourceListItem{
Name: "test-folder",
Resource: "Folder",
Group: "folders",
},
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false)
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,
})
fakeDynamicClient.PrependReactor("delete", "folders", func(action k8testing.Action) (bool, runtime.Object, error) {
return true, nil, fmt.Errorf("delete failed")
})
clients.On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "folders",
Resource: "Folder",
}).Return(fakeDynamicClient.Resource(resources.FolderResource), schema.GroupVersionKind{
Kind: "Folder",
Group: "folders",
Version: "v1",
}, nil)
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionDeleted &&
result.Path == "to-be-deleted/" &&
result.Name == "test-folder" &&
result.Kind == "Folder" &&
result.Group == "folders" &&
result.Error != nil &&
result.Error.Error() == "deleting resource folders/Folder test-folder: delete failed"
})).Return()
},
},
{
name: "operation timeout after 15 seconds",
description: "Should record timeout error when operation takes longer than 15 seconds",
changes: []ResourceFileChange{
{
Action: repository.FileActionCreated,
Path: "dashboards/slow.json",
},
},
setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) {
progress.On("TooManyErrors").Return(nil)
progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false)
repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", "").
Run(func(args mock.Arguments) {
ctx := args.Get(0).(context.Context)
select {
case <-ctx.Done():
return
case <-time.After(20 * time.Second):
return
}
}).
Return("", schema.GroupVersionKind{}, context.DeadlineExceeded)
// applyChange records the error from WriteResourceFromFile
progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Action == repository.FileActionCreated &&
result.Path == "dashboards/slow.json" &&
result.Error != nil &&
result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded"
})).Return().Once()
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := repository.NewMockRepository(t)
repoResources := resources.NewMockRepositoryResources(t)
clients := resources.NewMockResourceClients(t)
progress := jobs.NewMockJobProgressRecorder(t)
compareFn := NewMockCompareFn(t)
tt.setupMocks(repo, repoResources, clients, progress, compareFn)
compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil)
repo.On("Config").Return(&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
},
})
progress.On("SetTotal", mock.Anything, len(tt.changes)).Return()
err := FullSync(context.Background(), repo, compareFn.Execute, clients, "current-ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
if tt.expectedError != "" {
require.EqualError(t, err, tt.expectedError, tt.description)
} else {
require.NoError(t, err, tt.description)
}
})
}
}