fix: restrict specific resource export to folder sync targets only

Specific resource export is only supported for repositories with folder
sync targets. Instance sync targets should use the full export flow instead.

Changes:
- Add repository config parameter to ExportSpecificResources function
- Validate that sync target is 'folder' type, reject 'instance' type
- Update all tests to pass repository config with folder sync target
- Add test case for instance sync target rejection
This commit is contained in:
Roberto Jimenez Sanchez
2025-12-03 09:13:08 +01:00
parent 4f5235c02b
commit 395a9db6c9
3 changed files with 85 additions and 14 deletions
@@ -105,11 +105,17 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
// ExportSpecificResources exports a list of specific resources identified by ResourceRef entries.
// It validates that resources are not folders, are supported, and are unmanaged.
func ExportSpecificResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
// Specific resource export only works with folder sync targets, not instance sync targets.
func ExportSpecificResources(ctx context.Context, repoConfig *provisioning.Repository, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
if len(options.Resources) == 0 {
return errors.New("no resources specified for export")
}
// Validate that specific resource export is only used with folder sync targets
if repoConfig.Spec.Sync.Target != provisioning.SyncTargetTypeFolder {
return fmt.Errorf("specific resource export is only supported for folder sync targets, but repository has target type '%s'", repoConfig.Spec.Sync.Target)
}
progress.SetMessage(ctx, "exporting specific resources")
tree, err := loadUnmanagedFolderTree(ctx, clients, progress)
@@ -17,6 +17,28 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// Helper function to create a repository config with folder sync target
func createFolderSyncRepository() *provisioningV0.Repository {
return &provisioningV0.Repository{
Spec: provisioningV0.RepositorySpec{
Sync: provisioningV0.SyncOptions{
Target: provisioningV0.SyncTargetTypeFolder,
},
},
}
}
// Helper function to create a repository config with instance sync target
func createInstanceSyncRepository() *provisioningV0.Repository {
return &provisioningV0.Repository{
Spec: provisioningV0.RepositorySpec{
Sync: provisioningV0.SyncOptions{
Target: provisioningV0.SyncTargetTypeInstance,
},
},
}
}
// Helper function to create folder objects
func createFolderObject(name, uid, parentFolderUID string) unstructured.Unstructured {
folder := unstructured.Unstructured{
@@ -50,7 +72,7 @@ func createDashboardObjectWithFolder(name, folderID string) unstructured.Unstruc
}
// Helper function to run ExportSpecificResources test
func runExportSpecificResourcesTest(t *testing.T, resourceRefs []provisioningV0.ResourceRef, folderItems []unstructured.Unstructured, setupProgress func(*jobs.MockJobProgressRecorder), setupResources func(*resources.MockRepositoryResources, *resources.MockResourceClients)) error {
func runExportSpecificResourcesTest(t *testing.T, repoConfig *provisioningV0.Repository, resourceRefs []provisioningV0.ResourceRef, folderItems []unstructured.Unstructured, setupProgress func(*jobs.MockJobProgressRecorder), setupResources func(*resources.MockRepositoryResources, *resources.MockResourceClients)) error {
resourceClients := resources.NewMockResourceClients(t)
mockProgress := jobs.NewMockJobProgressRecorder(t)
setupProgress(mockProgress)
@@ -64,7 +86,7 @@ func runExportSpecificResourcesTest(t *testing.T, resourceRefs []provisioningV0.
Resources: resourceRefs,
}
err := ExportSpecificResources(context.Background(), options, resourceClients, repoResources, mockProgress)
err := ExportSpecificResources(context.Background(), repoConfig, options, resourceClients, repoResources, mockProgress)
mockProgress.AssertExpectations(t)
repoResources.AssertExpectations(t)
@@ -74,6 +96,8 @@ func runExportSpecificResourcesTest(t *testing.T, resourceRefs []provisioningV0.
}
func TestExportSpecificResources_Success(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "dashboard-1",
@@ -148,11 +172,13 @@ func TestExportSpecificResources_Success(t *testing.T) {
})).Return("grafana/dashboard-2.json", nil)
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_EmptyResources(t *testing.T) {
repoConfig := createFolderSyncRepository()
options := provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
@@ -163,11 +189,36 @@ func TestExportSpecificResources_EmptyResources(t *testing.T) {
repoResources := resources.NewMockRepositoryResources(t)
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := ExportSpecificResources(context.Background(), options, resourceClients, repoResources, mockProgress)
err := ExportSpecificResources(context.Background(), repoConfig, options, resourceClients, repoResources, mockProgress)
require.EqualError(t, err, "no resources specified for export")
}
func TestExportSpecificResources_RejectsInstanceSyncTarget(t *testing.T) {
repoConfig := createInstanceSyncRepository()
options := provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
Resources: []provisioningV0.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
Group: resources.DashboardResource.Group,
},
},
}
resourceClients := resources.NewMockResourceClients(t)
repoResources := resources.NewMockRepositoryResources(t)
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := ExportSpecificResources(context.Background(), repoConfig, options, resourceClients, repoResources, mockProgress)
require.EqualError(t, err, "specific resource export is only supported for folder sync targets, but repository has target type 'instance'")
}
func TestExportSpecificResources_RejectsFolders(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "my-folder",
@@ -196,11 +247,13 @@ func TestExportSpecificResources_RejectsFolders(t *testing.T) {
// No ForKind or WriteResourceFileFromObject calls expected for folders
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_RejectsManagedResources(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "managed-dashboard",
@@ -246,11 +299,13 @@ func TestExportSpecificResources_RejectsManagedResources(t *testing.T) {
// No WriteResourceFileFromObject call expected for managed resources
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_RejectsUnsupportedResources(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "some-resource",
@@ -292,11 +347,13 @@ func TestExportSpecificResources_RejectsUnsupportedResources(t *testing.T) {
// No WriteResourceFileFromObject call expected for unsupported resources
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_FolderPathResolution(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "dashboard-in-nested-folder",
@@ -346,11 +403,13 @@ func TestExportSpecificResources_FolderPathResolution(t *testing.T) {
})).Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil)
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_FolderClientError(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "dashboard-1",
@@ -368,11 +427,13 @@ func TestExportSpecificResources_FolderClientError(t *testing.T) {
resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error"))
}
err := runExportSpecificResourcesTest(t, resourceRefs, nil, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, nil, setupProgress, setupResources)
require.EqualError(t, err, "get folder client: folder client error")
}
func TestExportSpecificResources_ResourceNotFound(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "non-existent-dashboard",
@@ -410,11 +471,13 @@ func TestExportSpecificResources_ResourceNotFound(t *testing.T) {
resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboardClient, resources.DashboardResource, nil)
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_DashboardVersionConversion(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "v2-dashboard",
@@ -490,11 +553,13 @@ func TestExportSpecificResources_DashboardVersionConversion(t *testing.T) {
})).Return("grafana/v2-dashboard.json", nil)
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.NoError(t, err)
}
func TestExportSpecificResources_TooManyErrors(t *testing.T) {
repoConfig := createFolderSyncRepository()
resourceRefs := []provisioningV0.ResourceRef{
{
Name: "dashboard-1",
@@ -537,6 +602,6 @@ func TestExportSpecificResources_TooManyErrors(t *testing.T) {
mock.Anything).Return("", fmt.Errorf("write error"))
}
err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources)
err := runExportSpecificResourcesTest(t, repoConfig, resourceRefs, folderItems, setupProgress, setupResources)
require.EqualError(t, err, "too many errors")
}
@@ -104,7 +104,7 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
if len(options.Resources) > 0 {
progress.SetTotal(ctx, len(options.Resources))
progress.StrictMaxErrors(1) // Fail fast on any error during export
return ExportSpecificResources(ctx, *options, clients, repositoryResources, progress)
return ExportSpecificResources(ctx, cfg, *options, clients, repositoryResources, progress)
}
// Fall back to existing ExportAll behavior for backward compatibility