diff --git a/pkg/tests/apis/provisioning/export_resources_test.go b/pkg/tests/apis/provisioning/export_resources_test.go new file mode 100644 index 00000000000..f8c7f52a7a5 --- /dev/null +++ b/pkg/tests/apis/provisioning/export_resources_test.go @@ -0,0 +1,409 @@ +package provisioning + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_ExportSpecificResources(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create unmanaged dashboards directly in Grafana + dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create first dashboard") + dashboard1Name := dashboard1Obj.GetName() + + dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml") + dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create second dashboard") + dashboard2Name := dashboard2Obj.GetName() + + // Verify dashboards are unmanaged + dash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{}) + require.NoError(t, err) + manager1, found1 := dash1.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found1 || manager1 == "", "dashboard1 should be unmanaged") + + dash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{}) + require.NoError(t, err) + manager2, found2 := dash2.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found2 || manager2 == "", "dashboard2 should be unmanaged") + + // Create repository + const repo = "export-resources-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: 0, // No dashboards expected after sync (we'll export manually) + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Export specific dashboards using Resources field + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Path: "", + Resources: []provisioning.ResourceRef{ + { + Name: dashboard1Name, + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + { + Name: dashboard2Name, + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // Verify both dashboards were exported + dashboard1File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json") + dashboard2File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v2beta1.json") + + // Check dashboard1 + body1, err := os.ReadFile(dashboard1File) + require.NoError(t, err, "exported file should exist for dashboard1") + obj1 := map[string]any{} + err = json.Unmarshal(body1, &obj1) + require.NoError(t, err, "exported file should be valid JSON") + val, _, err := unstructured.NestedString(obj1, "metadata", "name") + require.NoError(t, err) + require.Equal(t, "test-v1", val) + + // Check dashboard2 + body2, err := os.ReadFile(dashboard2File) + require.NoError(t, err, "exported file should exist for dashboard2") + obj2 := map[string]any{} + err = json.Unmarshal(body2, &obj2) + require.NoError(t, err, "exported file should be valid JSON") + val, _, err = unstructured.NestedString(obj2, "metadata", "name") + require.NoError(t, err) + require.Equal(t, "test-v2beta1", val) +} + +func TestIntegrationProvisioning_ExportSpecificResourcesWithPath(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create unmanaged dashboard + dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create dashboard") + dashboardName := dashboardObj.GetName() + + // Create repository + const repo = "export-resources-path-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Export with custom path + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Path: "custom/path", + Resources: []provisioning.ResourceRef{ + { + Name: dashboardName, + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // Verify dashboard was exported to custom path + expectedFile := filepath.Join(helper.ProvisioningPath, "custom", "path", "test-dashboard-created-at-v1.json") + body, err := os.ReadFile(expectedFile) + require.NoError(t, err, "exported file should exist at custom path") + obj := map[string]any{} + err = json.Unmarshal(body, &obj) + require.NoError(t, err, "exported file should be valid JSON") + val, _, err := unstructured.NestedString(obj, "metadata", "name") + require.NoError(t, err) + require.Equal(t, "test-v1", val) +} + +func TestIntegrationProvisioning_ExportSpecificResourcesRejectsFolders(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create a folder + folder := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "folder.grafana.app/v1beta1", + "kind": "Folder", + "metadata": map[string]any{ + "name": "test-folder", + }, + "spec": map[string]any{ + "title": "Test Folder", + }, + }, + } + folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create folder") + folderName := folderObj.GetName() + + // Create repository + const repo = "export-reject-folders-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Try to export folder (should fail validation) + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: folderName, + Kind: "Folder", + Group: "folder.grafana.app", + }, + }, + }, + } + + // This should fail with validation error + body := asJSON(spec) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx) + + err = result.Error() + require.Error(t, err, "should fail validation when trying to export folder") + require.Contains(t, err.Error(), "folders are not supported", "error should mention folders are not supported") +} + +func TestIntegrationProvisioning_ExportSpecificResourcesRejectsManagedResources(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create a managed dashboard via repository sync + testRepo := TestRepo{ + Name: "managed-dashboard-repo", + Copies: map[string]string{ + "exportunifiedtorepository/dashboard-test-v1.yaml": "dashboard.json", + }, + ExpectedDashboards: 1, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Get the managed dashboard + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Len(t, dashboards.Items, 1, "should have one managed dashboard") + managedDashboard := dashboards.Items[0] + managedDashboardName := managedDashboard.GetName() + + // Verify it's managed + manager, found := managedDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, found && manager != "", "dashboard should be managed") + + // Create another repository for export + const exportRepo = "export-managed-reject-test-repo" + exportTestRepo := TestRepo{ + Name: exportRepo, + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, exportTestRepo) + + // Try to export managed dashboard (should fail) + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Resources: []provisioning.ResourceRef{ + { + Name: managedDashboardName, + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + + // This should fail because the resource is managed + body := asJSON(spec) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(exportRepo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx) + + // Wait for job to complete and check it failed + obj, err := result.Get() + require.NoError(t, err, "job should be created") + unstruct, ok := obj.(*unstructured.Unstructured) + require.True(t, ok, "should get unstructured object") + + // Wait for job to complete + job := helper.AwaitJob(t, ctx, unstruct) + lastState := mustNestedString(job.Object, "status", "state") + lastErrors := mustNestedStringSlice(job.Object, "status", "errors") + + // Job should fail with error about managed resource + require.Equal(t, string(provisioning.JobStateError), lastState, "job should fail") + require.NotEmpty(t, lastErrors, "job should have errors") + require.Contains(t, lastErrors[0], "managed", "error should mention managed resource") +} + +func TestIntegrationProvisioning_ExportSpecificResourcesWithFolderStructure(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Create an unmanaged folder + folder := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "folder.grafana.app/v1beta1", + "kind": "Folder", + "metadata": map[string]any{ + "name": "test-export-folder", + }, + "spec": map[string]any{ + "title": "Test Export Folder", + }, + }, + } + folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create folder") + folderUID := folderObj.GetUID() + + // Verify folder is unmanaged + manager, found := folderObj.GetAnnotations()[utils.AnnoKeyManagerIdentity] + require.True(t, !found || manager == "", "folder should be unmanaged") + + // Create unmanaged dashboard in the folder + dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml") + // Set folder UID in dashboard spec + unstructured.SetNestedField(dashboard.Object, string(folderUID), "spec", "folder") + + dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create dashboard in folder") + dashboardName := dashboardObj.GetName() + + // Create repository + const repo = "export-folder-structure-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Export dashboard (should preserve folder structure) + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Path: "", + Resources: []provisioning.ResourceRef{ + { + Name: dashboardName, + Kind: "Dashboard", + Group: "dashboard.grafana.app", + }, + }, + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // Verify dashboard was exported with folder structure + // The folder path should be included in the file path based on the folder title + // Folder title is "Test Export Folder", which gets slugified to "test-export-folder" + expectedFile := filepath.Join(helper.ProvisioningPath, "test-export-folder", "test-dashboard-created-at-v1.json") + body, err := os.ReadFile(expectedFile) + require.NoError(t, err, "exported file should exist with folder structure") + obj := map[string]any{} + err = json.Unmarshal(body, &obj) + require.NoError(t, err, "exported file should be valid JSON") + val, _, err := unstructured.NestedString(obj, "metadata", "name") + require.NoError(t, err) + require.Equal(t, "test-v1", val) +} + +func TestIntegrationProvisioning_ExportSpecificResourcesEmptyList(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + + // Create repository + const repo = "export-empty-resources-test-repo" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: 0, + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Try to export with empty resources list (should fail validation) + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Resources: []provisioning.ResourceRef{}, + }, + } + + // This should fail with validation error + body := asJSON(spec) + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(helper.Context()) + + err := result.Error() + require.Error(t, err, "should fail validation when resources list is empty") +} +