Files

418 lines
12 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"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"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"
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 (
maxVersionsToDeletePerBatch = 100
maxVersionDeletionBatches = 50
)
type Service struct {
cfg *setting.Cfg
store store
dashSvc dashboards.DashboardService
k8sclient client.K8sHandler
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 {
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,
),
dashSvc: dashboardService,
log: log.New("dashboard-version"),
}
}
func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersionDTO, error) {
// Get the DashboardUID if not populated
if query.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
if err != nil {
return nil, err
}
query.DashboardUID = u
}
// The store methods require the dashboard ID (uid is not in the dashboard
// versions table, at time of this writing), so get the DashboardID if it
// was not populated.
if query.DashboardID == 0 {
id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID)
if err != nil {
return nil, err
}
query.DashboardID = id
}
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
version, err := s.getHistoryThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version)
if err != nil {
return nil, err
}
return version, nil
}
version, err := s.store.Get(ctx, query)
if err != nil {
return nil, err
}
version.Data.Set("id", version.DashboardID)
return version.ToDTO(query.DashboardUID), 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) {
// Get the DashboardUID if not populated
if query.DashboardUID == "" {
u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
if err != nil {
return nil, err
}
query.DashboardUID = u
}
// The store methods require the dashboard ID (uid is not in the dashboard
// versions table, at time of this writing), so get the DashboardID if it
// was not populated.
if query.DashboardID == 0 {
id, err := s.getDashIDMaybeEmpty(ctx, query.DashboardUID, query.OrgID)
if err != nil {
return nil, err
}
query.DashboardID = id
}
if query.Limit == 0 {
query.Limit = 1000
}
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
versions, err := s.listHistoryThroughK8s(
ctx,
query.OrgID,
query.DashboardUID,
int64(query.Limit),
query.ContinueToken,
)
if err != nil {
return nil, err
}
return versions, nil
}
dvs, err := s.store.List(ctx, query)
if err != nil {
return nil, err
}
dtos := make([]*dashver.DashboardVersionDTO, len(dvs))
for i, v := range dvs {
dtos[i] = v.ToDTO(query.DashboardUID)
}
return &dashver.DashboardVersionResponse{
Versions: dtos,
}, 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
}
// getDashIDMaybeEmpty is a helper function which takes a dashboardUID and
// returns the ID. If the dashboard is not found, it will return -1.
func (s *Service) getDashIDMaybeEmpty(ctx context.Context, uid string, orgID int64) (int64, error) {
q := dashboards.GetDashboardQuery{UID: uid, OrgID: orgID}
result, err := s.dashSvc.GetDashboard(ctx, &q)
if err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
s.log.Debug("dashboard not found")
return -1, nil
} else {
s.log.Error("error getting dashboard", err)
return -1, err
}
}
return result.ID, 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
}
dashboards, err := s.UnstructuredToLegacyDashboardVersionList(ctx, out.Items, orgID)
if err != nil {
return nil, err
}
return &dashver.DashboardVersionResponse{
ContinueToken: out.GetContinue(),
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) {
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 {
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
}
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: dashVersion,
DashboardID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
DashboardUID: uid,
Created: created,
CreatedBy: createdByID,
Message: obj.GetMessage(),
RestoredFrom: restoreVer,
Version: int(dashVersion),
ParentVersion: int(parentVersion),
Data: simplejson.NewFromAny(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
}