From 395a9db6c9411a028ace5e1539eb74e301ff2bd1 Mon Sep 17 00:00:00 2001 From: Roberto Jimenez Sanchez Date: Wed, 3 Dec 2025 09:13:08 +0100 Subject: [PATCH] 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 --- .../provisioning/jobs/export/resources.go | 8 +- .../jobs/export/resources_specific_test.go | 89 ++++++++++++++++--- .../apis/provisioning/jobs/export/worker.go | 2 +- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index 535649f58b5..6d4dedb639a 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -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) diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go index 1343502bb26..39edb0f5e33 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go @@ -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") } diff --git a/pkg/registry/apis/provisioning/jobs/export/worker.go b/pkg/registry/apis/provisioning/jobs/export/worker.go index 3543b687c2d..28804717b22 100644 --- a/pkg/registry/apis/provisioning/jobs/export/worker.go +++ b/pkg/registry/apis/provisioning/jobs/export/worker.go @@ -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