diff --git a/pkg/registry/apis/provisioning/jobs/export/resources.go b/pkg/registry/apis/provisioning/jobs/export/resources.go index 35793972d89..f1b8cd078de 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources.go @@ -8,6 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" @@ -36,44 +37,41 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, return fmt.Errorf("get client for %s: %w", kind.Resource, err) } - // When requesting v2 (or v0) dashboards over the v1 api, we want to keep the original apiVersion if conversion fails + // When requesting 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 v2clientAlphaV1, v2clientAlphaV2 dynamic.ResourceInterface + // Cache clients for different versions + versionClients := make(map[string]dynamic.ResourceInterface) shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) { // Check if there's a stored version in the conversion status. // This indicates the original API version the dashboard was created with, // which should be preserved during export regardless of whether conversion succeeded or failed. storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion") if storedVersion != "" { - // For v2 we need to request the original version - if strings.HasPrefix(storedVersion, "v2alpha1") { - if v2clientAlphaV1 == nil { - v2clientAlphaV1, _, err = clients.ForResource(ctx, resources.DashboardResourceV2alpha1) - if err != nil { - return nil, err - } - } - return v2clientAlphaV1.Get(ctx, item.GetName(), metav1.GetOptions{}) - } - - if strings.HasPrefix(storedVersion, "v2beta1") { - if v2clientAlphaV2 == nil { - v2clientAlphaV2, _, err = clients.ForResource(ctx, resources.DashboardResourceV2beta1) - 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 + // For v0 we can simply fallback -- the full model is saved if strings.HasPrefix(storedVersion, "v0") { item.SetAPIVersion(fmt.Sprintf("%s/%s", kind.Group, storedVersion)) return item, nil } - return nil, fmt.Errorf("unsupported dashboard version: %s", storedVersion) + // For any other version (v1, v2, v3, etc.), fetch the original version via client + // Check if we already have a client cached for this version + versionClient, ok := versionClients[storedVersion] + if !ok { + // Dynamically construct the GroupVersionResource for any version + versionGVR := schema.GroupVersionResource{ + Group: kind.Group, + Version: storedVersion, + Resource: kind.Resource, + } + var err error + versionClient, _, err = clients.ForResource(ctx, versionGVR) + if err != nil { + return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err) + } + versionClients[storedVersion] = versionClient + } + return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{}) } // If conversion failed but there's no storedVersion, this is an error condition diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_test.go index 709bdc3affc..36bf304cc70 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_test.go @@ -336,202 +336,188 @@ func TestExportResources_Dashboards_FailedConversionNoStoredVersion(t *testing.T require.NoError(t, err) } -func TestExportResources_Dashboards_V2Alpha1(t *testing.T) { - mockItems := []unstructured.Unstructured{ +func TestExportResources_Dashboards_Versions(t *testing.T) { + tests := []struct { + name string + storedVersion string + dashboardName string + expectSuccess bool + clientError error + expectedError string + createDashboard func(name, version string) unstructured.Unstructured + }{ { - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "v2-dashboard", - }, - "status": map[string]interface{}{ + name: "v1beta1 success", + storedVersion: "v1beta1", + dashboardName: "v1-dashboard", + expectSuccess: true, + createDashboard: func(name, version string) unstructured.Unstructured { + dashboard := createDashboardObject(name) + dashboard.SetAPIVersion(fmt.Sprintf("%s/%s", resources.DashboardResource.Group, version)) + dashboard.Object["status"] = map[string]interface{}{ "conversion": map[string]interface{}{ "failed": true, - "storedVersion": "v2alpha1", + "storedVersion": version, }, - }, + } + return dashboard + }, + }, + { + name: "v2alpha1 success", + storedVersion: "v2alpha1", + dashboardName: "v2alpha-dashboard", + expectSuccess: true, + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v2alpha1 client error", + storedVersion: "v2alpha1", + dashboardName: "v2alpha-dashboard-error", + expectSuccess: false, + clientError: fmt.Errorf("v2 client error"), + expectedError: "writing resource file for v2alpha-dashboard-error: get client for version v2alpha1: v2 client error", + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v2beta1 success", + storedVersion: "v2beta1", + dashboardName: "v2beta-dashboard", + expectSuccess: true, + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v2beta1 client error", + storedVersion: "v2beta1", + dashboardName: "v2beta-dashboard-error", + expectSuccess: false, + clientError: fmt.Errorf("v2 client error"), + expectedError: "writing resource file for v2beta-dashboard-error: get client for version v2beta1: v2 client error", + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v2gamma1 success", + storedVersion: "v2gamma1", + dashboardName: "v2gamma-dashboard", + expectSuccess: true, + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v2 success", + storedVersion: "v2", + dashboardName: "v2-dashboard", + expectSuccess: true, + createDashboard: func(name, version string) unstructured.Unstructured { + return createV2DashboardObject(name, version) + }, + }, + { + name: "v3alpha1 success", + storedVersion: "v3alpha1", + dashboardName: "v3-dashboard", + expectSuccess: true, + createDashboard: func(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": 3, + "title": "V3 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 == "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", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) - - // Setup v2 client - v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1") - v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}} - resourceClients.On("ForResource", mock.Anything, 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", + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": tt.dashboardName, + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": tt.storedVersion, + }, + }, }, }, - }, - }, - } - - 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() != "writing resource file for v2-dashboard-error: v2 client error" { - return false + setupProgress := func(progress *jobs.MockJobProgressRecorder) { + progress.On("SetMessage", mock.Anything, "start resource export").Return() + progress.On("SetMessage", mock.Anything, "export dashboards").Return() + if tt.expectSuccess { + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Name == tt.dashboardName && result.Action == repository.FileActionCreated + })).Return() + } else { + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + if result.Name != tt.dashboardName { + return false + } + if result.Action != repository.FileActionIgnored { + return false + } + if result.Error == nil { + return false + } + return result.Error.Error() == tt.expectedError + })).Return() + } + progress.On("TooManyErrors").Return(nil) } - return true - })).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", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", mock.Anything, resources.DashboardResourceV2alpha1).Return(nil, gvk, fmt.Errorf("v2 client error")) - resourceClients.On("ForResource", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) - } + // Setup version-specific client + versionGVR := schema.GroupVersionResource{ + Group: resources.DashboardResource.Group, + Version: tt.storedVersion, + Resource: resources.DashboardResource.Resource, + } - err := runExportTest(t, mockItems, setupProgress, setupResources) - require.NoError(t, err) -} + if tt.clientError != nil { + resourceClients.On("ForResource", mock.Anything, versionGVR).Return(nil, gvk, tt.clientError) + } else { + dashboard := tt.createDashboard(tt.dashboardName, tt.storedVersion) + versionClient := &mockDynamicInterface{items: []unstructured.Unstructured{dashboard}} + resourceClients.On("ForResource", mock.Anything, versionGVR).Return(versionClient, gvk, nil) -func TestExportResources_Dashboards_V2beta1(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": "v2beta1", - }, - }, - }, - }, - } - - 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", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) - - // Setup v2 client - v2Dashboard := createV2DashboardObject("v2-dashboard", "v2beta1") - v2Client := &mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}} - resourceClients.On("ForResource", mock.Anything, resources.DashboardResourceV2beta1).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_V2beta1_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": "v2beta1", - }, - }, - }, - }, - } - - 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 + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + repoResources.On("WriteResourceFileFromObject", mock.Anything, &dashboard, options).Return(fmt.Sprintf("%s.json", tt.dashboardName), nil) + } } - if result.Error.Error() != "writing resource file for v2-dashboard-error: v2 client error" { - return false - } - - return true - })).Return() - progress.On("TooManyErrors").Return(nil) + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) + }) } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { - resourceClients.On("ForResource", mock.Anything, resources.DashboardResourceV2beta1).Return(nil, gvk, fmt.Errorf("v2 client error")) - resourceClients.On("ForResource", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) - } - - err := runExportTest(t, mockItems, setupProgress, setupResources) - require.NoError(t, err) } func TestExportResources_Dashboards_SkipsManagedResources(t *testing.T) { @@ -567,3 +553,122 @@ func TestExportResources_Dashboards_SkipsManagedResources(t *testing.T) { err = runExportTest(t, mockItems, setupProgress, setupResources) require.NoError(t, err) } + +func TestExportResources_Dashboards_MultipleVersions(t *testing.T) { + // Test that we can handle multiple dashboards with different stored versions + mockItems := []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2alpha-dashboard", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2alpha1", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v2beta-dashboard", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v2beta1", + }, + }, + }, + }, + { + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v3-dashboard", + }, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{ + "failed": true, + "storedVersion": "v3alpha1", + }, + }, + }, + }, + } + + 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 == "v2alpha-dashboard" || result.Name == "v2beta-dashboard" || result.Name == "v3-dashboard") && + result.Action == repository.FileActionCreated + })).Return().Times(3) + progress.On("TooManyErrors").Return(nil).Times(3) + } + + setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients, mockClient *mockDynamicInterface, gvk schema.GroupVersionKind) { + // Setup v1 client + resourceClients.On("ForResource", mock.Anything, resources.DashboardResource).Return(mockClient, gvk, nil) + + // Setup v2alpha1 client + v2alphaDashboard := createV2DashboardObject("v2alpha-dashboard", "v2alpha1") + v2alphaClient := &mockDynamicInterface{items: []unstructured.Unstructured{v2alphaDashboard}} + v2alphaGVR := schema.GroupVersionResource{ + Group: resources.DashboardResource.Group, + Version: "v2alpha1", + Resource: resources.DashboardResource.Resource, + } + resourceClients.On("ForResource", mock.Anything, v2alphaGVR).Return(v2alphaClient, gvk, nil) + + // Setup v2beta1 client + v2betaDashboard := createV2DashboardObject("v2beta-dashboard", "v2beta1") + v2betaClient := &mockDynamicInterface{items: []unstructured.Unstructured{v2betaDashboard}} + v2betaGVR := schema.GroupVersionResource{ + Group: resources.DashboardResource.Group, + Version: "v2beta1", + Resource: resources.DashboardResource.Resource, + } + resourceClients.On("ForResource", mock.Anything, v2betaGVR).Return(v2betaClient, gvk, nil) + + // Setup v3alpha1 client + v3Dashboard := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "dashboard.grafana.app/v3alpha1", + "kind": "Dashboard", + "metadata": map[string]interface{}{ + "name": "v3-dashboard", + }, + "spec": map[string]interface{}{ + "version": 3, + "title": "V3 Dashboard", + }, + }, + } + v3Client := &mockDynamicInterface{items: []unstructured.Unstructured{v3Dashboard}} + v3GVR := schema.GroupVersionResource{ + Group: resources.DashboardResource.Group, + Version: "v3alpha1", + Resource: resources.DashboardResource.Resource, + } + resourceClients.On("ForResource", mock.Anything, v3GVR).Return(v3Client, gvk, nil) + + options := resources.WriteOptions{ + Path: "grafana", + Ref: "feature/branch", + } + repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2alphaDashboard, options).Return("v2alpha-dashboard.json", nil) + repoResources.On("WriteResourceFileFromObject", mock.Anything, &v2betaDashboard, options).Return("v2beta-dashboard.json", nil) + repoResources.On("WriteResourceFileFromObject", mock.Anything, &v3Dashboard, options).Return("v3-dashboard.json", nil) + } + + err := runExportTest(t, mockItems, setupProgress, setupResources) + require.NoError(t, err) +} diff --git a/pkg/registry/apis/provisioning/jobs/migrate/clean.go b/pkg/registry/apis/provisioning/jobs/migrate/clean.go index c82bbdaea2f..c05be914bf0 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/clean.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/clean.go @@ -63,6 +63,7 @@ func (c *namespaceCleaner) Clean(ctx context.Context, namespace string, progress return nil // Skip this resource } + // Deletion works by name, so we can use any client regardless of version if err := client.Delete(ctx, item.GetName(), metav1.DeleteOptions{}); err != nil { result.Error = fmt.Errorf("deleting resource %s/%s %s: %w", result.Group, result.Kind, result.Name, err) progress.Record(ctx, result) diff --git a/pkg/tests/apis/provisioning/exportjob_test.go b/pkg/tests/apis/provisioning/exportjob_test.go index 665accd84e2..2218e87ef77 100644 --- a/pkg/tests/apis/provisioning/exportjob_test.go +++ b/pkg/tests/apis/provisioning/exportjob_test.go @@ -104,6 +104,168 @@ func TestIntegrationProvisioning_ExportUnifiedToRepository(t *testing.T) { } } +func TestIntegrationProvisioning_ExportDashboardsWithStoredVersions(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + // Test table for different dashboard versions + tests := []struct { + name string + file string + createFunc func(*unstructured.Unstructured, metav1.CreateOptions) (*unstructured.Unstructured, error) + expectedTitle string + expectedName string + expectedVer string + fileName string + }{ + { + name: "v0alpha1", + file: "exportunifiedtorepository/dashboard-test-v0.yaml", + createFunc: func(dashboard *unstructured.Unstructured, opts metav1.CreateOptions) (*unstructured.Unstructured, error) { + return helper.DashboardsV0.Resource.Create(ctx, dashboard, opts) + }, + expectedTitle: "Test dashboard. Created at v0", + expectedName: "test-v0", + expectedVer: "dashboard.grafana.app/v0alpha1", + fileName: "test-dashboard-created-at-v0.json", + }, + { + name: "v1beta1", + file: "exportunifiedtorepository/dashboard-test-v1.yaml", + createFunc: func(dashboard *unstructured.Unstructured, opts metav1.CreateOptions) (*unstructured.Unstructured, error) { + return helper.DashboardsV1.Resource.Create(ctx, dashboard, opts) + }, + expectedTitle: "Test dashboard. Created at v1", + expectedName: "test-v1", + expectedVer: "dashboard.grafana.app/v1beta1", + fileName: "test-dashboard-created-at-v1.json", + }, + { + name: "v2alpha1", + file: "exportunifiedtorepository/dashboard-test-v2alpha1.yaml", + createFunc: func(dashboard *unstructured.Unstructured, opts metav1.CreateOptions) (*unstructured.Unstructured, error) { + return helper.DashboardsV2alpha1.Resource.Create(ctx, dashboard, opts) + }, + expectedTitle: "Test dashboard. Created at v2alpha1", + expectedName: "test-v2alpha1", + expectedVer: "dashboard.grafana.app/v2alpha1", + fileName: "test-dashboard-created-at-v2alpha1.json", + }, + { + name: "v2beta1", + file: "exportunifiedtorepository/dashboard-test-v2beta1.yaml", + createFunc: func(dashboard *unstructured.Unstructured, opts metav1.CreateOptions) (*unstructured.Unstructured, error) { + return helper.DashboardsV2beta1.Resource.Create(ctx, dashboard, opts) + }, + expectedTitle: "Test dashboard. Created at v2beta1", + expectedName: "test-v2beta1", + expectedVer: "dashboard.grafana.app/v2beta1", + fileName: "test-dashboard-created-at-v2beta1.json", + }, + } + + // Create dashboards in different versions + for _, tt := range tests { + dashboard := helper.LoadYAMLOrJSONFile(tt.file) + _, err := tt.createFunc(dashboard, metav1.CreateOptions{}) + require.NoError(t, err, "should be able to create %s dashboard", tt.name) + } + + // Create repository + const repo = "version-test-repository" + testRepo := TestRepo{ + Name: repo, + Copies: map[string]string{}, + ExpectedDashboards: len(tests), + ExpectedFolders: 0, + } + helper.CreateRepo(t, testRepo) + + // Export dashboards + spec := provisioning.JobSpec{ + Action: provisioning.JobActionPush, + Push: &provisioning.ExportJobOptions{ + Folder: "", + Path: "", + }, + } + helper.TriggerJobAndWaitForSuccess(t, repo, spec) + + // Verify each dashboard was exported with its original stored version + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fpath := filepath.Join(helper.ProvisioningPath, tt.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) + + obj := map[string]any{} + err = json.Unmarshal(body, &obj) + require.NoError(t, err, "exported file not json %s", fpath) + + // Verify API version matches the stored version + val, _, err := unstructured.NestedString(obj, "apiVersion") + require.NoError(t, err) + require.Equal(t, tt.expectedVer, val, "exported dashboard should have original stored version") + + // Verify title + val, _, err = unstructured.NestedString(obj, "spec", "title") + require.NoError(t, err) + require.Equal(t, tt.expectedTitle, val) + + // Verify name + val, _, err = unstructured.NestedString(obj, "metadata", "name") + require.NoError(t, err) + require.Equal(t, tt.expectedName, val) + + // Verify no status field in exported file + require.Nil(t, obj["status"], "exported file should not have status element") + }) + } + + // Verify that listing via v1 API shows storedVersion when conversion fails + // This tests the generic version handling logic + dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "should be able to list dashboards via v1 API") + + for _, dashboard := range dashboards.Items { + // Check if there's a storedVersion in conversion status + status, found, _ := unstructured.NestedMap(dashboard.Object, "status") + if found && status != nil { + conversion, convFound, _ := unstructured.NestedMap(status, "conversion") + if convFound && conversion != nil { + storedVersion, storedFound, _ := unstructured.NestedString(conversion, "storedVersion") + if storedFound && storedVersion != "" { + // Verify that the storedVersion is preserved during export + // by checking the exported file has the correct version + dashboardName := dashboard.GetName() + for _, tt := range tests { + if tt.expectedName == dashboardName { + fpath := filepath.Join(helper.ProvisioningPath, tt.fileName) + //nolint:gosec // we are ok with reading files in testdata + body, err := os.ReadFile(fpath) + require.NoError(t, err, "exported file should exist for %s", dashboardName) + + obj := map[string]any{} + err = json.Unmarshal(body, &obj) + require.NoError(t, err) + + exportedVersion, _, err := unstructured.NestedString(obj, "apiVersion") + require.NoError(t, err) + // Extract version from apiVersion (e.g., "dashboard.grafana.app/v2alpha1" -> "v2alpha1") + expectedVersion := "dashboard.grafana.app/" + storedVersion + require.Equal(t, expectedVersion, exportedVersion, + "exported version should match storedVersion %s for dashboard %s", storedVersion, dashboardName) + } + } + } + } + } + } +} + func TestIntegrationProvisioning_SecondRepositoryOnlyExportsNewDashboards(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t)