Provisioning: bulk move by name (#108869)

* Add ResourceRef to move job spec
* Implement move by name
* Use map to struct
This commit is contained in:
Roberto Jiménez Sánchez
2025-07-30 10:30:24 +02:00
committed by GitHub
parent f4335fc5ce
commit f4e45bf3bc
11 changed files with 747 additions and 22 deletions
@@ -3253,6 +3253,18 @@
"description": "Ref to the branch or commit hash that should move",
"type": "string"
},
"resources": {
"description": "Resources to move This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the paths.",
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef"
}
]
}
},
"targetPath": {
"description": "Destination path for the move (e.g. \"new-location/\")",
"type": "string"
@@ -1568,6 +1568,200 @@ func TestIntegrationProvisioning_MoveJob(t *testing.T) {
assert.Equal(collect, "error", state, "move job should have failed due to missing target path")
}, time.Second*10, time.Millisecond*100, "Expected move job to fail with error state")
})
t.Run("move by resource reference", func(t *testing.T) {
// Create a unique repository for resource reference testing to avoid contamination
const refRepo = "move-ref-test-repo"
localRefTmp := helper.RenderObject(t, "testdata/local-write.json.tmpl", map[string]any{
"Name": refRepo,
"SyncEnabled": true,
"SyncTarget": "folder",
})
_, err := helper.Repositories.Resource.Create(ctx, localRefTmp, metav1.CreateOptions{})
require.NoError(t, err)
// Create modified test files with unique UIDs for ResourceRef testing
allPanelsContent := helper.LoadFile("testdata/all-panels.json")
textOptionsContent := helper.LoadFile("testdata/text-options.json")
timelineDemoContent := helper.LoadFile("testdata/timeline-demo.json")
// Modify UIDs to be unique for ResourceRef tests
allPanelsModified := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "moveref1"`, 1)
textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "moveref2"`, 1)
timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "moveref3"`, 1)
// Create temporary files and copy them to the provisioning path
tmpDir := t.TempDir()
tmpFile1 := filepath.Join(tmpDir, "move-ref-test-1.json")
tmpFile2 := filepath.Join(tmpDir, "move-ref-test-2.json")
tmpFile3 := filepath.Join(tmpDir, "move-ref-test-3.json")
require.NoError(t, os.WriteFile(tmpFile1, []byte(allPanelsModified), 0644))
require.NoError(t, os.WriteFile(tmpFile2, []byte(textOptionsModified), 0644))
require.NoError(t, os.WriteFile(tmpFile3, []byte(timelineDemoModified), 0644))
// Copy files to provisioning path to set up test - use refRepo's path
helper.CopyToProvisioningPath(t, tmpFile1, "move-source-1.json")
helper.CopyToProvisioningPath(t, tmpFile2, "move-source-2.json")
helper.CopyToProvisioningPath(t, tmpFile3, "move-source-3.json")
// Sync to populate resources in Grafana
helper.SyncAndWait(t, refRepo, nil)
t.Run("move single dashboard by resource reference", func(t *testing.T) {
// Create move job for single dashboard using ResourceRef
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(refRepo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionMove,
Move: &provisioning.MoveJobOptions{
TargetPath: "moved-by-ref/",
Resources: []provisioning.ResourceRef{
{
Name: "moveref1", // UID from modified all-panels.json
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create move job with ResourceRef")
// Wait for job to complete
helper.AwaitJobs(t, refRepo)
// Verify corresponding file is moved in repository
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "moved-by-ref", "move-source-1.json")
require.NoError(t, err, "file should be moved to new location in repository")
// Verify original file is deleted from repository
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-1.json")
require.Error(t, err, "original file should be deleted from repository")
require.True(t, apierrors.IsNotFound(err), "should be not found error")
// Verify dashboard still exists in Grafana (should be updated after sync)
helper.SyncAndWait(t, refRepo, nil)
_, err = helper.DashboardsV1.Resource.Get(ctx, "moveref1", metav1.GetOptions{})
require.NoError(t, err, "dashboard should still exist in Grafana after move")
// Verify other resource still exists in original location
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json")
require.NoError(t, err, "other files should still exist in original location")
})
t.Run("move multiple resources by reference", func(t *testing.T) {
// Create move job for remaining resource using ResourceRef
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(refRepo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionMove,
Move: &provisioning.MoveJobOptions{
TargetPath: "archived-by-ref/",
Resources: []provisioning.ResourceRef{
{
Name: "moveref2", // UID from modified text-options.json
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create move job with ResourceRef")
// Wait for job to complete
helper.AwaitJobs(t, refRepo)
// Verify file is moved in repository
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "archived-by-ref", "move-source-2.json")
require.NoError(t, err, "file should be moved to new location")
// Verify original file is deleted from repository
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "move-source-2.json")
require.Error(t, err, "original file should be deleted from repository")
require.True(t, apierrors.IsNotFound(err), "should be not found error")
// Verify dashboard still exists in Grafana after sync
helper.SyncAndWait(t, refRepo, nil)
_, err = helper.DashboardsV1.Resource.Get(ctx, "moveref2", metav1.GetOptions{})
require.NoError(t, err, "dashboard should still exist in Grafana after move")
})
t.Run("mixed move - paths and resources", func(t *testing.T) {
// Setup fresh resources for mixed test
tmpMixed1 := filepath.Join(tmpDir, "mixed-move-1.json")
tmpMixed2 := filepath.Join(tmpDir, "mixed-move-2.json")
allPanelsMixed := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "mixedmove1"`, 1)
textOptionsMixed := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "mixedmove2"`, 1)
require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsMixed), 0644))
require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsMixed), 0644))
helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-move-1.json") // UID: mixedmove1
helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-move-2.json") // UID: mixedmove2
helper.SyncAndWait(t, refRepo, nil)
// Create move job that combines both paths and resource references
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(refRepo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionMove,
Move: &provisioning.MoveJobOptions{
TargetPath: "mixed-target/",
Paths: []string{"mixed-move-1.json"}, // Move by path
Resources: []provisioning.ResourceRef{
{
Name: "mixedmove2", // Move by resource reference
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create mixed move job")
// Wait for job to complete
helper.AwaitJobs(t, refRepo)
// Verify both targeted resources are moved in repository
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-1.json")
require.NoError(t, err, "file moved by path should exist at new location")
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-target", "mixed-move-2.json")
require.NoError(t, err, "file moved by resource ref should exist at new location")
// Verify files are deleted from original locations
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-1.json")
require.Error(t, err, "file moved by path should be deleted from original location")
_, err = helper.Repositories.Resource.Get(ctx, refRepo, metav1.GetOptions{}, "files", "mixed-move-2.json")
require.Error(t, err, "file moved by resource ref should be deleted from original location")
// Verify dashboards still exist in Grafana after sync
helper.SyncAndWait(t, refRepo, nil)
_, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove1", metav1.GetOptions{})
require.NoError(t, err, "dashboard moved by path should still exist in Grafana")
_, err = helper.DashboardsV1.Resource.Get(ctx, "mixedmove2", metav1.GetOptions{})
require.NoError(t, err, "dashboard moved by resource ref should still exist in Grafana")
})
})
}
func TestIntegrationProvisioning_MoveResources(t *testing.T) {
@@ -1850,4 +2044,66 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) {
require.Error(t, result.Error(), "should fail when source file doesn't exist")
})
})
t.Run("move non-existent resource by reference", func(t *testing.T) {
// Create move job for non-existent resource
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionMove,
Move: &provisioning.MoveJobOptions{
TargetPath: "moved-nonexistent/",
Resources: []provisioning.ResourceRef{
{
Name: "non-existent-move-uid",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create move job")
// Wait for job to complete - should record error but continue
require.EventuallyWithT(t, func(collect *assert.CollectT) {
list := &unstructured.UnstructuredList{}
err := helper.AdminREST.Get().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Do(ctx).Into(list)
assert.NoError(collect, err, "should be able to list jobs")
assert.NotEmpty(collect, list.Items, "expect at least one job")
// Find the most recent move job
var moveJob *unstructured.Unstructured
for _, elem := range list.Items {
assert.Equal(collect, repo, elem.GetLabels()["provisioning.grafana.app/repository"], "should have repo label")
action := mustNestedString(elem.Object, "spec", "action")
if action == "move" {
// Get the most recent one (they should be ordered by creation time)
moveJob = &elem
}
}
if !assert.NotNil(collect, moveJob, "should find a move job") {
return
}
state := mustNestedString(moveJob.Object, "status", "state")
// The job should complete but record errors for individual resource resolution failures
if state == "error" || state == "completed" || state == "success" {
// Any of these states is acceptable - the key is that resource resolution errors are recorded
// and don't fail the entire job due to error-tolerant implementation
return
}
assert.Fail(collect, "job should complete or error, but got state: %s", state)
}, time.Second*10, time.Millisecond*100, "Expected move job to handle non-existent resource")
})
}