Provisioning: Check For Resource Ownership Before Operations (#109582)

This commit is contained in:
Roberto Jiménez Sánchez
2025-08-15 10:05:53 +03:00
committed by GitHub
parent 9a47dd2a7e
commit 1ff39510d3
9 changed files with 647 additions and 26 deletions
+188
View File
@@ -2,9 +2,11 @@ package provisioning
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"path"
"testing"
"github.com/grafana/grafana/pkg/apimachinery/utils"
@@ -390,3 +392,189 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
})
})
}
func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
ctx := context.Background()
// Create first repository targeting "folder-1" with its own subdirectory
const repo1 = "ownership-repo-1"
helper.CreateRepo(t, TestRepo{
Name: repo1,
Path: path.Join(helper.ProvisioningPath, "repo1"),
Target: "folder",
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
},
ExpectedDashboards: 1,
ExpectedFolders: 1,
})
// Create second repository targeting "folder-2" with its own subdirectory
const repo2 = "ownership-repo-2"
path2 := path.Join(helper.ProvisioningPath, "repo2")
helper.CreateRepo(t, TestRepo{
Name: repo2,
Path: path2,
Target: "folder",
Copies: map[string]string{
"testdata/timeline-demo.json": "dashboard2.json",
},
ExpectedDashboards: 2, // Total across both repos
ExpectedFolders: 2, // Total across both repos
})
t.Run("CREATE file with UID already owned by different repository - should fail", func(t *testing.T) {
// Try to create a dashboard in repo2 that has the same UID as the one in repo1
// The all-panels.json has UID "n1jR8vnnz" which is already owned by repo1
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo2). // Using repo2 to try to create resource with same UID as repo1
SubResource("files", "conflicting-dashboard.json").
Body(helper.LoadFile("testdata/all-panels.json")). // Same file = same UID
SetHeader("Content-Type", "application/json").
Do(ctx)
// This should fail with ownership conflict
require.Error(t, result.Error(), "creating resource with UID already owned by different repository should fail")
// Get detailed error information
err := result.Error()
t.Logf("CREATE operation error: %T - %v", err, err)
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
t.Logf("Status error details: code=%d, reason=%s, message=%s",
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
}
// Verify it returns BadRequest (400) for ownership conflicts
if !apierrors.IsBadRequest(err) {
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
return
}
// Check error message contains ownership conflict information
errorMsg := err.Error()
t.Logf("Error message: %s", errorMsg)
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
})
t.Run("UPDATE with UID already owned by different repository - should fail", func(t *testing.T) {
// Try to update the dashboard owned by repo1 using repo2
result := helper.AdminREST.Put().
Namespace("default").
Resource("repositories").
Name(repo2). // Using repo2 to try to update repo1's resource
SubResource("files", "conflicting-update.json").
Body(helper.LoadFile("testdata/all-panels.json")). // Same UID as repo1's dashboard
SetHeader("Content-Type", "application/json").
Do(ctx)
// This should fail with ownership conflict
require.Error(t, result.Error(), "updating resource owned by different repository should fail")
// Get detailed error information
err := result.Error()
t.Logf("UPDATE operation error: %T - %v", err, err)
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
t.Logf("Status error details: code=%d, reason=%s, message=%s",
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
}
// Verify it returns BadRequest (400) for ownership conflicts
if !apierrors.IsBadRequest(err) {
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
return
}
// Check error message contains ownership conflict information
errorMsg := err.Error()
t.Logf("Error message: %s", errorMsg)
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
})
t.Run("DELETE resource owned by different repository - should fail", func(t *testing.T) {
// Create a file manually in the second repo which is already in first one
helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "repo2/conflicting-delete.json")
printFileTree(t, helper.ProvisioningPath)
result := helper.AdminREST.Delete().
Namespace("default").
Resource("repositories").
Name(repo2).
SubResource("files", "conflicting-delete.json").
SetHeader("Content-Type", "application/json").
Do(ctx)
// This should fail with ownership conflict
require.Error(t, result.Error(), "deleting resource owned by different repository should fail")
// Get detailed error information
err := result.Error()
t.Logf("DELETE operation error: %T - %v", err, err)
if statusErr := apierrors.APIStatus(nil); errors.As(err, &statusErr) {
t.Logf("Status error details: code=%d, reason=%s, message=%s",
statusErr.Status().Code, statusErr.Status().Reason, statusErr.Status().Message)
}
// Verify it returns BadRequest (400) for ownership conflicts
if !apierrors.IsBadRequest(err) {
t.Errorf("Expected BadRequest error but got: %T - %v", err, err)
return
}
// Check error message contains ownership conflict information
errorMsg := err.Error()
t.Logf("Error message: %s", errorMsg)
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
})
t.Run("MOVE and UPDATE file with UID already owned by different repository - should fail", func(t *testing.T) {
resp := helper.postFilesRequest(t, repo2, filesPostOptions{
targetPath: "moved-dashboard.json",
originalPath: path.Join("dashboard2.json"),
message: "attempt to move file from different repository",
body: string(helper.LoadFile("testdata/all-panels.json")), // Content to move with the conflicting UID
})
// nolint:errcheck
defer resp.Body.Close()
// This should fail with ownership conflict
require.NotEqual(t, http.StatusOK, resp.StatusCode, "moving resource owned by different repository should fail")
// Read response body to check error message
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
errorMsg := string(body)
// Log detailed error information
t.Logf("MOVE operation HTTP status: %d", resp.StatusCode)
t.Logf("MOVE operation error response: %s", errorMsg)
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should return BadRequest (400) for ownership conflict")
// Check error message contains ownership conflict information
require.Contains(t, errorMsg, fmt.Sprintf("managed by repo '%s'", repo1))
require.Contains(t, errorMsg, fmt.Sprintf("cannot be modified by repo '%s'", repo2))
})
t.Run("verify original resources remain intact", func(t *testing.T) {
const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json (repo1)
const timelineUID = "mIJjFy8Kz" // UID from timeline-demo.json (repo2)
// Verify repo1's dashboard is still owned by repo1
dashboard1, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{})
require.NoError(t, err, "repo1's dashboard should still exist")
require.Equal(t, repo1, dashboard1.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo1's dashboard should still be owned by repo1")
// Verify repo2's dashboard is still owned by repo2
dashboard2, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{})
require.NoError(t, err, "repo2's dashboard should still exist")
require.Equal(t, repo2, dashboard2.GetAnnotations()[utils.AnnoKeyManagerIdentity], "repo2's dashboard should still be owned by repo2")
})
}
+10 -3
View File
@@ -315,6 +315,7 @@ func (h *provisioningTestHelper) RenderObject(t *testing.T, filePath string, val
// The from path is relative to test file's directory.
func (h *provisioningTestHelper) CopyToProvisioningPath(t *testing.T, from, to string) {
fullPath := path.Join(h.ProvisioningPath, to)
t.Logf("Copying file from '%s' to provisioning path '%s'", from, fullPath)
err := os.MkdirAll(path.Dir(fullPath), 0750)
require.NoError(t, err, "failed to create directories for provisioning path")
@@ -439,15 +440,21 @@ func (h *provisioningTestHelper) logRepositoryObject(t *testing.T, obj map[strin
t.Logf("%s%d items:", prefix, len(v))
for i, item := range v {
if itemMap, ok := item.(map[string]interface{}); ok {
t.Logf("%s├── item %d:", prefix, i+1)
// Try to get the actual file path from the item
if pathVal, exists := itemMap["path"]; exists {
t.Logf("%s├── %v", prefix, pathVal)
} else {
t.Logf("%s├── item %d:", prefix, i+1)
}
h.logRepositoryObject(t, itemMap, prefix+" ", newPath)
}
}
}
default:
// This could be file content or metadata
if key != "kind" && key != "apiVersion" {
t.Logf("%s├── %s", prefix, key)
// Skip common metadata fields that are not useful for debugging
if key != "kind" && key != "apiVersion" && key != "path" && key != "size" && key != "hash" {
t.Logf("%s├── %s: %v", prefix, key, value)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
package provisioning
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"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"
)
func TestIntegrationProvisioning_PullJobOwnershipProtection(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
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 first repository targeting "folder" with its own subdirectory
helper.CreateRepo(t, TestRepo{
Name: repo1,
Path: path.Join(helper.ProvisioningPath, "repo1"),
Target: "folder",
Copies: map[string]string{
"testdata/all-panels.json": "dashboard1.json",
},
ExpectedDashboards: 1,
ExpectedFolders: 1,
})
// Create second repository targeting "folder" with its own subdirectory
helper.CreateRepo(t, TestRepo{
Name: repo2,
Path: path.Join(helper.ProvisioningPath, "repo2"),
Target: "folder",
Copies: map[string]string{
"testdata/timeline-demo.json": "dashboard2.json",
},
ExpectedDashboards: 2, // Total across both repos
ExpectedFolders: 2, // Total across both repos
})
// 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 assert.Contains(t, errMsg, fmt.Sprintf("managed by repo '%s'", repo1)) &&
assert.Contains(t, 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.GetResourceVersion(), persistentRepo2Dashboard.GetResourceVersion(), "repo2's resource should not be modified by repo1 pull")
// Step 4: Pull repo2 and verify repo1's resource is still intact
helper.SyncAndWait(t, repo2, nil)
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.GetResourceVersion(), persistentRepo1Dashboard.GetResourceVersion(), "repo1's resource should not be modified by repo2 pull")
})
}