diff --git a/pkg/registry/apis/dashboard/large.go b/pkg/registry/apis/dashboard/large.go index c9c83f0dd37..21c9d5f8832 100644 --- a/pkg/registry/apis/dashboard/large.go +++ b/pkg/registry/apis/dashboard/large.go @@ -8,7 +8,8 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" commonV0 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -35,8 +36,14 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis reduceUnstructredSpec(&dash.Spec) case *dashboardV1.Dashboard: reduceUnstructredSpec(&dash.Spec) - case *dashboardV2.Dashboard: - dash.Spec = dashboardV2.DashboardSpec{ + case *dashboardV2alpha1.Dashboard: + dash.Spec = dashboardV2alpha1.DashboardSpec{ + Title: dash.Spec.Title, + Description: dash.Spec.Description, + Tags: dash.Spec.Tags, + } + case *dashboardV2alpha2.Dashboard: + dash.Spec = dashboardV2alpha2.DashboardSpec{ Title: dash.Spec.Title, Description: dash.Spec.Description, Tags: dash.Spec.Tags, @@ -55,7 +62,9 @@ func NewDashboardLargeObjectSupport(scheme *runtime.Scheme, threshold int) *apis return dash.Spec.UnmarshalJSON(blob) case *dashboardV1.Dashboard: return dash.Spec.UnmarshalJSON(blob) - case *dashboardV2.Dashboard: + case *dashboardV2alpha1.Dashboard: + return json.Unmarshal(blob, &dash.Spec) + case *dashboardV2alpha2.Dashboard: return json.Unmarshal(blob, &dash.Spec) default: return fmt.Errorf("unsupported dashboard type %T", obj) diff --git a/pkg/registry/apis/dashboard/large_test.go b/pkg/registry/apis/dashboard/large_test.go index cdeeb0310f8..fa62d9e62f6 100644 --- a/pkg/registry/apis/dashboard/large_test.go +++ b/pkg/registry/apis/dashboard/large_test.go @@ -11,7 +11,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashv2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" ) func TestLargeDashboardSupportV1(t *testing.T) { @@ -73,47 +74,47 @@ func TestLargeDashboardSupportV1(t *testing.T) { require.Len(t, panels, expectedPanelCount) } -func TestLargeDashboardSupportV2(t *testing.T) { +func TestLargeDashboardSupportV2alpha1(t *testing.T) { // Test RebuildSpec functionality specifically for v2 dashboards // This tests the json.Unmarshal(blob, &dash.Spec) path for structured specs // unlike v0/v1 which use the UnmarshalJSON path for unstructured specs // Create a v2 dashboard with structured spec - originalV2Dash := &dashv2.Dashboard{ + originalV2Dash := &dashv2alpha1.Dashboard{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2", Namespace: "test", }, - Spec: dashv2.DashboardSpec{ + Spec: dashv2alpha1.DashboardSpec{ Title: "Test V2 Dashboard", Description: stringPtr("A test dashboard for v2 large object support"), Tags: []string{"test", "v2", "large-object"}, Editable: boolPtr(true), LiveNow: boolPtr(false), Preload: false, - Annotations: []dashv2.DashboardAnnotationQueryKind{ + Annotations: []dashv2alpha1.DashboardAnnotationQueryKind{ { Kind: "AnnotationQuery", - Spec: dashv2.DashboardAnnotationQuerySpec{ + Spec: dashv2alpha1.DashboardAnnotationQuerySpec{ Name: "Test Annotation", }, }, }, - Elements: map[string]dashv2.DashboardElement{ + Elements: map[string]dashv2alpha1.DashboardElement{ "panel-1": { - PanelKind: &dashv2.DashboardPanelKind{}, + PanelKind: &dashv2alpha1.DashboardPanelKind{}, }, }, - Layout: dashv2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{}, - TimeSettings: dashv2.DashboardTimeSettingsSpec{}, - CursorSync: dashv2.DashboardDashboardCursorSyncOff, - Variables: []dashv2.DashboardVariableKind{}, - Links: []dashv2.DashboardDashboardLink{}, + Layout: dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{}, + TimeSettings: dashv2alpha1.DashboardTimeSettingsSpec{}, + CursorSync: dashv2alpha1.DashboardDashboardCursorSyncOff, + Variables: []dashv2alpha1.DashboardVariableKind{}, + Links: []dashv2alpha1.DashboardDashboardLink{}, }, } scheme := runtime.NewScheme() - err := dashv2.AddToScheme(scheme) + err := dashv2alpha1.AddToScheme(scheme) require.NoError(t, err) largeObject := NewDashboardLargeObjectSupport(scheme, 0) @@ -142,7 +143,107 @@ func TestLargeDashboardSupportV2(t *testing.T) { require.Empty(t, dashToReduce.Spec.Links) // Now test RebuildSpec - this is the key test for v2! - rehydratedDash := &dashv2.Dashboard{ + rehydratedDash := &dashv2alpha1.Dashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-v2-rehydrated", + Namespace: "test", + }, + } + + // This tests the json.Unmarshal(blob, &dash.Spec) path for v2 dashboards + err = largeObject.RebuildSpec(rehydratedDash, originalSpecBlob) + require.NoError(t, err) + + // Verify the full dashboard spec is restored correctly + require.Equal(t, originalV2Dash.Spec.Title, rehydratedDash.Spec.Title) + require.Equal(t, originalV2Dash.Spec.Description, rehydratedDash.Spec.Description) + require.Equal(t, originalV2Dash.Spec.Tags, rehydratedDash.Spec.Tags) + require.Equal(t, originalV2Dash.Spec.Editable, rehydratedDash.Spec.Editable) + require.Equal(t, originalV2Dash.Spec.LiveNow, rehydratedDash.Spec.LiveNow) + require.Equal(t, originalV2Dash.Spec.Preload, rehydratedDash.Spec.Preload) + + // Verify annotations are restored + require.Len(t, rehydratedDash.Spec.Annotations, 1) + annotation := rehydratedDash.Spec.Annotations[0] + require.Equal(t, "AnnotationQuery", annotation.Kind) + require.Equal(t, "Test Annotation", annotation.Spec.Name) + + // Verify elements are restored + require.Len(t, rehydratedDash.Spec.Elements, 1) + _, exists := rehydratedDash.Spec.Elements["panel-1"] + require.True(t, exists) +} + +func TestLargeDashboardSupportV2alpha2(t *testing.T) { + // Test RebuildSpec functionality specifically for v2 dashboards + // This tests the json.Unmarshal(blob, &dash.Spec) path for structured specs + // unlike v0/v1 which use the UnmarshalJSON path for unstructured specs + + // Create a v2 dashboard with structured spec + originalV2Dash := &dashv2alpha2.Dashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-v2", + Namespace: "test", + }, + Spec: dashv2alpha2.DashboardSpec{ + Title: "Test V2 Dashboard", + Description: stringPtr("A test dashboard for v2 large object support"), + Tags: []string{"test", "v2", "large-object"}, + Editable: boolPtr(true), + LiveNow: boolPtr(false), + Preload: false, + Annotations: []dashv2alpha2.DashboardAnnotationQueryKind{ + { + Kind: "AnnotationQuery", + Spec: dashv2alpha2.DashboardAnnotationQuerySpec{ + Name: "Test Annotation", + }, + }, + }, + Elements: map[string]dashv2alpha2.DashboardElement{ + "panel-1": { + PanelKind: &dashv2alpha2.DashboardPanelKind{}, + }, + }, + Layout: dashv2alpha2.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{}, + TimeSettings: dashv2alpha2.DashboardTimeSettingsSpec{}, + CursorSync: dashv2alpha2.DashboardDashboardCursorSyncOff, + Variables: []dashv2alpha2.DashboardVariableKind{}, + Links: []dashv2alpha2.DashboardDashboardLink{}, + }, + } + + scheme := runtime.NewScheme() + err := dashv2alpha2.AddToScheme(scheme) + require.NoError(t, err) + + largeObject := NewDashboardLargeObjectSupport(scheme, 0) + + // Marshal the original spec to use as our "blob" data + originalSpecBlob, err := json.Marshal(originalV2Dash.Spec) + require.NoError(t, err) + + // Create a copy to test reduction + dashToReduce := originalV2Dash.DeepCopy() + + // Convert the dashboard to a small value (ReduceSpec) + err = largeObject.ReduceSpec(dashToReduce) + require.NoError(t, err) + + // Verify only essential fields remain after reduction + require.Equal(t, "Test V2 Dashboard", dashToReduce.Spec.Title) + require.Equal(t, stringPtr("A test dashboard for v2 large object support"), dashToReduce.Spec.Description) + require.Equal(t, []string{"test", "v2", "large-object"}, dashToReduce.Spec.Tags) + + // Everything else should be empty/default + require.Empty(t, dashToReduce.Spec.Annotations) + require.Empty(t, dashToReduce.Spec.Elements) + require.Nil(t, dashToReduce.Spec.Layout.GridLayoutKind) + require.Empty(t, dashToReduce.Spec.Variables) + require.Empty(t, dashToReduce.Spec.Links) + + // Now test RebuildSpec - this is the key test for v2! + rehydratedDash := &dashv2alpha2.Dashboard{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2-rehydrated", Namespace: "test", diff --git a/pkg/registry/apis/dashboard/mutate.go b/pkg/registry/apis/dashboard/mutate.go index 2470d35b417..574cf5ea634 100644 --- a/pkg/registry/apis/dashboard/mutate.go +++ b/pkg/registry/apis/dashboard/mutate.go @@ -11,7 +11,8 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -57,17 +58,30 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute Error: migrationErr.Error(), } } - case *dashboardV2.Dashboard: + + case *dashboardV2alpha1.Dashboard: // Temporary fix: The generator fails to properly initialize this property, so we'll do it here // until the generator is fixed. if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil { - v.Spec.Layout.GridLayoutKind = &dashboardV2.DashboardGridLayoutKind{ + v.Spec.Layout.GridLayoutKind = &dashboardV2alpha1.DashboardGridLayoutKind{ Kind: "GridLayout", - Spec: dashboardV2.DashboardGridLayoutSpec{}, + Spec: dashboardV2alpha1.DashboardGridLayoutSpec{}, } } - resourceInfo = dashboardV2.DashboardResourceInfo + resourceInfo = dashboardV2alpha1.DashboardResourceInfo + + case *dashboardV2alpha2.Dashboard: + // Temporary fix: The generator fails to properly initialize this property, so we'll do it here + // until the generator is fixed. + if v.Spec.Layout.GridLayoutKind == nil && v.Spec.Layout.RowsLayoutKind == nil && v.Spec.Layout.AutoGridLayoutKind == nil && v.Spec.Layout.TabsLayoutKind == nil { + v.Spec.Layout.GridLayoutKind = &dashboardV2alpha2.DashboardGridLayoutKind{ + Kind: "GridLayout", + Spec: dashboardV2alpha2.DashboardGridLayoutSpec{}, + } + } + + resourceInfo = dashboardV2alpha2.DashboardResourceInfo // Noop for V2 default: diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index 63ddefd236d..bfbbb7f0109 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -12,7 +12,8 @@ import ( dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashv2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" "github.com/grafana/grafana/apps/dashboard/pkg/migration" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" "github.com/grafana/grafana/apps/dashboard/pkg/migration/testutil" @@ -141,9 +142,19 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { expectedError: false, }, { - name: "v2 should set layout if it is not set", - inputObj: &v2alpha1.Dashboard{ - Spec: v2alpha1.DashboardSpec{ + name: "v2alpha1 should set layout if it is not set", + inputObj: &dashv2alpha1.Dashboard{ + Spec: dashv2alpha1.DashboardSpec{ + Title: "test123", + }, + }, + operation: admission.Create, + expectedTitle: "test123", + }, + { + name: "v2alpha2 should set layout if it is not set", + inputObj: &dashv2alpha2.Dashboard{ + Spec: dashv2alpha2.DashboardSpec{ Title: "test123", }, }, @@ -203,7 +214,8 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) { if tt.migrationExpected { require.Equal(t, schemaversion.LATEST_VERSION, schemaVersion, "dashboard should be migrated to the latest version") } - case *v2alpha1.Dashboard: + case *dashv2alpha1.Dashboard: + case *dashv2alpha2.Dashboard: require.Equal(t, tt.expectedTitle, v.Spec.Title, "title should be set") require.NotNil(t, v.Spec.Layout, "layout should be set") require.NotNil(t, v.Spec.Layout.GridLayoutKind, "layout should be a GridLayout") diff --git a/pkg/registry/apis/dashboard/schema_validation.go b/pkg/registry/apis/dashboard/schema_validation.go index 26749eaa598..91629e5eeb5 100644 --- a/pkg/registry/apis/dashboard/schema_validation.go +++ b/pkg/registry/apis/dashboard/schema_validation.go @@ -12,7 +12,8 @@ import ( v0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" v1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - v2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + v2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + v2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/featuremgmt" ) @@ -32,7 +33,8 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru errorOnSchemaMismatches = false // Never error for v0 case *v1.Dashboard: errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1) - case *v2.Dashboard: + case *v2alpha1.Dashboard: + case *v2alpha2.Dashboard: errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2) default: return nil, fmt.Errorf("invalid dashboard type: %T", obj) @@ -52,8 +54,10 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru errors, schemaVersionError = v0.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) case *v1.Dashboard: errors, schemaVersionError = v1.ValidateDashboardSpec(v, alwaysLogSchemaValidationErrors) - case *v2.Dashboard: - errors = v2.ValidateDashboardSpec(v) + case *v2alpha1.Dashboard: + errors = v2alpha1.ValidateDashboardSpec(v) + case *v2alpha2.Dashboard: + errors = v2alpha2.ValidateDashboardSpec(v) } } diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index e02b7d7bfad..c226cacac00 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -38,21 +38,30 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, // When requesting v2 (or v0) dashboards over the v1 api, we want to keep the original apiVersion if conversion fails var shim conversionShim if kind.GroupResource() == resources.DashboardResource.GroupResource() { - var v2client dynamic.ResourceInterface + var v2clientAlphaV1, v2clientAlphaV2 dynamic.ResourceInterface shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) { failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed") if failed { storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion") - // For v2 we need to request the original version - if strings.HasPrefix(storedVersion, "v2") { - if v2client == nil { - v2client, _, err = clients.ForResource(resources.DashboardResourceV2) + if strings.HasPrefix(storedVersion, "v2alpha1") { + if v2clientAlphaV1 == nil { + v2clientAlphaV1, _, err = clients.ForResource(resources.DashboardResourceV2alpha1) if err != nil { return nil, err } } - return v2client.Get(ctx, item.GetName(), metav1.GetOptions{}) + return v2clientAlphaV1.Get(ctx, item.GetName(), metav1.GetOptions{}) + } + + if strings.HasPrefix(storedVersion, "v2alpha2") { + if v2clientAlphaV2 == nil { + v2clientAlphaV2, _, err = clients.ForResource(resources.DashboardResourceV2alpha2) + if err != nil { + return nil, err + } + } + return v2clientAlphaV2.Get(ctx, item.GetName(), metav1.GetOptions{}) } // For v0 we can simply fallback -- the full model is saved, but @@ -97,6 +106,7 @@ func exportResource(ctx context.Context, if shim != nil { item, err = shim(ctx, item) } + if err == nil { result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{ Path: options.Path, diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_test.go index 64f7d256866..2e663b64e57 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_test.go @@ -16,432 +16,519 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) -func TestExportResources_Dashboards(t *testing.T) { - tests := []struct { - name string - mockItems []unstructured.Unstructured - expectedError string - setupProgress func(progress *jobs.MockJobProgressRecorder) - setupResources func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) - }{ - { - name: "successful dashboard export", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-1", - }, - }, - }, - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-2", - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated - })).Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil) - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-1" - }), options).Return("dashboard-1.json", nil) - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-2" - }), options).Return("dashboard-2.json", nil) +// Helper function to create dashboard objects +func createDashboardObject(name string) unstructured.Unstructured { + return unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": name, }, }, - { - name: "client error", - mockItems: nil, - expectedError: "get client for dashboards: didn't work", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, fmt.Errorf("didn't work")) - }, - }, - { - name: "dashboard export with errors", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-1", - }, - }, - }, - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-2", - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" - })).Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil) - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-1" - }), options).Return("", fmt.Errorf("failed to export dashboard")) - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-2" - }), options).Return("dashboard-2.json", nil) - }, - }, - { - name: "dashboard export too many errors", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-1", - }, - }, - }, - }, - expectedError: "export dashboards: too many errors encountered", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" - })).Return() - progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered")) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-1" - }), options).Return("", fmt.Errorf("failed to export dashboard")) - }, - }, - { - name: "ignores existing dashboards", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "existing-dashboard", - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored - })).Return() - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "existing-dashboard" - }), options).Return("", resources.ErrAlreadyInRepository) - }, - }, - { - name: "uses saved dashboard version", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "existing-dashboard", - }, - "spec": map[string]interface{}{ - "hello": "world", - }, - "status": map[string]interface{}{ - "conversion": map[string]interface{}{ - "failed": true, - "storedVersion": "v0xyz", - }, - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored - })).Return() - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - - repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - // Verify that the object has the expected status.conversion.storedVersion field - status, exists, err := unstructured.NestedMap(obj.Object, "status") - if !exists || err != nil { - return false - } - - conversion, exists, err := unstructured.NestedMap(status, "conversion") - if !exists || err != nil { - return false - } - - storedVersion, exists, err := unstructured.NestedString(conversion, "storedVersion") - if !exists || err != nil { - return false - } - - if storedVersion != "v0xyz" { - return false - } - - return obj.GetName() == "existing-dashboard" - }), options).Return("", fmt.Errorf("XXX")) - }, - }, - { - name: "dashboard with failed conversion but no stored version", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "dashboard-no-stored-version", - }, - "status": map[string]interface{}{ - "conversion": map[string]interface{}{ - "failed": true, - // No storedVersion field - }, - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-no-stored-version" && - result.Action == repository.FileActionIgnored && - result.Error != nil - })).Return() - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - // The value is not saved - }, - }, - { - name: "handles v2 dashboard version", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "v2-dashboard", - }, - "status": map[string]interface{}{ - "conversion": map[string]interface{}{ - "failed": true, - "storedVersion": "v2", - }, - }, - }, - }, - }, - expectedError: "", - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - // Setup v1 client - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - - // Setup v2 client - - // Mock v2 client Get call - v2Dashboard := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "dashboard.grafana.app/v2alpha1", - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "v2-dashboard", - }, - "spec": map[string]interface{}{ - "version": 2, - "title": "V2 Dashboard", - }, - }, - } - v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{*v2Dashboard}} - resourceClients.On("ForResource", resources.DashboardResourceV2).Return(v2Client, gvk, nil) - - options := resources.WriteOptions{ - Path: "grafana", - Ref: "feature/branch", - } - repoResources.On("WriteResourceFileFromObject", mock.Anything, v2Dashboard, options).Return("v2-dashboard.json", nil) - }, - }, - { - name: "handles v2 client creation error", - mockItems: []unstructured.Unstructured{ - { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "v2-dashboard-error", - }, - "status": map[string]interface{}{ - "conversion": map[string]interface{}{ - "failed": true, - "storedVersion": "v2", - }, - }, - }, - }, - }, - setupProgress: func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "start resource export").Return() - progress.On("SetMessage", mock.Anything, "export dashboards").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - if result.Name != "v2-dashboard-error" { - return false - } - if result.Action != repository.FileActionIgnored { - return false - } - if result.Error == nil { - return false - } - - if result.Error.Error() != "v2 client error" { - return false - } - - return true - })).Return() - progress.On("TooManyErrors").Return(nil) - }, - setupResources: func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) - resourceClients.On("ForResource", resources.DashboardResourceV2).Return(nil, gvk, fmt.Errorf("v2 client error")) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClient := &mockDynamicInterface{ - items: tt.mockItems, - } - - resourceClients := resources.NewMockResourceClients(t) - mockProgress := jobs.NewMockJobProgressRecorder(t) - tt.setupProgress(mockProgress) - - repoResources := resources.NewMockRepositoryResources(t) - tt.setupResources(repoResources, resourceClients, mockClient, schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Version: resources.DashboardResource.Version, - Kind: "DashboardList", - }) - - options := provisioningV0.ExportJobOptions{ - Path: "grafana", - Branch: "feature/branch", - } - - err := ExportResources(context.Background(), options, resourceClients, repoResources, mockProgress) - if tt.expectedError != "" { - require.EqualError(t, err, tt.expectedError) - } else { - require.NoError(t, err) - } - - mockProgress.AssertExpectations(t) - repoResources.AssertExpectations(t) - resourceClients.AssertExpectations(t) - }) } } + +// Helper function to create v2 dashboard objects +func createV2DashboardObject(name, version string) unstructured.Unstructured { + return unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": fmt.Sprintf("dashboard.grafana.app/%s", version), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": name, + }, + "spec": map[string]interface{}{ + "version": 2, + "title": "V2 Dashboard", + }, + }, + } +} + +// Helper function to run export test +func runExportTest(t *testing.T, mockItems []unstructured.Unstructured, setupProgress func(*jobs.MockJobProgressRecorder), setupResources func(*resources.MockRepositoryResources, *resources.MockResourceClients, *mockDynamicInterface, schema.GroupVersionKind)) error { + mockClient := &mockDynamicInterface{ + items: mockItems, + } + + resourceClients := resources.NewMockResourceClients(t) + mockProgress := jobs.NewMockJobProgressRecorder(t) + setupProgress(mockProgress) + + repoResources := resources.NewMockRepositoryResources(t) + setupResources(repoResources, resourceClients, mockClient, schema.GroupVersionKind{ + Group: resources.DashboardResource.Group, + Version: resources.DashboardResource.Version, + Kind: "DashboardList", + }) + + options := provisioningV0.ExportJobOptions{ + Path: "grafana", + Branch: "feature/branch", + } + + err := ExportResources(context.Background(), options, resourceClients, repoResources, mockProgress) + + mockProgress.AssertExpectations(t) + repoResources.AssertExpectations(t) + resourceClients.AssertExpectations(t) + + return err +} + +func TestExportResources_Dashboards_Success(t *testing.T) { + mockItems := []unstructured.Unstructured{ + createDashboardObject("dashboard-1"), + createDashboardObject("dashboard-2"), + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated + })).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "dashboard-1" + }), options).Return("dashboard-1.json", nil) + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "dashboard-2" + }), options).Return("dashboard-2.json", nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_ClientError(t *testing.T) { + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, fmt.Errorf("didn't work")) + } + + err := runExportTest(t, nil, setupProgress, setupResources) + require.EqualError(t, err, "get client for dashboards: didn't work") +} + +func TestExportResources_Dashboards_WithErrors(t *testing.T) { + mockItems := []unstructured.Unstructured{ + createDashboardObject("dashboard-1"), + createDashboardObject("dashboard-2"), + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" + })).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "dashboard-1" + }), options).Return("", fmt.Errorf("failed to export dashboard")) + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "dashboard-2" + }), options).Return("dashboard-2.json", nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_TooManyErrors(t *testing.T) { + mockItems := []unstructured.Unstructured{ + createDashboardObject("dashboard-1"), + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-1" && result.Action == repository.FileActionIgnored && result.Error != nil && result.Error.Error() == "failed to export dashboard" + })).Return() + progress.On("TooManyErrors").Return(fmt.Errorf("too many errors encountered")) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "dashboard-1" + }), options).Return("", fmt.Errorf("failed to export dashboard")) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.EqualError(t, err, "export dashboards: too many errors encountered") +} + +func TestExportResources_Dashboards_IgnoresExisting(t *testing.T) { + mockItems := []unstructured.Unstructured{ + createDashboardObject("existing-dashboard"), + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "existing-dashboard" + }), options).Return("", resources.ErrAlreadyInRepository) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_SavedVersion(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "existing-dashboard", + }, + "spec": map[string]interface{}{ + "hello": "world", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v0xyz", + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "existing-dashboard" && result.Action == repository.FileActionIgnored + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + // Verify that the object has the expected status.conversion.storedVersion field + status, exists, err := unstructured.NestedMap(obj.Object, "status") + if !exists || err != nil { + return false + } + + conversion, exists, err := unstructured.NestedMap(status, "conversion") + if !exists || err != nil { + return false + } + + storedVersion, exists, err := unstructured.NestedString(conversion, "storedVersion") + if !exists || err != nil { + return false + } + + if storedVersion != "v0xyz" { + return false + } + + return obj.GetName() == "existing-dashboard" + }), options).Return("", fmt.Errorf("XXX")) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_FailedConversionNoStoredVersion(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "dashboard-no-stored-version", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + // No storedVersion field + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "dashboard-no-stored-version" && + result.Action == repository.FileActionIgnored && + result.Error != nil + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + // The value is not saved + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_V2Alpha1(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2-dashboard", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2alpha1", + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + // Setup v1 client + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + + // Setup v2 client + v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1") + v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}} + resourceClients.On("ForResource", resources.DashboardResourceV2alpha1).Return(v2Client, gvk, nil) + + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2Dashboard, options).Return("v2-dashboard.json", nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_V2Alpha1_ClientError(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2-dashboard-error", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2alpha1", + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + if result.Name != "v2-dashboard-error" { + return false + } + if result.Action != repository.FileActionIgnored { + return false + } + if result.Error == nil { + return false + } + + if result.Error.Error() != "v2 client error" { + return false + } + + return true + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResourceV2alpha1).Return(nil, gvk, fmt.Errorf("v2 client error")) + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_V2Alpha2(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2-dashboard", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2alpha2", + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + // Setup v1 client + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + + // Setup v2 client + v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha2") + v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}} + resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(v2Client, gvk, nil) + + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2Dashboard, options).Return("v2-dashboard.json", nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} + +func TestExportResources_Dashboards_V2Alpha2_ClientError(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2-dashboard-error", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2alpha2", + }, + }, + }, + }, + } + + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + if result.Name != "v2-dashboard-error" { + return false + } + if result.Action != repository.FileActionIgnored { + return false + } + if result.Error == nil { + return false + } + + if result.Error.Error() != "v2 client error" { + return false + } + + return true + })).Return() + progress.On("TooManyErrors").Return(nil) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + resourceClients.On("ForResource", resources.DashboardResourceV2alpha2).Return(nil, gvk, fmt.Errorf("v2 client error")) + resourceClients.On("ForResource", resources.DashboardResource).Return(mockClient, gvk, nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go index b27f45b6892..afc5261b2e8 100644 --- a/pkg/registry/apis/provisioning/resources/client.go +++ b/pkg/registry/apis/provisioning/resources/client.go @@ -11,7 +11,8 @@ import ( "k8s.io/client-go/dynamic" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver" @@ -19,10 +20,11 @@ import ( ) var ( - UserResource = iam.UserResourceInfo.GroupVersionResource() - FolderResource = folders.FolderResourceInfo.GroupVersionResource() - DashboardResource = dashboardV1.DashboardResourceInfo.GroupVersionResource() - DashboardResourceV2 = dashboardV2.DashboardResourceInfo.GroupVersionResource() + UserResource = iam.UserResourceInfo.GroupVersionResource() + FolderResource = folders.FolderResourceInfo.GroupVersionResource() + DashboardResource = dashboardV1.DashboardResourceInfo.GroupVersionResource() + DashboardResourceV2alpha1 = dashboardV2alpha1.DashboardResourceInfo.GroupVersionResource() + DashboardResourceV2alpha2 = dashboardV2alpha2.DashboardResourceInfo.GroupVersionResource() // SupportedProvisioningResources is the list of resources that can fully managed from the UI SupportedProvisioningResources = []schema.GroupVersionResource{FolderResource, DashboardResource} diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index c495f0504ac..1a1d538cc11 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -11,7 +11,8 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "google.golang.org/protobuf/types/known/wrapperspb" - dashboardalpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -87,8 +88,21 @@ func (s *Server) getContextuals(subject string) (*openfgav1.ContextualTupleKeys, User: subject, Relation: common.RelationSetView, Object: common.NewGroupResourceIdent( - dashboardalpha1.DashboardResourceInfo.GroupResource().Group, - dashboardalpha1.DashboardResourceInfo.GroupResource().Resource, + dashboardV2alpha1.DashboardResourceInfo.GroupResource().Group, + dashboardV2alpha1.DashboardResourceInfo.GroupResource().Resource, + "", + ), + }, + ) + + contextuals = append( + contextuals, + &openfgav1.TupleKey{ + User: subject, + Relation: common.RelationSetView, + Object: common.NewGroupResourceIdent( + dashboardV2alpha2.DashboardResourceInfo.GroupResource().Group, + dashboardV2alpha2.DashboardResourceInfo.GroupResource().Resource, "", ), }, diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 900b7ed7da2..7e8c36c003a 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -23,7 +23,8 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" ) func TestMain(m *testing.M) { @@ -164,10 +165,10 @@ func TestIntegrationDashboardsAppV1(t *testing.T) { } } -func TestIntegrationDashboardsAppV2(t *testing.T) { +func TestIntegrationDashboardsAppV2alpha1(t *testing.T) { gvr := schema.GroupVersionResource{ - Group: dashboardV2.GROUP, - Version: dashboardV2.VERSION, + Group: dashboardV2alpha1.GROUP, + Version: dashboardV2alpha1.VERSION, Resource: "dashboards", } if testing.Short() { @@ -176,7 +177,33 @@ func TestIntegrationDashboardsAppV2(t *testing.T) { modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} for _, mode := range modes { - t.Run(fmt.Sprintf("v1beta1 with dual writer mode %d", mode), func(t *testing.T) { + t.Run(fmt.Sprintf("v2alpha1 with dual writer mode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: mode, + }, + }, + }) + runDashboardTest(t, helper, gvr) + }) + } +} + +func TestIntegrationDashboardsAppV2alpha2(t *testing.T) { + gvr := schema.GroupVersionResource{ + Group: dashboardV2alpha2.GROUP, + Version: dashboardV2alpha2.VERSION, + Resource: "dashboards", + } + if testing.Short() { + t.Skip("skipping integration test") + } + + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} + for _, mode := range modes { + t.Run(fmt.Sprintf("v1alpha2 with dual writer mode %d", mode), func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -223,7 +250,7 @@ func TestIntegrationLegacySupport(t *testing.T) { clientV2 := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, - GVR: dashboardV2.DashboardResourceInfo.GroupVersionResource(), + GVR: dashboardV2alpha1.DashboardResourceInfo.GroupVersionResource(), }) obj, err = clientV2.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/dashboard-test-v2.yaml"), diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 42e4d9d9a5f..c7242b31340 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -16,7 +16,8 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" foldersV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -345,11 +346,29 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }, { name: "v2alpha1 dashboard with correct spec should not throw on v2", - resourceInfo: dashboardV2.DashboardResourceInfo, + resourceInfo: dashboardV2alpha1.DashboardResourceInfo, expectSpecErr: false, testObject: &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": dashboardV2.DashboardResourceInfo.TypeMeta().APIVersion, + "apiVersion": dashboardV2alpha1.DashboardResourceInfo.TypeMeta().APIVersion, + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "generateName": "test-", + }, + "spec": map[string]interface{}{ + "title": "Dashboard Title", + "description": "valid description", + }, + }, + }, + }, + { + name: "v2alpha2 dashboard with correct spec should not throw on v2", + resourceInfo: dashboardV2alpha2.DashboardResourceInfo, + expectSpecErr: false, + testObject: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": dashboardV2alpha2.DashboardResourceInfo.TypeMeta().APIVersion, "kind": "Dashboard", "metadata": map[string]interface{}{ "generateName": "test-", diff --git a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2.yaml b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha1.yaml similarity index 66% rename from pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2.yaml rename to pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha1.yaml index 540b9dcc919..ddfa58720bf 100644 --- a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2.yaml +++ b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha1.yaml @@ -1,9 +1,9 @@ apiVersion: dashboard.grafana.app/v2alpha1 kind: Dashboard metadata: - name: test-v2 + name: test-v2alpha1 spec: - title: Test dashboard. Created at v2 + title: Test dashboard. Created at v2alpha1 layout: kind: GridLayout spec: diff --git a/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml new file mode 100644 index 00000000000..7a4079cee41 --- /dev/null +++ b/pkg/tests/apis/provisioning/exportunifiedtorepository/dashboard-test-v2alpha2.yaml @@ -0,0 +1,10 @@ +apiVersion: dashboard.grafana.app/v2alpha2 +kind: Dashboard +metadata: + name: test-v2alpha2 +spec: + title: Test dashboard. Created at v2alpha2 + layout: + kind: GridLayout + spec: + items: [] diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index bd398addb04..bde59656092 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -21,7 +21,8 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - dashboardV2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardsV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" + dashboardsV2alpha2 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha2" folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" @@ -41,15 +42,16 @@ type provisioningTestHelper struct { *apis.K8sTestHelper ProvisioningPath string - Repositories *apis.K8sResourceClient - Jobs *apis.K8sResourceClient - Folders *apis.K8sResourceClient - DashboardsV0 *apis.K8sResourceClient - DashboardsV1 *apis.K8sResourceClient - DashboardsV2 *apis.K8sResourceClient - AdminREST *rest.RESTClient - EditorREST *rest.RESTClient - ViewerREST *rest.RESTClient + Repositories *apis.K8sResourceClient + Jobs *apis.K8sResourceClient + Folders *apis.K8sResourceClient + DashboardsV0 *apis.K8sResourceClient + DashboardsV1 *apis.K8sResourceClient + DashboardsV2alpha1 *apis.K8sResourceClient + DashboardsV2alpha2 *apis.K8sResourceClient + AdminREST *rest.RESTClient + EditorREST *rest.RESTClient + ViewerREST *rest.RESTClient } func (h *provisioningTestHelper) SyncAndWait(t *testing.T, repo string, options *provisioning.SyncJobOptions) { @@ -255,10 +257,15 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper Namespace: "default", // actually org1 GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(), }) - dashboardsV2 := helper.GetResourceClient(apis.ResourceClientArgs{ + dashboardsV2alpha1 := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, Namespace: "default", // actually org1 - GVR: dashboardV2.DashboardResourceInfo.GroupVersionResource(), + GVR: dashboardsV2alpha1.DashboardResourceInfo.GroupVersionResource(), + }) + dashboardsV2alpha2 := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: "default", // actually org1 + GVR: dashboardsV2alpha2.DashboardResourceInfo.GroupVersionResource(), }) // Repo client, but less guard rails. Useful for subresources. We'll need this later... @@ -289,15 +296,16 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper ProvisioningPath: provisioningPath, K8sTestHelper: helper, - Repositories: repositories, - AdminREST: adminClient, - EditorREST: editorClient, - ViewerREST: viewerClient, - Jobs: jobs, - Folders: folders, - DashboardsV0: dashboardsV0, - DashboardsV1: dashboardsV1, - DashboardsV2: dashboardsV2, + Repositories: repositories, + AdminREST: adminClient, + EditorREST: editorClient, + ViewerREST: viewerClient, + Jobs: jobs, + Folders: folders, + DashboardsV0: dashboardsV0, + DashboardsV1: dashboardsV1, + DashboardsV2alpha1: dashboardsV2alpha1, + DashboardsV2alpha2: dashboardsV2alpha2, } } diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 4040a854b7a..1c5b5c23600 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io/fs" "net/http" "os" "path/filepath" @@ -21,11 +22,50 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/pkg/extensions" - "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/tests/apis" ) +// printFileTree prints the directory structure as a tree for debugging purposes +func printFileTree(t *testing.T, rootPath string) { + t.Helper() + t.Logf("File tree for %s:", rootPath) + + err := filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(rootPath, path) + if err != nil { + return err + } + + if relPath == "." { + return nil + } + + depth := strings.Count(relPath, string(filepath.Separator)) + indent := strings.Repeat(" ", depth) + + if d.IsDir() { + t.Logf("%s├── %s/", indent, d.Name()) + } else { + info, err := d.Info() + if err != nil { + t.Logf("%s├── %s (error reading info)", indent, d.Name()) + } else { + t.Logf("%s├── %s (%d bytes)", indent, d.Name(), info.Size()) + } + } + + return nil + }) + if err != nil { + t.Logf("Error walking directory: %v", err) + } +} + func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -609,9 +649,13 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { _, err = helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) require.NoError(t, err, "should be able to create v1 dashboard") - dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2.yaml") - _, err = helper.DashboardsV2.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) - require.NoError(t, err, "should be able to create v2 dashboard") + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha1.yaml") + _, err = helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v2alpha1 dashboard") + + dashboard = helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2alpha2.yaml") + _, err = helper.DashboardsV2alpha2.Resource.Create(ctx, dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create v2alpha2 dashboard") // Now for the repository. const repo = "local-repository" @@ -642,15 +686,19 @@ func TestProvisioning_ExportUnifiedToRepository(t *testing.T) { title string apiVersion string name string + fileName string } + printFileTree(t, helper.ProvisioningPath) + // Check that each file was exported with its stored version for _, test := range []props{ - {title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0"}, - {title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1"}, - {title: "Test dashboard. Created at v2", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2"}, + {title: "Test dashboard. Created at v0", apiVersion: "dashboard.grafana.app/v0alpha1", name: "test-v0", fileName: "test-dashboard-created-at-v0.json"}, + {title: "Test dashboard. Created at v1", apiVersion: "dashboard.grafana.app/v1beta1", name: "test-v1", fileName: "test-dashboard-created-at-v1.json"}, + {title: "Test dashboard. Created at v2alpha1", apiVersion: "dashboard.grafana.app/v2alpha1", name: "test-v2alpha1", fileName: "test-dashboard-created-at-v2alpha1.json"}, + {title: "Test dashboard. Created at v2alpha2", apiVersion: "dashboard.grafana.app/v2alpha2", name: "test-v2alpha2", fileName: "test-dashboard-created-at-v2alpha2.json"}, } { - fpath := filepath.Join(helper.ProvisioningPath, slugify.Slugify(test.title)+".json") + fpath := filepath.Join(helper.ProvisioningPath, test.fileName) //nolint:gosec // we are ok with reading files in testdata body, err := os.ReadFile(fpath) require.NoError(t, err, "exported file was not created at path %s", fpath)