Dashboards: Add Dashboard API Validation tests and fix underlying issues (#103502)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 := "a.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,
|
||||
|
||||
Reference in New Issue
Block a user