Files
grafana/pkg/services/dashboards/service/client/client_test.go
Igor Suleymanov a07a8d0ba2 Fix listing and getting dashboard versions across different API versions (#109860)
* Fix listing and getting dashboard versions across different API versions

What

This commit updates dashboard version service to use API version aware
API client. The service now also supports parsing different API version
representation of dashboards.

The API version aware client is also updated to support listing across
versions.

Why

Currently listing or getting specific versions is broken for all v2
versions of the dashboard API, especially if the dashboard being checked
is still saved using v1 APIs.

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

* Remove superfluous tracing spans

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

---------

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>
2025-09-03 13:51:11 +03:00

548 lines
19 KiB
Go

package client
import (
"context"
"errors"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/client"
)
type testSetup struct {
handler *K8sClientWithFallback
mockClientV1Alpha1 *client.MockK8sHandler
mockClientV2Alpha1 *client.MockK8sHandler
mockMetrics *k8sClientMetrics
mockFactoryCalls map[string]int
t *testing.T
}
func setupTest(t *testing.T) *testSetup {
mockClientV1Alpha1 := &client.MockK8sHandler{}
mockClientV2Alpha1 := &client.MockK8sHandler{}
mockMetrics := newK8sClientMetrics(prometheus.NewRegistry())
mockFactoryCalls := make(map[string]int)
handler := &K8sClientWithFallback{
K8sHandler: mockClientV1Alpha1,
newClientFunc: func(ctx context.Context, version string) client.K8sHandler {
mockFactoryCalls[version]++
if version == "v2alpha1" {
return mockClientV2Alpha1
}
if version == dashboardv1.VERSION {
return mockClientV1Alpha1
}
t.Fatalf("Unexpected call to newClientFunc with version %s", version)
return nil
},
log: log.New("test"),
metrics: mockMetrics,
}
return &testSetup{
handler: handler,
mockClientV1Alpha1: mockClientV1Alpha1,
mockClientV2Alpha1: mockClientV2Alpha1,
mockMetrics: mockMetrics,
mockFactoryCalls: mockFactoryCalls,
t: t,
}
}
func TestK8sHandlerWithFallback_Get(t *testing.T) {
t.Run("Get without fallback", func(t *testing.T) {
setup := setupTest(t)
ctx := context.Background()
name := "test-dashboard"
orgID := int64(1)
options := metav1.GetOptions{}
expectedResult := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": name,
},
"status": map[string]interface{}{
"someOtherStatus": "ok",
},
},
}
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(expectedResult, nil).Once()
result, err := setup.handler.Get(ctx, name, orgID, options)
require.NoError(t, err)
require.Equal(t, expectedResult, result)
require.Equal(t, 0, len(setup.mockFactoryCalls), "Factory should not be called for non-fallback case")
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("Get with fallback due to conversion error", func(t *testing.T) {
setup := setupTest(t)
ctx := context.Background()
name := "test-dashboard-fallback"
orgID := int64(2)
options := metav1.GetOptions{ResourceVersion: "123"}
storedVersion := "v2alpha1"
conversionErr := "failed to convert"
v1alpha1Result := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": name,
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": storedVersion,
"error": conversionErr,
},
},
},
}
expectedResultFallback := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "dashboard/v2alpha1",
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": name,
},
},
}
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(v1alpha1Result, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(expectedResultFallback, nil).Once()
result, err := setup.handler.Get(ctx, name, orgID, options)
require.NoError(t, err)
require.Equal(t, expectedResultFallback, result)
require.Equal(t, 1, setup.mockFactoryCalls["v2alpha1"], "Factory should be called once with v2alpha1")
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("Get initial error", func(t *testing.T) {
setup := setupTest(t)
ctx := context.Background()
name := "test-dashboard-error"
orgID := int64(3)
options := metav1.GetOptions{}
expectedErr := errors.New("initial get failed")
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(nil, expectedErr).Once()
_, err := setup.handler.Get(ctx, name, orgID, options)
require.Error(t, err)
require.Equal(t, expectedErr, err)
require.Equal(t, 0, len(setup.mockFactoryCalls), "Factory should not be called for error case")
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("Get with fallback fails", func(t *testing.T) {
setup := setupTest(t)
ctx := context.Background()
name := "test-dashboard-fallback-error"
orgID := int64(4)
options := metav1.GetOptions{}
storedVersion := "v2alpha1"
conversionErr := "failed to convert again"
fallbackErr := errors.New("fallback get failed")
v1alpha1Result := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": name,
},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": storedVersion,
"error": conversionErr,
},
},
},
}
setup.mockClientV1Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(v1alpha1Result, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, name, orgID, options, mock.Anything).Return(nil, fallbackErr).Once()
_, err := setup.handler.Get(ctx, name, orgID, options)
require.Error(t, err)
require.Equal(t, fallbackErr, err)
require.Equal(t, 1, setup.mockFactoryCalls["v2alpha1"], "Factory should be called once with v2alpha1")
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
}
func TestK8sHandlerWithFallback_List(t *testing.T) {
// Helper function to create a dashboard item
createDashboard := func(name, resourceVersion string, status map[string]interface{}) unstructured.Unstructured {
return unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": name,
"resourceVersion": resourceVersion,
},
"status": status,
},
}
}
// Helper function to create a fallback dashboard item
createFallbackDashboard := func(name, resourceVersion, apiVersion string) unstructured.Unstructured {
return unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": apiVersion,
"kind": "Dashboard",
"metadata": map[string]interface{}{
"name": name,
"resourceVersion": resourceVersion,
},
},
}
}
// Helper function to create conversion status
conversionStatus := func(failed bool, storedVersion, errorMsg string) map[string]interface{} {
return map[string]interface{}{
"conversion": map[string]interface{}{
"failed": failed,
"storedVersion": storedVersion,
"error": errorMsg,
},
}
}
t.Run("List without fallback needed", func(t *testing.T) {
setup := setupTest(t)
expectedResult := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
createDashboard("dashboard-1", "123", map[string]interface{}{"someOtherStatus": "ok"}),
createDashboard("dashboard-2", "456", map[string]interface{}{"anotherStatus": "ok"}),
},
}
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(1), metav1.ListOptions{}).Return(expectedResult, nil).Once()
result, err := setup.handler.List(context.Background(), 1, metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, expectedResult, result)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with some items needing fallback", func(t *testing.T) {
setup := setupTest(t)
initialResult := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
createDashboard("dashboard-ok", "123", map[string]interface{}{"someOtherStatus": "ok"}),
createDashboard("dashboard-fallback", "456", conversionStatus(true, "v2alpha1", "conversion failed")),
},
}
fallbackResult := createFallbackDashboard("dashboard-fallback", "456", "dashboard/v2alpha1")
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(2), metav1.ListOptions{}).Return(initialResult, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, "dashboard-fallback", int64(2), metav1.GetOptions{ResourceVersion: "456"}, mock.Anything).Return(&fallbackResult, nil).Once()
result, err := setup.handler.List(context.Background(), 2, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, result.Items, 2)
expectedItems := []unstructured.Unstructured{
createDashboard("dashboard-ok", "123", map[string]interface{}{"someOtherStatus": "ok"}),
fallbackResult,
}
require.ElementsMatch(t, expectedItems, result.Items)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with all items needing fallback", func(t *testing.T) {
setup := setupTest(t)
initialResult := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
createDashboard("dashboard-1-fallback", "111", conversionStatus(true, "v2alpha1", "conversion failed 1")),
createDashboard("dashboard-2-fallback", "222", conversionStatus(true, "v2alpha1", "conversion failed 2")),
},
}
fallbackResult1 := createFallbackDashboard("dashboard-1-fallback", "111", "dashboard/v2alpha1")
fallbackResult2 := createFallbackDashboard("dashboard-2-fallback", "222", "dashboard/v2alpha1")
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(3), metav1.ListOptions{}).Return(initialResult, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, "dashboard-1-fallback", int64(3), metav1.GetOptions{ResourceVersion: "111"}, mock.Anything).Return(&fallbackResult1, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, "dashboard-2-fallback", int64(3), metav1.GetOptions{ResourceVersion: "222"}, mock.Anything).Return(&fallbackResult2, nil).Once()
result, err := setup.handler.List(context.Background(), 3, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, result.Items, 2)
expectedItems := []unstructured.Unstructured{fallbackResult1, fallbackResult2}
require.ElementsMatch(t, expectedItems, result.Items)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with different versions needing fallback", func(t *testing.T) {
setup := setupTest(t)
initialResult := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
createDashboard("dashboard-v2alpha1", "333", conversionStatus(true, "v2alpha1", "conversion failed v2alpha1")),
createDashboard("dashboard-v1beta1", "444", conversionStatus(true, "v1beta1", "conversion failed v1beta1")),
},
}
fallbackResultV2Alpha1 := createFallbackDashboard("dashboard-v2alpha1", "333", "dashboard/v2alpha1")
fallbackResultV1Beta1 := createFallbackDashboard("dashboard-v1beta1", "444", "dashboard/v1beta1")
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(4), metav1.ListOptions{}).Return(initialResult, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, "dashboard-v2alpha1", int64(4), metav1.GetOptions{ResourceVersion: "333"}, mock.Anything).Return(&fallbackResultV2Alpha1, nil).Once()
setup.mockClientV1Alpha1.On("Get", mock.Anything, "dashboard-v1beta1", int64(4), metav1.GetOptions{ResourceVersion: "444"}, mock.Anything).Return(&fallbackResultV1Beta1, nil).Once()
result, err := setup.handler.List(context.Background(), 4, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, result.Items, 2)
expectedItems := []unstructured.Unstructured{fallbackResultV2Alpha1, fallbackResultV1Beta1}
require.ElementsMatch(t, expectedItems, result.Items)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with initial fetch error", func(t *testing.T) {
setup := setupTest(t)
expectedErr := errors.New("initial list failed")
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(5), metav1.ListOptions{}).Return(nil, expectedErr).Once()
_, err := setup.handler.List(context.Background(), 5, metav1.ListOptions{})
require.Error(t, err)
require.Equal(t, expectedErr, err)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with fallback fetch error", func(t *testing.T) {
setup := setupTest(t)
initialResult := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
createDashboard("dashboard-fallback-error", "555", conversionStatus(true, "v2alpha1", "conversion failed")),
},
}
fallbackErr := errors.New("fallback get failed")
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(6), metav1.ListOptions{}).Return(initialResult, nil).Once()
setup.mockClientV2Alpha1.On("Get", mock.Anything, "dashboard-fallback-error", int64(6), metav1.GetOptions{ResourceVersion: "555"}, mock.Anything).Return(nil, fallbackErr).Once()
_, err := setup.handler.List(context.Background(), 6, metav1.ListOptions{})
require.Error(t, err)
require.Equal(t, fallbackErr, err)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
t.Run("List with empty result", func(t *testing.T) {
setup := setupTest(t)
emptyResult := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{}}
setup.mockClientV1Alpha1.On("List", mock.Anything, int64(7), metav1.ListOptions{}).Return(emptyResult, nil).Once()
result, err := setup.handler.List(context.Background(), 7, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, result.Items, 0)
setup.mockClientV1Alpha1.AssertExpectations(t)
setup.mockClientV2Alpha1.AssertExpectations(t)
})
}
func TestGetConversionStatus(t *testing.T) {
tests := []struct {
name string
obj *unstructured.Unstructured
expectedFailed bool
expectedStoredVersion string
expectedError string
}{
{
name: "No status field",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
}},
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
{
name: "Status field, but no conversion field",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{"someOtherStatus": "ok"},
}},
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
{
name: "Conversion field, failed=true, with storedVersion and error",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha1",
"error": "conversion failed",
},
},
}},
expectedFailed: true,
expectedStoredVersion: "v2alpha1",
expectedError: "conversion failed",
},
{
name: "Conversion field, failed=false",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": false,
"storedVersion": "v1alpha1",
"error": "",
},
},
}},
expectedFailed: false,
expectedStoredVersion: "v1alpha1",
expectedError: "",
},
{
name: "Conversion field, missing failed (defaults to false)",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"storedVersion": "v1alpha1",
"error": "",
},
},
}},
expectedFailed: false,
expectedStoredVersion: "v1alpha1",
expectedError: "",
},
{
name: "Conversion field, failed=true, missing storedVersion",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"error": "conversion failed",
},
},
}},
expectedFailed: true,
expectedStoredVersion: "",
expectedError: "conversion failed",
},
{
name: "Conversion field, failed=true, missing error",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{
"failed": true,
"storedVersion": "v2alpha1",
},
},
}},
expectedFailed: true,
expectedStoredVersion: "v2alpha1",
expectedError: "",
},
{
name: "Empty object",
obj: &unstructured.Unstructured{Object: map[string]interface{}{}},
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
{
name: "Nil object",
obj: nil,
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
{
name: "Status not a map",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": "not a map",
}},
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
{
name: "Conversion not a map",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"metadata": map[string]interface{}{"name": "test"},
"status": map[string]interface{}{
"conversion": "not a map",
},
}},
expectedFailed: false,
expectedStoredVersion: "",
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var input *unstructured.Unstructured
if tt.obj != nil {
input = tt.obj.DeepCopy()
} else {
input = &unstructured.Unstructured{Object: map[string]interface{}{}}
}
failed, storedVersion, conversionErr := getConversionStatus(input)
require.Equal(t, tt.expectedFailed, failed, "failed mismatch")
require.Equal(t, tt.expectedStoredVersion, storedVersion, "storedVersion mismatch")
require.Equal(t, tt.expectedError, conversionErr, "conversionErr mismatch")
})
}
}