Provisioning: bulk delete resources by name (#108833)

* Add resource reference to spec

* Add FindResourcePath

* Fix formatting

* Use ForKind client

* Add unit test for new method

* Format code

* Add integration tests

* Fix unit tests

* Fix formatting

* Find out preferred version based on group and kind

* Handle trailing slash for folders

* Format code

* Fix linting

* Add integration test for folder bulk deletion

* Format code

* Format discovery file

* Deduplicate paths for deletion
This commit is contained in:
Roberto Jiménez Sánchez
2025-07-29 14:29:35 +02:00
committed by GitHub
parent 720e724234
commit c7c0268594
16 changed files with 1776 additions and 23 deletions
+17
View File
@@ -141,6 +141,23 @@ type DeleteJobOptions struct {
// - nested/deep/ (for a directory)
// FIXME: we should validate this in admission hooks
Paths []string `json:"paths,omitempty"`
// Resources to delete
// 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.
Resources []ResourceRef `json:"resources,omitempty"`
}
type ResourceRef struct {
// Name is the name of the resource, such as a dashboard UID.
Name string `json:"name,omitempty"`
// Kind is the type of resource, for example, "Dashboard".
Kind string `json:"kind,omitempty"`
// Group is the group of the resource, such as "dashboard.grafana.app".
Group string `json:"group,omitempty"`
}
// The job status
@@ -56,6 +56,11 @@ func (in *DeleteJobOptions) DeepCopyInto(out *DeleteJobOptions) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Resources != nil {
in, out := &in.Resources, &out.Resources
*out = make([]ResourceRef, len(*in))
copy(*out, *in)
}
return
}
@@ -835,6 +840,22 @@ func (in *ResourceObjects) DeepCopy() *ResourceObjects {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceRef) DeepCopyInto(out *ResourceRef) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRef.
func (in *ResourceRef) DeepCopy() *ResourceRef {
if in == nil {
return nil
}
out := new(ResourceRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceRepositoryInfo) DeepCopyInto(out *ResourceRepositoryInfo) {
*out = *in
@@ -48,6 +48,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceList": schema_pkg_apis_provisioning_v0alpha1_ResourceList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceListItem": schema_pkg_apis_provisioning_v0alpha1_ResourceListItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceObjects": schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef": schema_pkg_apis_provisioning_v0alpha1_ResourceRef(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRepositoryInfo": schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref),
@@ -184,9 +185,25 @@ func schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref common.Reference
},
},
},
"resources": {
SchemaProps: spec.SchemaProps{
Description: "Resources to delete 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: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceRef"},
}
}
@@ -1824,6 +1841,39 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(ref common.ReferenceC
}
}
func schema_pkg_apis_provisioning_v0alpha1_ResourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"name": {
SchemaProps: spec.SchemaProps{
Description: "Name is the name of the resource, such as a dashboard UID.",
Type: []string{"string"},
Format: "",
},
},
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is the type of resource, for example, \"Dashboard\".",
Type: []string{"string"},
Format: "",
},
},
"group": {
SchemaProps: spec.SchemaProps{
Description: "Group is the group of the resource, such as \"dashboard.grafana.app\".",
Type: []string{"string"},
Format: "",
},
},
},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -1,4 +1,5 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,HistoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors
@@ -6,20 +6,25 @@ import (
"fmt"
"time"
"k8s.io/apimachinery/pkg/runtime/schema"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
type Worker struct {
syncWorker jobs.Worker
wrapFn repository.WrapWithStageFn
syncWorker jobs.Worker
wrapFn repository.WrapWithStageFn
resourcesFactory resources.RepositoryResourcesFactory
}
func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn) *Worker {
func NewWorker(syncWorker jobs.Worker, wrapFn repository.WrapWithStageFn, resourcesFactory resources.RepositoryResourcesFactory) *Worker {
return &Worker{
syncWorker: syncWorker,
wrapFn: wrapFn,
syncWorker: syncWorker,
wrapFn: wrapFn,
resourcesFactory: resourcesFactory,
}
}
@@ -31,10 +36,11 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr
if job.Spec.Delete == nil {
return errors.New("missing delete settings")
}
opts := *job.Spec.Delete
opts := *job.Spec.Delete
paths := opts.Paths
progress.SetTotal(ctx, len(paths))
progress.SetTotal(ctx, len(paths)+len(opts.Resources))
progress.StrictMaxErrors(1) // Fail fast on any error during deletion
fn := func(repo repository.Repository, _ bool) error {
@@ -43,6 +49,18 @@ func (w *Worker) Process(ctx context.Context, repo repository.Repository, job pr
return errors.New("delete job submitted targeting repository that is not a ReaderWriter")
}
// Resolve ResourceRef entries to file paths using RepositoryResources
if len(opts.Resources) > 0 {
resolvedPaths, err := w.resolveResourcesToPaths(ctx, rw, progress, opts.Resources)
if err != nil {
return err
}
paths = append(paths, resolvedPaths...)
}
// Deduplicate paths to avoid attempting to delete the same file multiple times
paths = deduplicatePaths(paths)
return w.deleteFiles(ctx, rw, progress, opts, paths...)
}
@@ -97,3 +115,67 @@ func (w *Worker) deleteFiles(ctx context.Context, rw repository.ReaderWriter, pr
return nil
}
// resolveResourcesToPaths converts ResourceRef entries to file paths, recording errors for individual resources
func (w *Worker) resolveResourcesToPaths(ctx context.Context, rw repository.ReaderWriter, progress jobs.JobProgressRecorder, resources []provisioning.ResourceRef) ([]string, error) {
if len(resources) == 0 {
return nil, nil
}
progress.SetMessage(ctx, "Resolving resource paths")
repositoryResources, err := w.resourcesFactory.Client(ctx, rw)
if err != nil {
return nil, fmt.Errorf("create repository resources client: %w", err)
}
resolvedPaths := make([]string, 0, len(resources))
for _, resource := range resources {
result := jobs.JobResourceResult{
Name: resource.Name,
Group: resource.Group,
Action: repository.FileActionDeleted, // Will be used for deletion later
}
gvk := schema.GroupVersionKind{
Group: resource.Group,
Kind: resource.Kind,
// Version is left empty so ForKind will use the preferred version
}
progress.SetMessage(ctx, fmt.Sprintf("Finding path for resource %s/%s/%s", resource.Group, resource.Kind, resource.Name))
resourcePath, err := repositoryResources.FindResourcePath(ctx, resource.Name, gvk)
if err != nil {
result.Error = fmt.Errorf("find path for resource %s/%s/%s: %w", resource.Group, resource.Kind, resource.Name, err)
progress.Record(ctx, result)
// Continue with next resource instead of failing fast
if err := progress.TooManyErrors(); err != nil {
return resolvedPaths, err
}
continue
}
result.Path = resourcePath
resolvedPaths = append(resolvedPaths, resourcePath)
}
return resolvedPaths, nil
}
// deduplicatePaths removes duplicate file paths from the slice while preserving order
func deduplicatePaths(paths []string) []string {
if len(paths) <= 1 {
return paths
}
seen := make(map[string]bool, len(paths))
result := make([]string, 0, len(paths))
for _, path := range paths {
if !seen[path] {
seen[path] = true
result = append(result, path)
}
}
return result
}
@@ -6,9 +6,13 @@ import (
"testing"
"time"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -22,6 +26,15 @@ func (m *mockReaderWriter) Delete(ctx context.Context, path, ref, message string
return args.Error(0)
}
// simpleRepository implements only the base Repository interface, not ReaderWriter
type simpleRepository struct{}
func (s *simpleRepository) Config() *provisioning.Repository { return nil }
func (s *simpleRepository) Validate() field.ErrorList { return nil }
func (s *simpleRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
return nil, nil
}
func TestDeleteWorker_IsSupported(t *testing.T) {
tests := []struct {
name string
@@ -59,7 +72,7 @@ func TestDeleteWorker_IsSupported(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
worker := NewWorker(nil, nil)
worker := NewWorker(nil, nil, nil)
result := worker.IsSupported(context.Background(), tt.job)
require.Equal(t, tt.expected, result)
})
@@ -73,7 +86,7 @@ func TestDeleteWorker_ProcessMissingDeleteSettings(t *testing.T) {
},
}
worker := NewWorker(nil, nil)
worker := NewWorker(nil, nil, nil)
err := worker.Process(context.Background(), nil, job, nil)
require.EqualError(t, err, "missing delete settings")
}
@@ -102,7 +115,7 @@ func TestDeleteWorker_ProcessNotReaderWriter(t *testing.T) {
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
worker := NewWorker(nil, mockWrapFn.Execute)
worker := NewWorker(nil, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: delete job submitted targeting repository that is not a ReaderWriter")
}
@@ -125,7 +138,7 @@ func TestDeleteWorker_ProcessWrapFnError(t *testing.T) {
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
worker := NewWorker(nil, mockWrapFn.Execute)
worker := NewWorker(nil, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: stage failed")
}
@@ -172,7 +185,7 @@ func TestDeleteWorker_ProcessDeleteFilesSuccess(t *testing.T) {
return result.Path == "test/path2" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
worker := NewWorker(nil, mockWrapFn.Execute)
worker := NewWorker(nil, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -210,7 +223,7 @@ func TestDeleteWorker_ProcessDeleteFilesWithError(t *testing.T) {
})).Return()
mockProgress.On("TooManyErrors").Return(errors.New("too many errors"))
worker := NewWorker(nil, mockWrapFn.Execute)
worker := NewWorker(nil, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: too many errors")
}
@@ -254,7 +267,7 @@ func TestDeleteWorker_ProcessWithSyncWorker(t *testing.T) {
return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental
}), mockProgress).Return(nil)
worker := NewWorker(mockSyncWorker, mockWrapFn.Execute)
worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -294,7 +307,7 @@ func TestDeleteWorker_ProcessSyncWorkerError(t *testing.T) {
syncError := errors.New("sync failed")
mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.Anything, mockProgress).Return(syncError)
worker := NewWorker(mockSyncWorker, mockWrapFn.Execute)
worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, nil)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "pull resources: sync failed")
}
@@ -363,7 +376,7 @@ func TestDeleteWorker_deleteFiles(t *testing.T) {
}
}
worker := NewWorker(nil, nil)
worker := NewWorker(nil, nil, nil)
err := worker.deleteFiles(context.Background(), mockRepo, mockProgress, opts, tt.paths...)
if tt.expectedError != "" {
@@ -377,3 +390,596 @@ func TestDeleteWorker_deleteFiles(t *testing.T) {
})
}
}
func TestDeleteWorker_ProcessWithResourceRefs(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Paths: []string{"test/path1"},
Resources: []provisioning.ResourceRef{
{
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "test-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
},
Ref: "main",
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
// Mock repository resources factory and client
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
// Mock FindResourcePath calls
mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("dashboards/test-dashboard.json", nil)
mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-folder", schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
// Version is empty - ForKind will discover the preferred version
}).Return("folders/test-folder.json", nil)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
return !opts.PushOnWrites && opts.Timeout == 10*time.Minute
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
// Progress tracking - expects 3 total (1 path + 2 resources)
mockProgress.On("SetTotal", mock.Anything, 3).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/test-folder").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting test/path1").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting folders/test-folder.json").Return()
mockProgress.On("TooManyErrors").Return(nil).Times(3)
// Mock file deletions
mockRepo.On("Delete", mock.Anything, "test/path1", "main", "Delete test/path1").Return(nil)
mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil)
mockRepo.On("Delete", mock.Anything, "folders/test-folder.json", "main", "Delete folders/test-folder.json").Return(nil)
// Mock progress records
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "test/path1" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "folders/test-folder.json" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
mockResourcesFactory.AssertExpectations(t)
mockRepositoryResources.AssertExpectations(t)
}
func TestDeleteWorker_ProcessResourceRefsOnly(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
Ref: "main",
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("dashboards/test-dashboard.json", nil)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return()
mockProgress.On("TooManyErrors").Return(nil)
mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil)
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
func TestDeleteWorker_ProcessResourceResolutionError(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "nonexistent-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
findPathError := errors.New("resource not found in repository: dashboard.grafana.app/dashboards/nonexistent-dashboard")
mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("", findPathError)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return()
// Expect error to be recorded, not thrown
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "nonexistent-dashboard" &&
result.Group == "dashboard.grafana.app" &&
result.Action == repository.FileActionDeleted &&
result.Error != nil
})).Return()
mockProgress.On("TooManyErrors").Return(nil)
// Mock sync worker behavior that happens when no ref is specified
mockProgress.On("ResetResults").Return()
mockProgress.On("SetMessage", mock.Anything, "pull resources").Return()
mockSyncWorker := jobs.NewMockWorker(t)
mockSyncWorker.On("Process", mock.Anything, mockRepo, mock.MatchedBy(func(syncJob provisioning.Job) bool {
return syncJob.Spec.Pull != nil && !syncJob.Spec.Pull.Incremental
}), mockProgress).Return(nil)
worker := NewWorker(mockSyncWorker, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err) // Should succeed even with resource resolution error
}
func TestDeleteWorker_ProcessResourcesFactoryError(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
factoryError := errors.New("failed to create repository resources client")
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(nil, factoryError)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: create repository resources client: failed to create repository resources client")
}
func TestDeleteWorker_ProcessResourceRefsNotReaderWriter(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "test-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
}
// Create a simple repository that doesn't implement ReaderWriter
mockRepo := &simpleRepository{}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
// Mock the wrap function that will call our function and get the ReaderWriter error
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
// The ReaderWriter check should fail immediately, so no resource resolution calls should happen
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: delete job submitted targeting repository that is not a ReaderWriter")
}
func TestDeleteWorker_ProcessResourceResolutionTooManyErrors(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "nonexistent-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
findPathError := errors.New("resource not found in repository")
mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("", findPathError)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
mockProgress.On("SetTotal", mock.Anything, 1).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return()
// Mock recording error and TooManyErrors returning error
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "nonexistent-dashboard" && result.Error != nil
})).Return()
mockProgress.On("TooManyErrors").Return(errors.New("too many errors"))
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "delete files from repository: too many errors")
}
func TestDeleteWorker_ProcessMixedResourcesWithPartialFailure(t *testing.T) {
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "valid-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "nonexistent-dashboard",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "valid-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
},
Ref: "main",
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
// First resource succeeds
mockRepositoryResources.On("FindResourcePath", mock.Anything, "valid-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("dashboards/valid-dashboard.json", nil)
// Second resource fails
findPathError := errors.New("resource not found")
mockRepositoryResources.On("FindResourcePath", mock.Anything, "nonexistent-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
// Version is empty - ForKind will discover the preferred version
}).Return("", findPathError)
// Third resource succeeds
mockRepositoryResources.On("FindResourcePath", mock.Anything, "valid-folder", schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
// Version is empty - ForKind will discover the preferred version
}).Return("folders/valid-folder.json", nil)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
mockProgress.On("SetTotal", mock.Anything, 3).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/valid-dashboard").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/nonexistent-dashboard").Return()
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/valid-folder").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/valid-dashboard.json").Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting folders/valid-folder.json").Return()
// Record the error for the failed resource
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Name == "nonexistent-dashboard" && result.Error != nil
})).Return()
// Allow continuing after error
mockProgress.On("TooManyErrors").Return(nil).Times(3) // Called after each resource resolution and file deletion
// Mock successful file deletions for resolved resources
mockRepo.On("Delete", mock.Anything, "dashboards/valid-dashboard.json", "main", "Delete dashboards/valid-dashboard.json").Return(nil)
mockRepo.On("Delete", mock.Anything, "folders/valid-folder.json", "main", "Delete folders/valid-folder.json").Return(nil)
// Record successful deletions
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "dashboards/valid-dashboard.json" && result.Error == nil
})).Return()
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "folders/valid-folder.json" && result.Error == nil
})).Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err) // Should succeed overall, with only the failed resource recorded as error
}
func TestDeleteWorker_ProcessWithPathDeduplication(t *testing.T) {
// Test that duplicate paths from explicit paths and resource resolution are deduplicated
job := provisioning.Job{
Spec: provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Ref: "main", // Add ref to avoid sync worker execution
Paths: []string{"dashboards/test-dashboard.json", "folders/test-folder/"}, // Explicit paths
Resources: []provisioning.ResourceRef{
{
Name: "test-dashboard", // This will resolve to "dashboards/test-dashboard.json" (duplicate)
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "test-folder", // This will resolve to "folders/test-folder/" (duplicate)
Kind: "Folder",
Group: "folder.grafana.app",
},
{
Name: "unique-dashboard", // This will resolve to "dashboards/unique-dashboard.json" (unique)
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
}
mockRepo := &mockReaderWriter{
MockRepository: repository.NewMockRepository(t),
}
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockWrapFn := repository.NewMockWrapWithStageFn(t)
// Mock resources factory and repository resources
mockResourcesFactory := resources.NewMockRepositoryResourcesFactory(t)
mockRepositoryResources := resources.NewMockRepositoryResources(t)
mockResourcesFactory.On("Client", mock.Anything, mockRepo).Return(mockRepositoryResources, nil)
mockWrapFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(mockRepo, false)
})
// Expect total of 5 items (2 explicit paths + 3 resources), but only 3 unique paths will be deleted
mockProgress.On("SetTotal", mock.Anything, 5).Return()
mockProgress.On("StrictMaxErrors", 1).Return()
// Resource resolution phase
mockProgress.On("SetMessage", mock.Anything, "Resolving resource paths").Return()
// Mock resource path resolution - note duplicates with explicit paths
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/test-dashboard").Return()
mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
}).Return("dashboards/test-dashboard.json", nil) // Duplicate of explicit path
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource folder.grafana.app/Folder/test-folder").Return()
mockRepositoryResources.On("FindResourcePath", mock.Anything, "test-folder", schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
}).Return("folders/test-folder/", nil) // Duplicate of explicit path
mockProgress.On("SetMessage", mock.Anything, "Finding path for resource dashboard.grafana.app/Dashboard/unique-dashboard").Return()
mockRepositoryResources.On("FindResourcePath", mock.Anything, "unique-dashboard", schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
}).Return("dashboards/unique-dashboard.json", nil) // Unique path
// Note: successful resource resolution does not call Record - only failures do
// Deletion phase - should only delete 3 unique paths (deduplication working)
mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/test-dashboard.json").Return()
mockRepo.On("Delete", mock.Anything, "dashboards/test-dashboard.json", "main", "Delete dashboards/test-dashboard.json").Return(nil)
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "dashboards/test-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
mockProgress.On("TooManyErrors").Return(nil)
mockProgress.On("SetMessage", mock.Anything, "Deleting folders/test-folder/").Return()
mockRepo.On("Delete", mock.Anything, "folders/test-folder/", "main", "Delete folders/test-folder/").Return(nil)
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "folders/test-folder/" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
mockProgress.On("SetMessage", mock.Anything, "Deleting dashboards/unique-dashboard.json").Return()
mockRepo.On("Delete", mock.Anything, "dashboards/unique-dashboard.json", "main", "Delete dashboards/unique-dashboard.json").Return(nil)
mockProgress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool {
return result.Path == "dashboards/unique-dashboard.json" && result.Action == repository.FileActionDeleted && result.Error == nil
})).Return()
worker := NewWorker(nil, mockWrapFn.Execute, mockResourcesFactory)
err := worker.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
// Verify all mocks were called as expected - key point is that each file is only deleted once
mockRepo.AssertExpectations(t)
mockProgress.AssertExpectations(t)
mockResourcesFactory.AssertExpectations(t)
mockRepositoryResources.AssertExpectations(t)
}
func TestDeduplicatePaths(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty slice",
input: []string{},
expected: []string{},
},
{
name: "single path",
input: []string{"path1"},
expected: []string{"path1"},
},
{
name: "no duplicates",
input: []string{"path1", "path2", "path3"},
expected: []string{"path1", "path2", "path3"},
},
{
name: "with duplicates",
input: []string{"path1", "path2", "path1", "path3", "path2"},
expected: []string{"path1", "path2", "path3"},
},
{
name: "all same paths",
input: []string{"path1", "path1", "path1"},
expected: []string{"path1"},
},
{
name: "mixed paths with folder trailing slash",
input: []string{"folder/", "file.json", "folder/", "nested/file.json", "file.json"},
expected: []string{"folder/", "file.json", "nested/file.json"},
},
{
name: "preserves order",
input: []string{"c", "a", "b", "a", "c"},
expected: []string{"c", "a", "b"},
},
{
name: "realistic scenario - explicit paths and resource refs resolve to same paths",
input: []string{"dashboards/dashboard1.json", "folder/", "dashboards/dashboard1.json", "alerts/alert1.yaml", "folder/"},
expected: []string{"dashboards/dashboard1.json", "folder/", "alerts/alert1.yaml"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deduplicatePaths(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
+1 -1
View File
@@ -617,7 +617,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.storageStatus,
)
deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible)
deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible, b.repositoryResources)
workers := []jobs.Worker{
deleteWorker,
exportWorker,
@@ -107,6 +107,9 @@ type clientInfo struct {
client dynamic.ResourceInterface
}
// ForKind returns a client for a kind.
// If the kind has a version, it will be used.
// If the kind does not have a version, the preferred version will be used.
func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.ResourceInterface, schema.GroupVersionResource, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
@@ -116,9 +119,29 @@ func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.Resource
return info.client, info.gvr, nil
}
gvr, err := c.discovery.GetResourceForKind(gvk)
if err != nil {
return nil, schema.GroupVersionResource{}, err
var err error
var gvr schema.GroupVersionResource
var versionless schema.GroupVersionKind
if gvk.Version == "" {
versionless = gvk
gvr, gvk, err = c.discovery.GetPreferredVersionForKind(schema.GroupKind{
Group: gvk.Group,
Kind: gvk.Kind,
})
if err != nil {
return nil, schema.GroupVersionResource{}, err
}
info, ok := c.byKind[gvk]
if ok && info.client != nil {
c.byKind[versionless] = info
return info.client, info.gvr, nil
}
} else {
gvr, err = c.discovery.GetResourceForKind(gvk)
if err != nil {
return nil, schema.GroupVersionResource{}, err
}
}
info = &clientInfo{
gvk: gvk,
@@ -127,6 +150,9 @@ func (c *resourceClients) ForKind(gvk schema.GroupVersionKind) (dynamic.Resource
}
c.byKind[gvk] = info
c.byResource[gvr] = info
if versionless.Group != "" {
c.byKind[versionless] = info
}
return info.client, info.gvr, nil
}
@@ -4,11 +4,15 @@ import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apimachinery/utils"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
//go:generate mockery --name RepositoryResourcesFactory --structname MockRepositoryResourcesFactory --inpackage --filename repository_resources_factory_mock.go --with-expecter
@@ -28,6 +32,7 @@ type RepositoryResources interface {
// Resource from file
WriteResourceFromFile(ctx context.Context, path, ref string) (string, schema.GroupVersionKind, error)
RemoveResourceFromFile(ctx context.Context, path, ref string) (string, schema.GroupVersionKind, error)
FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error)
RenameResourceFile(ctx context.Context, path, previousRef, newPath, newRef string) (string, schema.GroupVersionKind, error)
// Stats
Stats(ctx context.Context) (*provisioning.ResourceStats, error)
@@ -55,6 +60,42 @@ func (r *repositoryResources) List(ctx context.Context) (*provisioning.ResourceL
return r.lister.List(ctx, r.namespace, r.repoName)
}
// FindResourcePath finds the repository file path for a resource by its name and GroupVersionKind
func (r *repositoryResources) FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error) {
// Use ForKind to get the dynamic client for this resource type
client, gvr, err := r.clients.ForKind(gvk)
if err != nil {
return "", fmt.Errorf("get client for kind %s: %w", gvk.Kind, err)
}
// Get the specific resource by name using the dynamic client (already namespaced)
obj, err := client.Get(ctx, name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
return "", fmt.Errorf("resource not found: %s/%s/%s", gvr.Group, gvr.Resource, name)
}
return "", fmt.Errorf("failed to get resource %s/%s/%s: %w", gvr.Group, gvr.Resource, name, err)
}
// Extract the source path from annotations
annotations := obj.GetAnnotations()
if annotations == nil {
return "", fmt.Errorf("resource %s/%s/%s has no annotations", gvr.Group, gvr.Resource, name)
}
sourcePath, exists := annotations[utils.AnnoKeySourcePath]
if !exists || sourcePath == "" {
return "", fmt.Errorf("resource %s/%s/%s has no source path annotation", gvr.Group, gvr.Resource, name)
}
// For folder resources, ensure the path has a trailing slash for proper deletion
if gvk.Kind == "Folder" && !safepath.IsDir(sourcePath) {
sourcePath = sourcePath + "/"
}
return sourcePath, nil
}
func NewRepositoryResourcesFactory(parsers ParserFactory, clients ClientFactory, lister ResourceLister) RepositoryResourcesFactory {
return &repositoryResourcesFactory{parsers, clients, lister}
}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package resources
@@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package resources
@@ -181,6 +181,64 @@ func (_c *MockRepositoryResources_EnsureFolderTreeExists_Call) RunAndReturn(run
return _c
}
// FindResourcePath provides a mock function with given fields: ctx, name, gvk
func (_m *MockRepositoryResources) FindResourcePath(ctx context.Context, name string, gvk schema.GroupVersionKind) (string, error) {
ret := _m.Called(ctx, name, gvk)
if len(ret) == 0 {
panic("no return value specified for FindResourcePath")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, schema.GroupVersionKind) (string, error)); ok {
return rf(ctx, name, gvk)
}
if rf, ok := ret.Get(0).(func(context.Context, string, schema.GroupVersionKind) string); ok {
r0 = rf(ctx, name, gvk)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(context.Context, string, schema.GroupVersionKind) error); ok {
r1 = rf(ctx, name, gvk)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositoryResources_FindResourcePath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindResourcePath'
type MockRepositoryResources_FindResourcePath_Call struct {
*mock.Call
}
// FindResourcePath is a helper method to define mock.On call
// - ctx context.Context
// - name string
// - gvk schema.GroupVersionKind
func (_e *MockRepositoryResources_Expecter) FindResourcePath(ctx interface{}, name interface{}, gvk interface{}) *MockRepositoryResources_FindResourcePath_Call {
return &MockRepositoryResources_FindResourcePath_Call{Call: _e.mock.On("FindResourcePath", ctx, name, gvk)}
}
func (_c *MockRepositoryResources_FindResourcePath_Call) Run(run func(ctx context.Context, name string, gvk schema.GroupVersionKind)) *MockRepositoryResources_FindResourcePath_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(schema.GroupVersionKind))
})
return _c
}
func (_c *MockRepositoryResources_FindResourcePath_Call) Return(_a0 string, _a1 error) *MockRepositoryResources_FindResourcePath_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockRepositoryResources_FindResourcePath_Call) RunAndReturn(run func(context.Context, string, schema.GroupVersionKind) (string, error)) *MockRepositoryResources_FindResourcePath_Call {
_c.Call.Return(run)
return _c
}
// List provides a mock function with given fields: ctx
func (_m *MockRepositoryResources) List(ctx context.Context) (*v0alpha1.ResourceList, error) {
ret := _m.Called(ctx)
@@ -0,0 +1,437 @@
package resources
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestRepositoryResources_FindResourcePath(t *testing.T) {
tests := []struct {
name string
resourceName string
gvk schema.GroupVersionKind
expectedGVR schema.GroupVersionResource
forKindError error
getError error
resourceObj *unstructured.Unstructured
expectedPath string
expectedError string
}{
{
name: "dashboard found successfully",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-dashboard",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "dashboards/test-dashboard.json",
},
},
},
},
expectedPath: "dashboards/test-dashboard.json",
},
{
name: "folder found successfully",
resourceName: "test-folder",
gvk: schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
},
expectedGVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v0alpha1",
Resource: "folders",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-folder",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "folders/test-folder",
},
},
},
},
expectedPath: "folders/test-folder/", // Trailing slash added for folder resources
},
{
name: "ForKind fails",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
forKindError: errors.New("kind not found"),
expectedError: "get client for kind Dashboard: kind not found",
},
{
name: "resource not found",
resourceName: "nonexistent-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
getError: apierrors.NewNotFound(schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, "nonexistent-dashboard"),
expectedError: "resource not found: dashboard.grafana.app/dashboards/nonexistent-dashboard",
},
{
name: "Get operation fails with other error",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
getError: errors.New("internal server error"),
expectedError: "failed to get resource dashboard.grafana.app/dashboards/test-dashboard: internal server error",
},
{
name: "resource has no annotations",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-dashboard",
"namespace": "test-namespace",
// No annotations
},
},
},
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no annotations",
},
{
name: "resource has empty annotations",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-dashboard",
"namespace": "test-namespace",
"annotations": map[string]interface{}{},
},
},
},
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation",
},
{
name: "resource has empty source path annotation",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-dashboard",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "", // Empty path
},
},
},
},
expectedError: "resource dashboard.grafana.app/dashboards/test-dashboard has no source path annotation",
},
{
name: "resource with nested folder path",
resourceName: "nested-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard",
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "nested-dashboard",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "team-a/subfolder/nested-dashboard.json",
},
},
},
},
expectedPath: "team-a/subfolder/nested-dashboard.json",
},
{
name: "folder without trailing slash gets slash added",
resourceName: "test-folder",
gvk: schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
},
expectedGVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v0alpha1",
Resource: "folders",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-folder",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "folders/test-folder", // No trailing slash
},
},
},
},
expectedPath: "folders/test-folder/", // Should have trailing slash added
},
{
name: "folder with trailing slash keeps slash",
resourceName: "test-folder-2",
gvk: schema.GroupVersionKind{
Group: "folder.grafana.app",
Kind: "Folder",
},
expectedGVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v0alpha1",
Resource: "folders",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-folder-2",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "folders/test-folder-2/", // Already has trailing slash
},
},
},
},
expectedPath: "folders/test-folder-2/", // Should keep existing trailing slash
},
{
name: "non-folder resource keeps path unchanged",
resourceName: "test-dashboard",
gvk: schema.GroupVersionKind{
Group: "dashboard.grafana.app",
Kind: "Dashboard", // Not a folder
},
expectedGVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Resource: "dashboards",
},
resourceObj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-dashboard",
"namespace": "test-namespace",
"annotations": map[string]interface{}{
utils.AnnoKeySourcePath: "dashboards/test-dashboard", // No trailing slash
},
},
},
},
expectedPath: "dashboards/test-dashboard", // Should remain unchanged for non-folders
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mocks
mockClients := NewMockResourceClients(t)
mockClient := &MockDynamicResourceInterface{}
// Create repository resources with mocked dependencies
resourcesManager := &ResourcesManager{
clients: mockClients,
}
repositoryResources := &repositoryResources{
ResourcesManager: resourcesManager,
namespace: "test-namespace",
repoName: "test-repo",
}
// Mock ForKind call
if tt.forKindError != nil {
mockClients.On("ForKind", tt.gvk).Return(nil, schema.GroupVersionResource{}, tt.forKindError)
} else {
mockClients.On("ForKind", tt.gvk).Return(mockClient, tt.expectedGVR, nil)
// Mock Get call if ForKind succeeds
if tt.getError != nil {
mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(nil, tt.getError)
} else {
mockClient.On("Get", mock.Anything, tt.resourceName, metav1.GetOptions{}, mock.Anything).Return(tt.resourceObj, nil)
}
}
// Execute the method
result, err := repositoryResources.FindResourcePath(context.Background(), tt.resourceName, tt.gvk)
// Assert results
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
require.Empty(t, result)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectedPath, result)
}
// Verify all mocks were called as expected
mockClients.AssertExpectations(t)
if tt.forKindError == nil {
mockClient.AssertExpectations(t)
}
})
}
}
// MockDynamicResourceInterface is a mock for dynamic.ResourceInterface
type MockDynamicResourceInterface struct {
mock.Mock
}
func (m *MockDynamicResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, options, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, options, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, obj, options)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
args := m.Called(ctx, name, options, subresources)
return args.Error(0)
}
func (m *MockDynamicResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error {
args := m.Called(ctx, options, listOptions)
return args.Error(0)
}
func (m *MockDynamicResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, options, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) {
args := m.Called(ctx, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.UnstructuredList), args.Error(1)
}
func (m *MockDynamicResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
args := m.Called(ctx, opts)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(watch.Interface), args.Error(1)
}
func (m *MockDynamicResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, pt, data, options, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, obj, options, subresources)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
func (m *MockDynamicResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) {
args := m.Called(ctx, name, obj, options)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*unstructured.Unstructured), args.Error(1)
}
// Ensure MockDynamicResourceInterface implements dynamic.ResourceInterface
var _ dynamic.ResourceInterface = (*MockDynamicResourceInterface)(nil)
@@ -14,6 +14,7 @@ type DiscoveryClient interface {
GetResourceForKind(gvk schema.GroupVersionKind) (schema.GroupVersionResource, error)
GetKindForResource(gvr schema.GroupVersionResource) (schema.GroupVersionKind, error)
GetPreferredVesion(gr schema.GroupResource) (schema.GroupVersionResource, schema.GroupVersionKind, error)
GetPreferredVersionForKind(gk schema.GroupKind) (schema.GroupVersionResource, schema.GroupVersionKind, error)
}
type DiscoveryClientImpl struct {
@@ -92,3 +93,44 @@ func (d *DiscoveryClientImpl) GetPreferredVesion(gr schema.GroupResource) (schem
}
return schema.GroupVersionResource{}, schema.GroupVersionKind{}, fmt.Errorf("preferred version not found for %s", gr.String())
}
func (d *DiscoveryClientImpl) GetPreferredVersionForKind(gk schema.GroupKind) (schema.GroupVersionResource, schema.GroupVersionKind, error) {
apiList, err := d.ServerPreferredResources()
if err != nil {
return schema.GroupVersionResource{}, schema.GroupVersionKind{}, err
}
for _, apis := range apiList {
// Check if this API group matches our target group
if !strings.HasPrefix(apis.GroupVersion, gk.Group) {
continue
}
// Parse the group/version
var group, version string
if strings.Contains(apis.GroupVersion, "/") {
parts := strings.Split(apis.GroupVersion, "/")
group = parts[0]
version = parts[1]
} else {
// Core API group (e.g., "v1")
group = ""
version = apis.GroupVersion
}
// Look for our target kind in this API group version
for _, resource := range apis.APIResources {
if resource.Kind == gk.Kind {
return schema.GroupVersionResource{
Group: group,
Version: version,
Resource: resource.Name,
}, schema.GroupVersionKind{
Group: group,
Version: version,
Kind: resource.Kind,
}, nil
}
}
}
return schema.GroupVersionResource{}, schema.GroupVersionKind{}, fmt.Errorf("preferred version not found for kind %s in group %s", gk.Kind, gk.Group)
}
@@ -2645,6 +2645,18 @@
"ref": {
"description": "Ref to the branch or commit hash to delete from",
"type": "string"
},
"resources": {
"description": "Resources to delete 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"
}
]
}
}
}
},
@@ -3816,6 +3828,23 @@
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRef": {
"type": "object",
"properties": {
"group": {
"description": "Group is the group of the resource, such as \"dashboard.grafana.app\".",
"type": "string"
},
"kind": {
"description": "Kind is the type of resource, for example, \"Dashboard\".",
"type": "string"
},
"name": {
"description": "Name is the name of the resource, such as a dashboard UID.",
"type": "string"
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ResourceRepositoryInfo": {
"type": "object",
"required": [
@@ -927,6 +927,340 @@ func TestIntegrationProvisioning_DeleteJob(t *testing.T) {
require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting all")
})
t.Run("delete by resource reference", func(t *testing.T) {
// Create modified test files with unique UIDs for ResourceRef testing
// Read and modify the testdata files to have unique UIDs that don't conflict with existing resources
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": "resourceref1"`, 1)
textOptionsModified := strings.Replace(string(textOptionsContent), `"uid": "WZ7AhQiVz"`, `"uid": "resourceref2"`, 1)
timelineDemoModified := strings.Replace(string(timelineDemoContent), `"uid": "mIJjFy8Kz"`, `"uid": "resourceref3"`, 1)
// Create temporary files and copy them to the provisioning path
tmpDir := t.TempDir()
tmpFile1 := filepath.Join(tmpDir, "resource-test-1.json")
tmpFile2 := filepath.Join(tmpDir, "resource-test-2.json")
tmpFile3 := filepath.Join(tmpDir, "resource-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 the temporary files to the provisioning path
helper.CopyToProvisioningPath(t, tmpFile1, "resource-test-1.json") // UID: resourceref1
helper.CopyToProvisioningPath(t, tmpFile2, "resource-test-2.json") // UID: resourceref2
helper.CopyToProvisioningPath(t, tmpFile3, "nested/resource-test-3.json") // UID: resourceref3
// Trigger sync to populate the new resources
helper.SyncAndWait(t, repo, nil)
// Verify the new resources are created
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.GreaterOrEqual(t, len(dashboards.Items), 3, "should have at least 3 dashboards after adding test resources")
// Debug: print the actual dashboard names/UIDs to verify they match our expectations
for i, dashboard := range dashboards.Items {
t.Logf("Dashboard %d: name=%s, UID=%s", i+1, dashboard.GetName(), dashboard.GetUID())
}
t.Run("delete single dashboard by resource reference", func(t *testing.T) {
// Create delete job for single dashboard using ResourceRef
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "resourceref1", // 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 delete job with ResourceRef")
// Wait for job to complete
helper.AwaitJobs(t, repo)
// Verify corresponding file is deleted from repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-1.json")
require.Error(t, err, "file should be deleted from repository")
require.True(t, apierrors.IsNotFound(err), "should be not found error")
// Verify dashboard is removed from Grafana (check count like other successful tests)
dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, 2, len(dashboards.Items), "should have 2 dashboards after deleting 1 from 3")
// Verify other resources still exist
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json")
require.NoError(t, err, "other files should still exist")
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json")
require.NoError(t, err, "nested files should still exist")
})
t.Run("delete multiple resources by reference", func(t *testing.T) {
// Create delete job for multiple resources using ResourceRef
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "resourceref2", // UID from modified text-options.json
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "resourceref3", // UID from modified timeline-demo.json
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create delete job with multiple ResourceRefs")
// Wait for job to complete
helper.AwaitJobs(t, repo)
// Verify both dashboards are removed from Grafana
_, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{})
require.Error(t, err, "text-options dashboard should be deleted")
require.True(t, apierrors.IsNotFound(err))
_, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{})
require.Error(t, err, "timeline-demo dashboard should be deleted")
require.True(t, apierrors.IsNotFound(err))
// Verify corresponding files are deleted from repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "resource-test-2.json")
require.Error(t, err, "resource-test-2.json should be deleted")
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "nested", "resource-test-3.json")
require.Error(t, err, "nested/resource-test-3.json should be deleted")
// Verify specific dashboards are removed from Grafana
dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, 0, len(dashboards.Items), "should have 0 dashboards after deleting 2 more (2 -> 0)")
})
t.Run("mixed deletion - paths and resources", func(t *testing.T) {
// Setup fresh resources for mixed test - reuse the modified content with unique UIDs
tmpMixed1 := filepath.Join(tmpDir, "mixed-test-1.json")
tmpMixed2 := filepath.Join(tmpDir, "mixed-test-2.json")
tmpMixed3 := filepath.Join(tmpDir, "mixed-test-3.json")
require.NoError(t, os.WriteFile(tmpMixed1, []byte(allPanelsModified), 0644))
require.NoError(t, os.WriteFile(tmpMixed2, []byte(textOptionsModified), 0644))
require.NoError(t, os.WriteFile(tmpMixed3, []byte(timelineDemoModified), 0644))
helper.CopyToProvisioningPath(t, tmpMixed1, "mixed-test-1.json") // UID: resourceref1
helper.CopyToProvisioningPath(t, tmpMixed2, "mixed-test-2.json") // UID: resourceref2
helper.CopyToProvisioningPath(t, tmpMixed3, "mixed-test-3.json") // UID: resourceref3
helper.SyncAndWait(t, repo, nil)
// Create delete job that combines both paths and resource references
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Paths: []string{"mixed-test-1.json"}, // Delete by path
Resources: []provisioning.ResourceRef{
{
Name: "resourceref2", // Delete 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 delete job")
// Wait for job to complete
helper.AwaitJobs(t, repo)
// Verify both targeted resources are deleted from Grafana
_, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref1", metav1.GetOptions{})
require.Error(t, err, "dashboard deleted by path should be removed")
_, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref2", metav1.GetOptions{})
require.Error(t, err, "dashboard deleted by resource ref should be removed")
// Verify the untargeted resource still exists
_, err = helper.DashboardsV1.Resource.Get(ctx, "resourceref3", metav1.GetOptions{})
require.NoError(t, err, "untargeted dashboard should still exist")
// Verify files are properly deleted/preserved in repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-1.json")
require.Error(t, err, "file deleted by path should be removed")
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-2.json")
require.Error(t, err, "file for resource deleted by ref should be removed")
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "mixed-test-3.json")
require.NoError(t, err, "untargeted file should still exist")
})
t.Run("delete folder by resource reference", func(t *testing.T) {
// Create a dashboard inside a folder to automatically create the folder structure
// This follows the same pattern as other tests in this file
testDashboard := strings.Replace(string(allPanelsContent), `"uid": "n1jR8vnnz"`, `"uid": "folder-dash"`, 1)
// Write the modified dashboard to a temporary file first
tmpFolderDash := filepath.Join(tmpDir, "folder-dashboard.json")
require.NoError(t, os.WriteFile(tmpFolderDash, []byte(testDashboard), 0644))
// Copy it to the folder structure using the helper
helper.CopyToProvisioningPath(t, tmpFolderDash, "test-folder/dashboard-in-folder.json")
// Sync to create the folder and its contents
helper.SyncAndWait(t, repo, nil)
// Verify folder was created in Grafana as a Folder resource
folders, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
var testFolder *unstructured.Unstructured
for _, folder := range folders.Items {
// Folder names are generated with suffixes, so check if it starts with "test-folder"
if strings.HasPrefix(folder.GetName(), "test-folder") {
testFolder = &folder
break
}
}
require.NotNil(t, testFolder, "test-folder should exist as a Folder resource")
testFolderName := testFolder.GetName()
// Verify dashboard inside the folder exists
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json")
require.NoError(t, err, "dashboard inside folder should exist")
// Create delete job for the folder using ResourceRef (use the actual generated name)
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: testFolderName, // Use the actual generated folder name
Kind: "Folder",
Group: "folder.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create delete job for folder")
// Wait for job to complete
helper.AwaitJobs(t, repo)
// Verify folder is deleted from Grafana
_, err = helper.Folders.Resource.Get(ctx, testFolderName, metav1.GetOptions{})
require.Error(t, err, "folder should be deleted from Grafana")
require.True(t, apierrors.IsNotFound(err), "should be not found error")
// Verify folder contents are also deleted from repository
_, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "test-folder", "dashboard-in-folder.json")
require.Error(t, err, "dashboard inside deleted folder should also be deleted")
require.True(t, apierrors.IsNotFound(err), "should be not found error")
})
t.Run("delete non-existent resource by reference", func(t *testing.T) {
// Create delete job for non-existent resource
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(asJSON(&provisioning.JobSpec{
Action: provisioning.JobActionDelete,
Delete: &provisioning.DeleteJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "non-existent-uid",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
})).
SetHeader("Content-Type", "application/json").
Do(ctx)
require.NoError(t, result.Error(), "should be able to create delete 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 delete job
var deleteJob *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 == "delete" {
// Get the most recent one (they should be ordered by creation time)
deleteJob = &elem
}
}
if !assert.NotNil(collect, deleteJob, "should find a delete job") {
return
}
state := mustNestedString(deleteJob.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 delete job to handle non-existent resource")
})
// Repository cleanup is handled by the main test function
})
t.Run("delete non-existent file", func(t *testing.T) {
// Create delete job for non-existent file
result := helper.AdminREST.Post().
@@ -758,11 +758,21 @@ export type ObjectMeta = {
Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string;
};
export type ResourceRef = {
/** Group is the group of the resource, such as "dashboard.grafana.app". */
group?: string;
/** Kind is the type of resource, for example, "Dashboard". */
kind?: string;
/** Name is the name of the resource, such as a dashboard UID. */
name?: string;
};
export type DeleteJobOptions = {
/** Paths to be deleted. Examples: - dashboard.json (for a file) - a/b/c/other-dashboard.json (for a file) - nested/deep/ (for a directory) FIXME: we should validate this in admission hooks */
paths?: string[];
/** Ref to the branch or commit hash to delete from */
ref?: string;
/** Resources to delete 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. */
resources?: ResourceRef[];
};
export type MigrateJobOptions = {
/** Preserve history (if possible) */
@@ -792,7 +802,6 @@ export type ExportJobOptions = {
/** Message to use when committing the changes in a single commit */
message?: string;
/** FIXME: we should validate this in admission hooks Prefix in target file system */
/** Prefix in target file system */
path?: string;
};
export type JobSpec = {