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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user