package provisioning import ( "context" "fmt" "os" "path" "path/filepath" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" 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_PullJobOwnershipProtection(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) helper := runGrafana(t) ctx := context.Background() // Create two repositories with folder targets and separate paths to avoid file conflicts const repo1 = "pulljob-repo-1" const repo2 = "pulljob-repo-2" // create both repos concurrently to reduce duration of this test var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() helper.CreateRepo(t, TestRepo{ Name: repo1, Path: path.Join(helper.ProvisioningPath, "repo1"), Target: "folder", Copies: map[string]string{ "testdata/all-panels.json": "dashboard1.json", }, SkipResourceAssertions: true, // will check both at the same time below to reduce duration of this test }) }() go func() { defer wg.Done() helper.CreateRepo(t, TestRepo{ Name: repo2, Path: path.Join(helper.ProvisioningPath, "repo2"), Target: "folder", Copies: map[string]string{ "testdata/timeline-demo.json": "dashboard2.json", }, SkipResourceAssertions: true, // will check both at the same time below to reduce duration of this test }) }() wg.Wait() require.EventuallyWithT(t, func(collect *assert.CollectT) { dashboards, err := helper.DashboardsV1.Resource.List(t.Context(), metav1.ListOptions{}) if err != nil { collect.Errorf("could not list dashboards error: %s", err.Error()) return } if len(dashboards.Items) != 2 { collect.Errorf("should have the expected dashboards after sync. got: %d. expected: %d", len(dashboards.Items), 2) return } folders, err := helper.Folders.Resource.List(t.Context(), metav1.ListOptions{}) if err != nil { collect.Errorf("could not list folders: error: %s", err.Error()) return } if len(folders.Items) != 2 { collect.Errorf("should have the expected folders after sync. got: %d. expected: %d", len(folders.Items), 2) return } assert.Len(collect, dashboards.Items, 2) assert.Len(collect, folders.Items, 2) }, waitTimeoutDefault, waitIntervalDefault, "should have the expected dashboards and folders after sync") // Test: Pull job should fail when trying to manage resources owned by another repository t.Run("pull job should fail when trying to manage resources owned by another repository", func(t *testing.T) { // Step 1: Try to add a file with the same UID as repo1's dashboard to repo2's directory // This simulates a scenario where repo2 tries to manage a resource that repo1 already owns const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (owned by repo1) // Copy the same file (same UID) to repo2's directory to create ownership conflict conflictingFilePath := "repo2/conflicting-dashboard.json" helper.CopyToProvisioningPath(t, "testdata/all-panels.json", conflictingFilePath) printFileTree(t, helper.ProvisioningPath) // Step 2: Try to pull repo2 - should fail due to ownership conflict job := helper.TriggerJobAndWaitForComplete(t, repo2, provisioning.JobSpec{ Action: provisioning.JobActionPull, Pull: &provisioning.SyncJobOptions{}, }) // Step 3: Verify the job failed with ownership conflict error jobObj := &provisioning.Job{} err := runtime.DefaultUnstructuredConverter.FromUnstructured(job.Object, jobObj) require.NoError(t, err) // The job completes with "warning" state instead of "error" state when it doesn't have too many errors t.Logf("Job state: %s", jobObj.Status.State) t.Logf("Job errors: %v", jobObj.Status.Errors) require.Equal(t, provisioning.JobStateWarning, jobObj.Status.State, "job should complete with warnings due to ownership conflicts") require.NotEmpty(t, jobObj.Status.Errors, "should have error details") // Check that error mentions ownership conflict found := false for _, errMsg := range jobObj.Status.Errors { t.Logf("Error message: %s", errMsg) if strings.Contains(errMsg, fmt.Sprintf("managed by repo '%s'", repo1)) && strings.Contains(errMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2)) { found = true break } } require.True(t, found, "should have ownership conflict error") // Step 4: Verify original resource is still owned by repo1 and unchanged originalDashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) require.NoError(t, err, "original dashboard should still exist") require.Equal(t, repo1, originalDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo1") // Clean up the conflicting file for subsequent tests err = os.Remove(filepath.Join(helper.ProvisioningPath, conflictingFilePath)) require.NoError(t, err, "should clean up conflicting file") }) // Test: Repositories should not delete resources owned by other repositories during pull t.Run("repositories should not delete resources owned by other repositories during pull", func(t *testing.T) { // Both repositories were created with their own resources (repo1 has all-panels.json, repo2 has timeline-demo.json) // Verify that pulling one repository doesn't affect the other's resources // Step 1: Verify both repositories have their own resources const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (repo1) const timelineUID = "mIJjFy8Kz" // UID from timeline-demo.json (repo2) repo1Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) require.NoError(t, err, "repo1's dashboard should exist") require.Equal(t, repo1, repo1Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "should be owned by repo1") repo2Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) require.NoError(t, err, "repo2's dashboard should exist") require.Equal(t, repo2, repo2Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "should be owned by repo2") // Step 2: Pull repo1 (which doesn't manage repo2's resource) - should complete successfully helper.SyncAndWait(t, repo1, nil) // Step 3: Verify that repo2's resource is still intact after repo1's pull persistentRepo2Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) require.NoError(t, err, "repo2's dashboard should still exist after repo1 pull") require.Equal(t, repo2, persistentRepo2Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo2") require.Equal(t, repo2Dashboard.GetGeneration(), persistentRepo2Dashboard.GetGeneration(), "repo2's resource should not be modified by repo1 pull") // Step 4: Pull repo2 and verify repo1's resource is still intact helper.TriggerJobAndWaitForSuccess(t, repo2, provisioning.JobSpec{ Action: provisioning.JobActionPull, Pull: &provisioning.SyncJobOptions{}, }) persistentRepo1Dashboard, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) require.NoError(t, err, "repo1's dashboard should still exist after repo2 pull") require.Equal(t, repo1, persistentRepo1Dashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity], "ownership should remain with repo1") require.Equal(t, repo1Dashboard.GetGeneration(), persistentRepo1Dashboard.GetGeneration(), "repo1's resource should not be modified by repo2 pull") }) }