Provisioning: add generic version handling for dashboard export (#114691)
* feat(provisioning): add generic version handling for dashboard export - Update export job to handle any dashboard version generically (v0, v1, v2, v3, etc.) - Dynamically construct GroupVersionResource for any stored version - Cache version-specific clients for efficiency - Add comprehensive table-driven unit tests for multiple versions - Add integration test to verify version handling end-to-end - Remove unnecessary version shim from clean operation (deletion works by name) * test: add unit test for v2 dashboard version (no suffix)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user