Files
grafana/pkg/services/dashboardversion/dashverimpl/dashver_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

541 lines
19 KiB
Go

package dashverimpl
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
dashboardv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
)
func TestDashboardVersionService(t *testing.T) {
t.Run("Get dashboard versions", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
creationTimestamp := time.Now().Add(time.Hour * -24).UTC()
updatedTimestamp := time.Now().UTC().Truncate(time.Second)
dash := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(10),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
"annotations": map[string]any{
utils.AnnoKeyCreatedBy: "user:1",
},
},
"spec": map[string]any{
"hello": "world",
},
}}
dash.SetCreationTimestamp(v1.NewTime(creationTimestamp))
obj, err := utils.MetaAccessor(dash)
require.NoError(t, err)
obj.SetUpdatedTimestamp(&updatedTimestamp)
mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", ""}).Return(map[string]*user.User{"user:1": {ID: 1}}, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{*dash}}, nil).Once()
res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
Version: 10,
})
require.Nil(t, err)
require.Equal(t, res, &dashver.DashboardVersionDTO{
ID: 10,
Version: 10,
ParentVersion: 9,
DashboardID: 42,
DashboardUID: "uid",
CreatedBy: 1,
Created: updatedTimestamp,
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(10), "hello": "world"}),
})
mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", "user:2"}).Return(map[string]*user.User{"user:1": {ID: 1}, "user:2": {ID: 2}}, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{{
Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(11),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
"annotations": map[string]any{
utils.AnnoKeyCreatedBy: "user:1",
utils.AnnoKeyUpdatedBy: "user:2", // if updated by is set, that is the version creator
},
},
"spec": map[string]any{},
}}}}, nil).Once()
res, err = dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
Version: 11,
})
require.Nil(t, err)
require.Equal(t, res, &dashver.DashboardVersionDTO{
ID: 11,
Version: 11,
ParentVersion: 10,
DashboardID: 42,
DashboardUID: "uid",
CreatedBy: 2,
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(11)}),
})
})
t.Run("should dashboard not found error when k8s returns not found", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).Return(&dashboards.DashboardRef{UID: "uid"}, nil)
mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
_, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{
DashboardID: 42,
OrgID: 1,
Version: 10,
})
require.ErrorIs(t, err, dashboards.ErrDashboardNotFound)
})
}
func TestDeleteExpiredVersions(t *testing.T) {
versionsToKeep := 5
cfg := setting.NewCfg()
cfg.DashboardVersionsToKeep = versionsToKeep
dashboardVersionStore := newDashboardVersionStoreFake()
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{
cfg: cfg, store: dashboardVersionStore, dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
t.Run("Don't delete anything if there are no expired versions", func(t *testing.T) {
err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4})
require.Nil(t, err)
})
t.Run("Clean up old dashboard versions successfully", func(t *testing.T) {
dashboardVersionStore.ExptectedDeletedVersions = 4
dashboardVersionStore.ExpectedVersions = []any{1, 2, 3, 4}
err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4})
require.Nil(t, err)
})
t.Run("Clean up old dashboard versions with error", func(t *testing.T) {
dashboardVersionStore.ExpectedError = errors.New("some error")
err := dashboardVersionService.DeleteExpired(context.Background(), &dashver.DeleteExpiredVersionsCommand{DeletedRows: 4})
require.NotNil(t, err)
})
}
func TestListDashboardVersions(t *testing.T) {
t.Run("List all versions for a given Dashboard ID through k8s", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.UnstructuredList{
Items: []unstructured.Unstructured{{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}}}}, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 1, len(res.Versions))
require.EqualValues(t, &dashver.DashboardVersionResponse{
Versions: []*dashver.DashboardVersionDTO{{
ID: 5,
DashboardID: 42,
ParentVersion: 4,
Version: 5, // should take from spec
DashboardUID: "uid",
Data: simplejson.NewFromAny(map[string]any{"uid": "uid", "version": int64(5)}),
}}}, res)
})
t.Run("List returns correct continue token across multiple pages", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
query := dashver.ListDashboardVersionsQuery{DashboardID: 42, Limit: 3}
mockCli.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{}, nil)
firstPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "11",
"generation": int64(4),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "12",
"generation": int64(5),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
firstMeta, err := meta.ListAccessor(firstPage)
require.NoError(t, err)
firstMeta.SetContinue("t1")
secondPage := &unstructured.UnstructuredList{
Items: []unstructured.Unstructured{
{Object: map[string]any{
"metadata": map[string]any{
"name": "uid",
"resourceVersion": "13",
"generation": int64(6),
"labels": map[string]any{
utils.LabelKeyDeprecatedInternalID: "42", // nolint:staticcheck
},
},
"spec": map[string]any{},
}},
},
}
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(firstPage, nil).Once()
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(secondPage, nil).Once()
res, err := dashboardVersionService.List(context.Background(), &query)
require.Nil(t, err)
require.Equal(t, 3, len(res.Versions))
require.Equal(t, "", res.ContinueToken)
mockCli.AssertNumberOfCalls(t, "List", 2)
})
t.Run("should return dashboard not found error when k8s client says not found", func(t *testing.T) {
dashboardService := dashboards.NewFakeDashboardService(t)
dashboardVersionService := Service{dashSvc: dashboardService, features: featuremgmt.WithFeatures()}
mockCli := new(client.MockK8sHandler)
dashboardVersionService.k8sclient = mockCli
dashboardVersionService.features = featuremgmt.WithFeatures()
dashboardService.On("GetDashboardUIDByID", mock.Anything,
mock.AnythingOfType("*dashboards.GetDashboardRefByIDQuery")).
Return(&dashboards.DashboardRef{UID: "uid"}, nil)
mockCli.On("List", mock.Anything, mock.Anything, mock.Anything).Return(nil, apierrors.NewNotFound(schema.GroupResource{Group: "dashboards.dashboard.grafana.app", Resource: "dashboard"}, "uid"))
query := dashver.ListDashboardVersionsQuery{DashboardID: 42}
_, err := dashboardVersionService.List(context.Background(), &query)
require.ErrorIs(t, dashboards.ErrDashboardNotFound, err)
})
}
func TestUnstructuredToDashboardVersionSpec(t *testing.T) {
tests := []struct {
name string
obj *unstructured.Unstructured
expectedResult DashboardVersionSpec
expectError bool
errorMessage string
checkSpec func(t *testing.T, spec any)
}{
{
name: "should convert v2alpha1 dashboard correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "test-dashboard",
"generation": int64(5),
},
"spec": map[string]any{
"title": "Test Dashboard",
"panels": []any{
map[string]any{"id": 1, "title": "Panel 1"},
},
},
},
},
expectedResult: DashboardVersionSpec{
UID: "test-dashboard",
Version: 5,
ParentVersion: 4,
},
expectError: false,
},
{
name: "should convert v2beta1 dashboard correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2beta1.GroupVersion.String(),
"metadata": map[string]any{
"name": "test-dashboard-v2",
"generation": int64(10),
},
"spec": map[string]any{
"title": "Test Dashboard V2",
"tags": []string{"test", "dashboard"},
},
},
},
expectedResult: DashboardVersionSpec{
UID: "test-dashboard-v2",
Version: 10,
ParentVersion: 9,
},
expectError: false,
},
{
name: "should convert legacy dashboard API version correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v1",
"metadata": map[string]any{
"name": "legacy-dashboard",
"generation": int64(3),
},
"spec": map[string]any{
"title": "Legacy Dashboard",
"uid": "legacy-uid",
},
},
},
expectedResult: DashboardVersionSpec{
UID: "legacy-dashboard",
Version: 3,
ParentVersion: 2,
},
expectError: false,
checkSpec: func(t *testing.T, spec any) {
specMap := spec.(map[string]any)
require.Equal(t, "legacy-dashboard", specMap["uid"])
require.Equal(t, int64(3), specMap["version"])
},
},
{
name: "should handle generation 0 correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "zero-gen-dashboard",
"generation": int64(0),
},
"spec": map[string]any{
"title": "Zero Generation Dashboard",
},
},
},
expectedResult: DashboardVersionSpec{
UID: "zero-gen-dashboard",
Version: 0,
ParentVersion: 0,
},
expectError: false,
},
{
name: "should handle generation 1 correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v0",
"metadata": map[string]any{
"name": "one-gen-dashboard",
"generation": int64(1),
},
"spec": map[string]any{
"title": "One Generation Dashboard",
},
},
},
expectedResult: DashboardVersionSpec{
UID: "one-gen-dashboard",
Version: 1,
ParentVersion: 0,
},
expectError: false,
checkSpec: func(t *testing.T, spec any) {
specMap := spec.(map[string]any)
require.Equal(t, int64(1), specMap["version"])
},
},
{
name: "should return error when spec is missing for v2alpha1/v2beta1",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2alpha1.GroupVersion.String(),
"metadata": map[string]any{
"name": "no-spec-dashboard",
"generation": int64(1),
},
// Missing spec
},
},
expectError: true,
errorMessage: "error parsing dashboard from k8s response",
},
{
name: "should return error when spec is missing for legacy API",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v1",
"metadata": map[string]any{
"name": "no-spec-legacy-dashboard",
"generation": int64(1),
},
// Missing spec
},
},
expectError: true,
errorMessage: "error parsing dashboard from k8s response",
},
{
name: "should return error when spec is not map for legacy API",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "dashboard.grafana.app/v1",
"metadata": map[string]any{
"name": "invalid-spec-dashboard",
"generation": int64(1),
},
"spec": "not a map", // Invalid spec type
},
},
expectError: true,
errorMessage: "error parsing dashboard from k8s response",
},
{
name: "should handle edge cases correctly",
obj: &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": dashboardv2beta1.GroupVersion.String(),
"metadata": map[string]any{
"name": "high-gen-dashboard",
"generation": int64(999999),
},
"spec": map[string]any{
"title": "High Generation Dashboard",
},
},
},
expectedResult: DashboardVersionSpec{
UID: "high-gen-dashboard",
Version: 999999,
ParentVersion: 999998,
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result DashboardVersionSpec
err := UnstructuredToDashboardVersionSpec(tt.obj, &result)
if tt.expectError {
require.Error(t, err)
require.Equal(t, tt.errorMessage, err.Error())
return
}
require.NoError(t, err)
// Check basic fields
require.Equal(t, tt.expectedResult.UID, result.UID)
require.Equal(t, tt.expectedResult.Version, result.Version)
require.Equal(t, tt.expectedResult.ParentVersion, result.ParentVersion)
// Check that spec is properly set
require.NotNil(t, result.Spec)
// Check that MetaAccessor is properly set
require.NotNil(t, result.MetaAccessor)
// Run custom spec checks if provided
if tt.checkSpec != nil {
tt.checkSpec(t, result.Spec)
}
})
}
}
type FakeDashboardVersionStore struct {
ExpectedDashboardVersion *dashver.DashboardVersion
ExptectedDeletedVersions int64
ExpectedVersions []any
ExpectedListVersions []*dashver.DashboardVersion
ExpectedError error
}
func newDashboardVersionStoreFake() *FakeDashboardVersionStore {
return &FakeDashboardVersionStore{}
}
func (f *FakeDashboardVersionStore) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersion, error) {
return f.ExpectedDashboardVersion, f.ExpectedError
}
func (f *FakeDashboardVersionStore) GetBatch(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand, perBatch int, versionsToKeep int) ([]any, error) {
return f.ExpectedVersions, f.ExpectedError
}
func (f *FakeDashboardVersionStore) DeleteBatch(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand, versionIdsToDelete []any) (int64, error) {
return f.ExptectedDeletedVersions, f.ExpectedError
}
func (f *FakeDashboardVersionStore) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) ([]*dashver.DashboardVersion, error) {
return f.ExpectedListVersions, f.ExpectedError
}