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>
This commit is contained in:
Igor Suleymanov
2025-09-03 13:51:11 +03:00
committed by GitHub
parent 95080d9d56
commit a07a8d0ba2
13 changed files with 794 additions and 103 deletions
@@ -11,22 +11,18 @@ import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
dashboardv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
dashboardv2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
dashboardclient "github.com/grafana/grafana/pkg/services/dashboards/service/client"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
const (
@@ -38,33 +34,28 @@ type Service struct {
cfg *setting.Cfg
store store
dashSvc dashboards.DashboardService
k8sclient client.K8sHandler
k8sclient dashboardclient.K8sHandlerWithFallback
features featuremgmt.FeatureToggles
log log.Logger
}
func ProvideService(cfg *setting.Cfg, db db.DB, dashboardService dashboards.DashboardService, dashboardStore dashboards.Store, features featuremgmt.FeatureToggles,
restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient, dual dualwrite.Service, sorter sort.Service) dashver.Service {
func ProvideService(
cfg *setting.Cfg,
db db.DB,
dashboardService dashboards.DashboardService,
features featuremgmt.FeatureToggles,
clientWithFallback dashboardclient.K8sHandlerWithFallback,
) dashver.Service {
return &Service{
cfg: cfg,
store: &sqlStore{
db: db,
dialect: db.GetDialect(),
},
features: features,
k8sclient: client.NewK8sHandler(
dual,
request.GetNamespaceMapper(cfg),
dashv0.DashboardResourceInfo.GroupVersionResource(),
restConfigProvider.GetRestConfig,
dashboardStore,
userService,
unified,
sorter,
features,
),
dashSvc: dashboardService,
log: log.New("dashboard-version"),
features: features,
k8sclient: clientWithFallback,
dashSvc: dashboardService,
log: log.New("dashboard-version"),
}
}
@@ -292,25 +283,12 @@ func (s *Service) UnstructuredToLegacyDashboardVersionList(ctx context.Context,
}
func (s *Service) unstructuredToLegacyDashboardVersionWithUsers(item *unstructured.Unstructured, users map[string]*user.User) (*dashver.DashboardVersionDTO, error) {
spec, ok := item.Object["spec"].(map[string]any)
if !ok {
return nil, errors.New("error parsing dashboard from k8s response")
}
obj, err := utils.MetaAccessor(item)
if err != nil {
var vspec DashboardVersionSpec
if err := UnstructuredToDashboardVersionSpec(item, &vspec); err != nil {
return nil, err
}
uid := obj.GetName()
spec["uid"] = uid
dashVersion := obj.GetGeneration()
parentVersion := dashVersion - 1
if parentVersion < 0 {
parentVersion = 0
}
if dashVersion > 0 {
spec["version"] = dashVersion
}
obj := vspec.MetaAccessor
var createdBy *user.User
if creator, ok := users[obj.GetCreatedBy()]; ok {
@@ -338,16 +316,16 @@ func (s *Service) unstructuredToLegacyDashboardVersionWithUsers(item *unstructur
}
return &dashver.DashboardVersionDTO{
ID: dashVersion,
ID: vspec.Version,
DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
DashboardUID: uid,
DashboardUID: vspec.UID,
Created: created,
CreatedBy: createdByID,
Message: obj.GetMessage(),
RestoredFrom: restoreVer,
Version: int(dashVersion),
ParentVersion: int(parentVersion),
Data: simplejson.NewFromAny(spec),
Version: int(vspec.Version),
ParentVersion: int(vspec.ParentVersion),
Data: simplejson.NewFromAny(vspec.Spec),
}, nil
}
@@ -369,3 +347,73 @@ func getRestoreVersion(msg string) (int, error) {
}
return ver, nil
}
// DashboardVersionSpec contains the necessary fields to represent a dashboard version.
type DashboardVersionSpec struct {
UID string
Version int64
ParentVersion int64
Spec any
MetaAccessor utils.GrafanaMetaAccessor
}
// UnstructuredToDashboardVersionSpec converts a k8s unstructured object to a DashboardVersionSpec.
// It supports dashboard API versions v0alpha1 through v2beta1.
func UnstructuredToDashboardVersionSpec(obj *unstructured.Unstructured, dst *DashboardVersionSpec) error {
if obj.GetAPIVersion() == dashboardv2alpha1.GroupVersion.String() ||
obj.GetAPIVersion() == dashboardv2beta1.GroupVersion.String() {
spec, ok := obj.Object["spec"]
if !ok {
return errors.New("error parsing dashboard from k8s response")
}
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
version := meta.GetGeneration()
parentVersion := version - 1
if parentVersion < 0 {
parentVersion = 0
}
dst.UID = obj.GetName()
dst.Version = version
dst.ParentVersion = parentVersion
dst.Spec = spec
dst.MetaAccessor = meta
return nil
}
// Otherwise we assume that we are dealing with a legacy dashboard API version (v0 / v1 / etc.)
spec, ok := obj.Object["spec"].(map[string]any)
if !ok {
return errors.New("error parsing dashboard from k8s response")
}
meta, err := utils.MetaAccessor(obj)
if err != nil {
return err
}
uid := meta.GetName()
spec["uid"] = uid
dashVersion := meta.GetGeneration()
parentVersion := dashVersion - 1
if parentVersion < 0 {
parentVersion = 0
}
if dashVersion > 0 {
spec["version"] = dashVersion
}
dst.UID = uid
dst.Version = dashVersion
dst.ParentVersion = parentVersion
dst.Spec = spec
dst.MetaAccessor = meta
return nil
}
@@ -22,6 +22,9 @@ import (
"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) {
@@ -280,6 +283,234 @@ func TestListDashboardVersions(t *testing.T) {
})
}
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