Dashboards: Add Dashboard API Validation tests and fix underlying issues (#103502)

This commit is contained in:
Marco de Abreu
2025-04-07 05:44:28 +03:00
committed by GitHub
parent 427715b070
commit b2f80d4dca
16 changed files with 1521 additions and 240 deletions
@@ -428,7 +428,7 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI
}, created, nil
}
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboard.Dashboard) (*dashboard.Dashboard, bool, error) {
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboard.Dashboard, failOnExisting bool) (*dashboard.Dashboard, bool, error) {
user, ok := claims.AuthInfoFrom(ctx)
if !ok || user == nil {
return nil, false, fmt.Errorf("no user found in context")
@@ -438,6 +438,9 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
if err != nil {
return nil, created, err
}
if failOnExisting && !created {
return nil, created, dashboards.ErrDashboardWithSameUIDExists
}
out, err := a.dashStore.SaveDashboard(ctx, *cmd)
if err != nil {
@@ -118,7 +118,8 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ
rv = int64(after.Version)
}
} else {
after, _, err := a.SaveDashboard(ctx, info.OrgID, dash)
failOnExisting := event.Type == resource.WatchEvent_ADDED
after, _, err := a.SaveDashboard(ctx, info.OrgID, dash, failOnExisting)
if err != nil {
return 0, err
}
+1 -1
View File
@@ -55,7 +55,7 @@ type DashboardAccess interface {
LegacyMigrator
GetDashboard(ctx context.Context, orgId int64, uid string, version int64) (*dashboard.Dashboard, int64, error)
SaveDashboard(ctx context.Context, orgId int64, dash *dashboard.Dashboard) (*dashboard.Dashboard, bool, error)
SaveDashboard(ctx context.Context, orgId int64, dash *dashboard.Dashboard, failOnExisting bool) (*dashboard.Dashboard, bool, error)
DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboard.Dashboard, bool, error)
// Get a typed list
+49 -4
View File
@@ -10,16 +10,20 @@ import (
"k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
type DashboardStorage struct {
Access legacy.DashboardAccess
Access legacy.DashboardAccess
DashboardService dashboards.DashboardService
}
func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer) (grafanarest.Storage, error) {
@@ -46,18 +50,29 @@ func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Sch
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
return &storeWrapper{
Store: store,
Store: store,
DashboardService: s.DashboardService,
}, err
}
type storeWrapper struct {
*registry.Store
DashboardService dashboards.DashboardService
}
// Create will create the dashboard using legacy storage and make sure the internal ID is set on the return object
func (s *storeWrapper) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
ctx = legacy.WithLegacyAccess(ctx)
obj, err := s.Store.Create(ctx, obj, createValidation, options)
meta, err := utils.MetaAccessor(obj)
if err != nil {
return nil, err
}
managerProperties, managerPresent := meta.GetManagerProperties()
isProvisioned := managerPresent && managerProperties.Kind != utils.ManagerKindUnknown
obj, err = s.Store.Create(ctx, obj, createValidation, options)
access := legacy.GetLegacyAccess(ctx)
if access != nil && access.DashboardID > 0 {
meta, _ := utils.MetaAccessor(obj)
@@ -66,7 +81,37 @@ func (s *storeWrapper) Create(ctx context.Context, obj runtime.Object, createVal
meta.SetDeprecatedInternalID(access.DashboardID) //nolint:staticcheck
}
}
return obj, err
if err != nil {
return obj, err
}
unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return obj, err
}
unstructuredObj := &unstructured.Unstructured{Object: unstructuredMap}
user, err := identity.GetRequester(ctx)
if err != nil {
return obj, err
}
legacyDashboard, err := s.DashboardService.UnstructuredToLegacyDashboard(ctx, unstructuredObj, user.GetOrgID())
if err != nil {
return obj, err
}
// We only need these two parameters for SetDefaultPermissions
dto := &dashboards.SaveDashboardDTO{
User: user,
OrgID: user.GetOrgID(),
}
// Temporary approach to set default permissions until we have a proper method in place via k8s
s.DashboardService.SetDefaultPermissions(ctx, dto, legacyDashboard, isProvisioned)
return obj, nil
}
// Update will update the dashboard using legacy storage and make sure the internal ID is set on the return object
+226 -24
View File
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/conversion"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
@@ -36,13 +37,20 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/quota"
"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"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/client"
)
var (
@@ -52,6 +60,11 @@ var (
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
)
const (
dashboardSpecTitle = "title"
dashboardSpecRefreshInterval = "refresh"
)
// This is used just so wire has something unique to return
type DashboardsAPIBuilder struct {
dashboardService dashboards.DashboardService
@@ -63,6 +76,13 @@ type DashboardsAPIBuilder struct {
dashboardProvisioningService dashboards.DashboardProvisioningService
scheme *runtime.Scheme
search *SearchHandler
dashStore dashboards.Store
folderStore folder.FolderStore
QuotaService quota.Service
ProvisioningService provisioning.ProvisioningService
cfg *setting.Cfg
dualWriter dualwrite.Service
folderClient client.K8sHandler
log log.Logger
reg prometheus.Registerer
@@ -83,10 +103,15 @@ func RegisterAPIService(
unified resource.ResourceClient,
dual dualwrite.Service,
sorter sort.Service,
quotaService quota.Service,
folderStore folder.FolderStore,
restConfigProvider apiserver.RestConfigProvider,
userService user.Service,
) *DashboardsAPIBuilder {
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
folderClient := client.NewK8sHandler(dual, request.GetNamespaceMapper(cfg), folderv0alpha1.FolderResourceInfo.GroupVersionResource(), restConfigProvider.GetRestConfig, dashStore, userService, unified, sorter)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards"),
@@ -96,9 +121,17 @@ func RegisterAPIService(
unified: unified,
dashboardProvisioningService: provisioningDashboardService,
search: NewSearchHandler(tracing, dual, legacyDashboardSearcher, unified, features),
dashStore: dashStore,
folderStore: folderStore,
QuotaService: quotaService,
ProvisioningService: provisioning,
cfg: cfg,
dualWriter: dual,
folderClient: folderClient,
legacy: &DashboardStorage{
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, sorter),
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, sorter),
DashboardService: dashboardService,
},
reg: reg,
}
@@ -143,42 +176,211 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
return scheme.SetVersionPriority(b.GetGroupVersions()...)
}
// Validate will prevent deletion of provisioned dashboards, unless the grace period is set to 0, indicating a force deletion
// Validate validates dashboard operations for the apiserver
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
if op == admission.Delete {
obj := a.GetOperationOptions()
deleteOptions, ok := obj.(*metav1.DeleteOptions)
if !ok {
return fmt.Errorf("expected v1.DeleteOptions")
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
}
return nil
}
// validateDelete checks if a dashboard can be deleted
func (b *DashboardsAPIBuilder) validateDelete(ctx context.Context, a admission.Attributes) error {
obj := a.GetOperationOptions()
deleteOptions, ok := obj.(*metav1.DeleteOptions)
if !ok {
return fmt.Errorf("expected v1.DeleteOptions")
}
// Skip validation for forced deletions (grace period = 0)
if deleteOptions.GracePeriodSeconds != nil && *deleteOptions.GracePeriodSeconds == 0 {
return nil
}
nsInfo, err := claims.ParseNamespace(a.GetNamespace())
if err != nil {
return fmt.Errorf("%v: %w", "failed to parse namespace", err)
}
// The name of the resource is the dashboard UID
dashboardUID := a.GetName()
provisioningData, err := b.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, dashboardUID)
if err != nil {
if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) ||
errors.Is(err, dashboards.ErrDashboardNotFound) ||
apierrors.IsNotFound(err) {
return nil
}
if deleteOptions.GracePeriodSeconds == nil || *deleteOptions.GracePeriodSeconds != 0 {
nsInfo, err := claims.ParseNamespace(a.GetNamespace())
if err != nil {
return fmt.Errorf("%v: %w", "failed to parse namespace", err)
}
return fmt.Errorf("%v: %w", "delete hook failed to check if dashboard is provisioned", err)
}
provisioningData, err := b.dashboardProvisioningService.GetProvisionedDashboardDataByDashboardUID(ctx, nsInfo.OrgID, a.GetName())
if err != nil {
if errors.Is(err, dashboards.ErrProvisionedDashboardNotFound) ||
errors.Is(err, dashboards.ErrDashboardNotFound) ||
apierrors.IsNotFound(err) {
return nil
}
if provisioningData != nil {
return apierrors.NewBadRequest(dashboards.ErrDashboardCannotDeleteProvisionedDashboard.Reason)
}
return fmt.Errorf("%v: %w", "delete hook failed to check if dashboard is provisioned", err)
}
return nil
}
if provisioningData != nil {
return apierrors.NewBadRequest(dashboards.ErrDashboardCannotDeleteProvisionedDashboard.Reason)
}
// validateCreate validates dashboard creation
func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
// Get the dashboard object
dashObj := a.GetObject()
title, refresh, err := getDashboardProperties(dashObj)
if err != nil {
return fmt.Errorf("error extracting dashboard properties: %w", err)
}
accessor, err := utils.MetaAccessor(dashObj)
if err != nil {
return fmt.Errorf("error getting meta accessor: %w", err)
}
// Basic validations
if err := b.dashboardService.ValidateBasicDashboardProperties(title, accessor.GetName(), accessor.GetMessage()); err != nil {
return err
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil {
return err
}
id, err := identity.GetRequester(ctx)
if err != nil {
return fmt.Errorf("error getting requester: %w", err)
}
internalId, err := id.GetInternalID()
if err != nil {
return fmt.Errorf("error getting internal ID: %w", err)
}
// Validate quota
if !a.IsDryRun() {
params := &quota.ScopeParameters{}
params.OrgID = id.GetOrgID()
params.UserID = internalId
quotaReached, err := b.QuotaService.CheckQuotaReached(ctx, dashboards.QuotaTargetSrv, params)
if err != nil && !errors.Is(err, quota.ErrDisabled) {
return err
}
if quotaReached {
return dashboards.ErrQuotaReached
}
}
return nil
}
// validateUpdate validates dashboard updates
func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
// Get the new and old dashboards
newDashObj := a.GetObject()
oldDashObj := a.GetOldObject()
title, refresh, err := getDashboardProperties(newDashObj)
if err != nil {
return fmt.Errorf("error extracting dashboard properties: %w", err)
}
oldAccessor, err := utils.MetaAccessor(oldDashObj)
if err != nil {
return fmt.Errorf("error getting old dash meta accessor: %w", err)
}
newAccessor, err := utils.MetaAccessor(newDashObj)
if err != nil {
return fmt.Errorf("error getting new dash meta accessor: %w", err)
}
// Parse namespace for old dashboard
nsInfo, err := claims.ParseNamespace(oldAccessor.GetNamespace())
if err != nil {
return fmt.Errorf("failed to parse namespace: %w", err)
}
// Basic validations
if err := b.dashboardService.ValidateBasicDashboardProperties(title, newAccessor.GetName(), newAccessor.GetMessage()); err != nil {
return err
}
// Validate folder existence if specified and changed
if !a.IsDryRun() && newAccessor.GetFolder() != "" && newAccessor.GetFolder() != oldAccessor.GetFolder() {
if err := b.validateFolderExists(ctx, newAccessor.GetFolder(), nsInfo.OrgID); err != nil {
return err
}
}
// Validate refresh interval
if err := b.dashboardService.ValidateDashboardRefreshInterval(b.cfg.MinRefreshInterval, refresh); err != nil {
return err
}
allowOverwrite := false // TODO: Add support for overwrite flag
// check for is someone else has written in between
if newAccessor.GetGeneration() != oldAccessor.GetGeneration() {
if allowOverwrite {
newAccessor.SetGeneration(oldAccessor.GetGeneration())
} else {
return dashboards.ErrDashboardVersionMismatch
}
}
return nil
}
// validateFolderExists checks if a folder exists
func (b *DashboardsAPIBuilder) validateFolderExists(ctx context.Context, folderUID string, orgID int64) error {
// Check if folder exists using the folder store
_, err := b.folderClient.Get(ctx, folderUID, orgID, metav1.GetOptions{})
if err != nil {
if errors.Is(err, dashboards.ErrFolderNotFound) {
return err
}
return fmt.Errorf("error checking folder existence: %w", err)
}
return nil
}
// getDashboardProperties extracts title and refresh interval from any dashboard version
func getDashboardProperties(obj runtime.Object) (string, string, error) {
var title, refresh string
// Extract properties based on the object's type
switch d := obj.(type) {
case *v0alpha1.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *v1alpha1.Dashboard:
title = d.Spec.GetNestedString(dashboardSpecTitle)
refresh = d.Spec.GetNestedString(dashboardSpecRefreshInterval)
case *v2alpha1.Dashboard:
title = d.Spec.Title
refresh = d.Spec.TimeSettings.AutoRefresh
default:
return "", "", fmt.Errorf("unsupported dashboard version: %T", obj)
}
return title, refresh, nil
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storageOpts := apistore.StorageOptions{
EnableFolderSupport: true,