Files
grafana/pkg/services/dashboardversion/dashverimpl/dashver.go

693 lines
20 KiB
Go

package dashverimpl
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"go.opentelemetry.io/otel"
"golang.org/x/sync/errgroup"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"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/infra/tracing"
"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"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl")
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
}
versionObj, err := s.getDashboardVersionThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version)
if err != nil {
return nil, err
}
return s.transformUnstructuredToLegacyDTO(ctx, versionObj)
}
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
}
list, err := s.listDashboardVersionsThroughK8s(
ctx,
query.OrgID,
query.DashboardUID,
int64(query.Limit),
query.ContinueToken,
)
if err != nil {
return nil, err
}
dashboards, err := s.transformUnstructuredToLegacyDTOList(ctx, list.Items)
if err != nil {
return nil, err
}
return &dashver.DashboardVersionResponse{
ContinueToken: list.GetContinue(),
Versions: dashboards,
}, nil
}
// RestoreVersion restores a dashboard version.
func (s *Service) RestoreVersion(ctx context.Context, cmd *dashver.RestoreVersionCommand) (*dashboards.Dashboard, error) {
ctx, span := tracer.Start(ctx, "Service.RestoreVersion")
defer span.End()
// Get dashboard UID if not provided
if cmd.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, cmd.DashboardID)
if err != nil {
s.log.Debug("error getting dashboard UID", "error", err)
return nil, tracing.Error(span, err)
}
cmd.DashboardUID = u
}
//nolint:staticcheck // not yet migrated to OpenFeature
if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesDashboards) ||
s.features.IsEnabled(ctx, featuremgmt.FlagDashboardNewLayouts) {
s.log.Debug("restoring dashboard version through k8s")
res, err := s.restoreVersionThroughK8s(ctx, cmd)
if err != nil {
s.log.Debug("error restoring dashboard version through k8s", "error", err)
return nil, tracing.Error(span, err)
}
return res, nil
}
s.log.Debug("restoring dashboard version through legacy")
res, err := s.restoreVersionLegacy(ctx, cmd)
if err != nil {
s.log.Debug("error restoring dashboard version through legacy", "error", err)
return nil, tracing.Error(span, err)
}
return res, nil
}
func (s *Service) getDashboardVersionThroughK8s(
ctx context.Context, orgID int64, dashboardUID string, version int64,
) (*unstructured.Unstructured, 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 &item, nil
}
}
continueToken = out.GetContinue()
if continueToken == "" || len(out.Items) == 0 {
break
}
}
return nil, dashboards.ErrDashboardNotFound
}
func (s *Service) listDashboardVersionsThroughK8s(
ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string,
) (*unstructured.UnstructuredList, 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()
}
return out, nil
}
func (s *Service) restoreVersionThroughK8s(
ctx context.Context, cmd *dashver.RestoreVersionCommand,
) (*dashboards.Dashboard, error) {
ctx, span := tracer.Start(ctx, "Service.restoreVersionThroughK8s")
defer span.End()
// We must use separate gctx context here, because it will be canceled, once the group is done.
// If we use the same ctx after the call to g.Wait, it will already be canceled at that point,
// causing all subsequent context-using operations to immediately return with "context canceled" error.
g, gctx := errgroup.WithContext(ctx)
var current *unstructured.Unstructured
g.Go(func() error {
var err error
current, err = s.k8sclient.Get(gctx, cmd.DashboardUID, cmd.Requester.GetOrgID(), v1.GetOptions{})
if err != nil {
s.log.Debug("error getting current dashboard", "error", err)
}
return err
})
var version *unstructured.Unstructured
g.Go(func() error {
var err error
version, err = s.getDashboardVersionThroughK8s(gctx, cmd.Requester.GetOrgID(), cmd.DashboardUID, cmd.Version)
if err != nil {
s.log.Debug("error getting version", "error", err)
}
return err
})
if err := g.Wait(); err != nil {
return nil, tracing.Error(span, err)
}
// Compare dashboard data using the new version-aware comparator
identical, err := compareUnstructuredDashboards(version, current)
if err != nil {
s.log.Debug("error comparing dashboard versions", "error", err)
return nil, tracing.Error(span, err)
}
if identical {
return nil, dashboards.ErrDashboardRestoreIdenticalVersion
}
versionMeta, err := utils.MetaAccessor(version)
if err != nil {
s.log.Debug("error getting old version meta accessor", "error", err)
return nil, tracing.Error(span, err)
}
spec, err := versionMeta.GetSpec()
if err != nil {
s.log.Debug("error getting old version spec", "error", err)
return nil, tracing.Error(span, err)
}
currentMeta, err := utils.MetaAccessor(current)
if err != nil {
s.log.Debug("error getting current meta accessor", "error", err)
return nil, tracing.Error(span, err)
}
currentMeta.SetMessage(dashboardRestoreMessage(int(versionMeta.GetGeneration())))
if err := currentMeta.SetSpec(spec); err != nil {
s.log.Debug("error setting current version spec", "error", err)
return nil, tracing.Error(span, err)
}
updatedObj, err := s.k8sclient.Update(ctx, current, cmd.Requester.GetOrgID(), v1.UpdateOptions{})
if err != nil {
s.log.Debug("error updating dashboard to specified version", "error", err)
return nil, tracing.Error(span, err)
}
res, err := s.dashSvc.UnstructuredToLegacyDashboard(ctx, updatedObj, cmd.Requester.GetOrgID())
if err != nil {
s.log.Debug("error converting dashboard to legacy dashboard", "error", err)
return nil, tracing.Error(span, err)
}
return res, nil
}
func (s *Service) restoreVersionLegacy(
ctx context.Context, cmd *dashver.RestoreVersionCommand,
) (*dashboards.Dashboard, error) {
ctx, span := tracer.Start(ctx, "Service.restoreVersionLegacy")
defer span.End()
// We must use separate gctx context here, because it will be canceled, once the group is done.
// If we use the same ctx after the call to g.Wait, it will already be canceled at that point,
// causing all subsequent context-using operations to immediately return with "context canceled" error.
g, gctx := errgroup.WithContext(ctx)
var currentDash *dashboards.Dashboard
g.Go(func() error {
var err error
currentDash, err = s.dashSvc.GetDashboard(gctx, &dashboards.GetDashboardQuery{
UID: cmd.DashboardUID,
OrgID: cmd.Requester.GetOrgID(),
})
if err != nil {
s.log.Debug("error getting dashboard", "error", err)
}
return err
})
var versionData *dashver.DashboardVersionDTO
g.Go(func() error {
versionObj, err := s.getDashboardVersionThroughK8s(gctx, cmd.Requester.GetOrgID(), cmd.DashboardUID, cmd.Version)
if err != nil {
s.log.Debug("error getting dashboard version", "error", err)
return err
}
versionData, err = s.transformUnstructuredToLegacyDTO(gctx, versionObj)
if err != nil {
s.log.Debug("error transforming dashboard version to DTO", "error", err)
return err
}
return nil
})
if err := g.Wait(); err != nil {
return nil, tracing.Error(span, err)
}
if compareDashboardData(versionData.Data.MustMap(), currentDash.Data.MustMap(), true) {
return nil, dashboards.ErrDashboardRestoreIdenticalVersion
}
userID, err := identity.UserIdentifier(cmd.Requester.GetID())
if err != nil {
s.log.Debug("error getting user identifier", "error", err)
return nil, tracing.Error(span, err)
}
// This logic has been copied from the API handler unmodified for the most part.
// There is some strange back-and-forth conversions between the two commands,
// that should ideally be cleaned up.
saveCmd := dashboards.SaveDashboardCommand{
RestoredFrom: versionData.Version,
OrgID: cmd.Requester.GetOrgID(),
UserID: userID,
Dashboard: versionData.Data,
FolderUID: currentDash.FolderUID,
}
saveCmd.Dashboard.Set("version", currentDash.Version)
saveCmd.Dashboard.Set("uid", currentDash.UID)
dash := saveCmd.GetDashboardModel()
dashItem := &dashboards.SaveDashboardDTO{
User: cmd.Requester,
OrgID: cmd.Requester.GetOrgID(),
UpdatedAt: time.Now(),
Message: dashboardRestoreMessage(versionData.Version),
Overwrite: false,
Dashboard: dash,
}
res, err := s.dashSvc.SaveDashboard(ctx, dashItem, true)
if err != nil {
s.log.Debug("error saving dashboard", "error", err)
return nil, tracing.Error(span, err)
}
return res, nil
}
func (s *Service) transformUnstructuredToLegacyDTO(
ctx context.Context, item *unstructured.Unstructured,
) (*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 unstructuredToLegacyDashboardVersionWithUsers(item, users)
}
func (s *Service) transformUnstructuredToLegacyDTOList(
ctx context.Context, items []unstructured.Unstructured,
) ([]*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 := unstructuredToLegacyDashboardVersionWithUsers(&item, users)
if err != nil {
return nil, err
}
versions[i] = version
}
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
}
// 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() == v2alpha1.GroupVersion.String() ||
obj.GetAPIVersion() == v2beta1.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
}
func 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
createdByID := int64(0)
if creator, ok := users[obj.GetCreatedBy()]; ok {
createdByID = creator.ID
}
// if updated by is set, then this version of the dashboard was "created"
// by that user. note: this will be empty for the first version of a dashboard in unistore,
// but will be set in legacy
if obj.GetUpdatedBy() != "" {
if updater, ok := users[obj.GetUpdatedBy()]; ok {
createdByID = updater.ID
} else {
createdByID = -1
}
}
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
}
const 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
}
// compareUnstructuredDashboards compares two dashboards in unstructured.Unstructured format.
func compareUnstructuredDashboards(dst, src *unstructured.Unstructured) (bool, error) {
dstVersion := dst.GetAPIVersion()
srcVersion := src.GetAPIVersion()
// Both should have the same API version for comparison
if dstVersion != srcVersion {
return false, fmt.Errorf("cannot compare dashboards with different API versions: %s vs %s", dstVersion, srcVersion)
}
dstSpec, ok := dst.Object["spec"].(map[string]any)
if !ok {
return false, fmt.Errorf("failed to parse spec for the dashboard version")
}
srcSpec, ok := src.Object["spec"].(map[string]any)
if !ok {
return false, fmt.Errorf("failed to parse spec for the current dashboard")
}
cleanData := dstVersion == v0alpha1.APIVersion ||
dstVersion == v1beta1.APIVersion
return compareDashboardData(dstSpec, srcSpec, cleanData), nil
}
func compareDashboardData(versionData, dashData map[string]any, cleanData bool) bool {
if cleanData {
// these can be different but the actual data is the same
delete(versionData, "version")
delete(dashData, "version")
delete(versionData, "id")
delete(dashData, "id")
delete(versionData, "uid")
delete(dashData, "uid")
}
return reflect.DeepEqual(versionData, dashData)
}