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

420 lines
11 KiB
Go

package dashverimpl
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
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/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/user"
"github.com/grafana/grafana/pkg/setting"
)
const (
maxVersionsToDeletePerBatch = 100
maxVersionDeletionBatches = 50
)
type Service struct {
cfg *setting.Cfg
store store
dashSvc dashboards.DashboardService
k8sclient dashboardclient.K8sHandlerWithFallback
features featuremgmt.FeatureToggles
log log.Logger
}
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: clientWithFallback,
dashSvc: dashboardService,
log: log.New("dashboard-version"),
}
}
func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersionDTO, error) {
if query.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
if err != nil {
return nil, err
}
query.DashboardUID = u
}
version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version)
if err != nil {
return nil, err
}
return version, nil
}
func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand) error {
versionsToKeep := s.cfg.DashboardVersionsToKeep
if versionsToKeep < 1 {
versionsToKeep = 1
}
for batch := 0; batch < maxVersionDeletionBatches; batch++ {
versionIdsToDelete, batchErr := s.store.GetBatch(ctx, cmd, maxVersionsToDeletePerBatch, versionsToKeep)
if batchErr != nil {
return batchErr
}
if len(versionIdsToDelete) < 1 {
return nil
}
deleted, err := s.store.DeleteBatch(ctx, cmd, versionIdsToDelete)
if err != nil {
return err
}
cmd.DeletedRows += deleted
if deleted < int64(maxVersionsToDeletePerBatch) {
break
}
}
return nil
}
// List all dashboard versions for the given dashboard ID.
func (s *Service) List(ctx context.Context, query *dashver.ListDashboardVersionsQuery) (*dashver.DashboardVersionResponse, error) {
if query.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
if err != nil {
return nil, err
}
query.DashboardUID = u
}
if query.Limit == 0 {
query.Limit = 1000
}
versions, err := s.listHistoryThroughK8s(
ctx,
query.OrgID,
query.DashboardUID,
int64(query.Limit),
query.ContinueToken,
)
if err != nil {
return nil, err
}
return versions, nil
}
// getDashUIDMaybeEmpty is a helper function which takes a dashboardID and
// returns the UID. If the dashboard is not found, it will return an empty
// string.
func (s *Service) getDashUIDMaybeEmpty(ctx context.Context, id int64) (string, error) {
q := dashboards.GetDashboardRefByIDQuery{ID: id}
result, err := s.dashSvc.GetDashboardUIDByID(ctx, &q)
if err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
s.log.Debug("dashboard not found")
return "", nil
} else {
s.log.Error("error getting dashboard", err)
return "", err
}
}
return result.UID, nil
}
func (s *Service) getHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, version int64) (*dashver.DashboardVersionDTO, error) {
// this is an unideal implementation - we have to list all versions and filter here, since there currently is no way to query for the
// generation id in unified storage, so we cannot query for the dashboard version directly, and we cannot use search as history is not indexed.
// use batches to make sure we don't load too much data at once.
const batchSize = 50
labelSelector := utils.LabelKeyGetHistory + "=true"
fieldSelector := "metadata.name=" + dashboardUID
var continueToken string
for {
out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
LabelSelector: labelSelector,
FieldSelector: fieldSelector,
Limit: int64(batchSize),
Continue: continueToken,
})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, dashboards.ErrDashboardNotFound
}
return nil, err
}
if out == nil {
return nil, dashboards.ErrDashboardNotFound
}
for _, item := range out.Items {
if item.GetGeneration() == version {
return s.UnstructuredToLegacyDashboardVersion(ctx, &item, orgID)
}
}
continueToken = out.GetContinue()
if continueToken == "" || len(out.Items) == 0 {
break
}
}
return nil, dashboards.ErrDashboardNotFound
}
func (s *Service) listHistoryThroughK8s(ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string) (*dashver.DashboardVersionResponse, error) {
labelSelector := utils.LabelKeyGetHistory + "=true"
fieldSelector := "metadata.name=" + dashboardUID
out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
LabelSelector: labelSelector,
FieldSelector: fieldSelector,
Limit: limit,
Continue: continueToken,
})
if err != nil {
if apierrors.IsNotFound(err) {
return nil, dashboards.ErrDashboardNotFound
}
return nil, err
}
if out == nil {
return nil, dashboards.ErrDashboardNotFound
}
// if k8s returns a continue token, we need to fetch the next page(s) until we either reach the limit or there are no more pages
continueToken = out.GetContinue()
for (len(out.Items) < int(limit)) && (continueToken != "") {
tempOut, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
LabelSelector: labelSelector,
FieldSelector: fieldSelector,
Continue: continueToken,
Limit: limit - int64(len(out.Items)),
})
if err != nil {
return nil, err
}
out.Items = append(out.Items, tempOut.Items...)
continueToken = tempOut.GetContinue()
}
dashboards, err := s.UnstructuredToLegacyDashboardVersionList(ctx, out.Items, orgID)
if err != nil {
return nil, err
}
return &dashver.DashboardVersionResponse{
ContinueToken: continueToken,
Versions: dashboards,
}, nil
}
func (s *Service) UnstructuredToLegacyDashboardVersion(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*dashver.DashboardVersionDTO, error) {
obj, err := utils.MetaAccessor(item)
if err != nil {
return nil, err
}
users, err := s.k8sclient.GetUsersFromMeta(ctx, []string{obj.GetCreatedBy(), obj.GetUpdatedBy()})
if err != nil {
return nil, err
}
return s.unstructuredToLegacyDashboardVersionWithUsers(item, users)
}
func (s *Service) UnstructuredToLegacyDashboardVersionList(ctx context.Context, items []unstructured.Unstructured, orgID int64) ([]*dashver.DashboardVersionDTO, error) {
// get users ahead of time to do just one db call, rather than 2 per item in the list
userMeta := []string{}
for _, item := range items {
obj, err := utils.MetaAccessor(&item)
if err != nil {
return nil, err
}
if obj.GetCreatedBy() != "" {
userMeta = append(userMeta, obj.GetCreatedBy())
}
if obj.GetUpdatedBy() != "" {
userMeta = append(userMeta, obj.GetUpdatedBy())
}
}
users, err := s.k8sclient.GetUsersFromMeta(ctx, userMeta)
if err != nil {
return nil, err
}
versions := make([]*dashver.DashboardVersionDTO, len(items))
for i, item := range items {
version, err := s.unstructuredToLegacyDashboardVersionWithUsers(&item, users)
if err != nil {
return nil, err
}
versions[i] = version
}
return versions, nil
}
func (s *Service) unstructuredToLegacyDashboardVersionWithUsers(item *unstructured.Unstructured, users map[string]*user.User) (*dashver.DashboardVersionDTO, error) {
var vspec DashboardVersionSpec
if err := UnstructuredToDashboardVersionSpec(item, &vspec); err != nil {
return nil, err
}
obj := vspec.MetaAccessor
var createdBy *user.User
if creator, ok := users[obj.GetCreatedBy()]; ok {
createdBy = creator
}
// if updated by is set, then this version of the dashboard was "created"
// by that user
if updater, ok := users[obj.GetUpdatedBy()]; ok {
createdBy = updater
}
createdByID := int64(0)
if createdBy != nil {
createdByID = createdBy.ID
}
created := obj.GetCreationTimestamp().Time
if updated, err := obj.GetUpdatedTimestamp(); err == nil && updated != nil {
created = *updated
}
restoreVer, err := getRestoreVersion(obj.GetMessage())
if err != nil {
return nil, err
}
return &dashver.DashboardVersionDTO{
ID: vspec.Version,
DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
DashboardUID: vspec.UID,
Created: created,
CreatedBy: createdByID,
Message: obj.GetMessage(),
RestoredFrom: restoreVer,
Version: int(vspec.Version),
ParentVersion: int(vspec.ParentVersion),
Data: simplejson.NewFromAny(vspec.Spec),
}, nil
}
var restoreMsg = "Restored from version "
func DashboardRestoreMessage(version int) string {
return fmt.Sprintf("%s%d", restoreMsg, version)
}
func getRestoreVersion(msg string) (int, error) {
parts := strings.Split(msg, restoreMsg)
if len(parts) < 2 {
return 0, nil
}
ver, err := strconv.Atoi(parts[1])
if err != nil {
return 0, err
}
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
}