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
+4 -2
View File
@@ -95,11 +95,13 @@ func (g *genericStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Obje
}
func (g *genericStrategy) AllowCreateOnUpdate() bool {
return true
// Necessary due to dualwriter storage strategy
return true // TODO: Check if we can have a separate strategy for storage and for the /apis endpoint
}
func (g *genericStrategy) AllowUnconditionalUpdate() bool {
return true
// Necessary due to dualwriter storage strategy
return true // TODO: Check if we can have a separate strategy for storage and for the /apis endpoint
}
func (g *genericStrategy) Canonicalize(obj runtime.Object) {}
@@ -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,
+5
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/model"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// DashboardService is a service for operating on dashboards.
@@ -33,6 +34,10 @@ type DashboardService interface {
GetAllDashboardsByOrgId(ctx context.Context, orgID int64) ([]*Dashboard, error)
CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error
CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error)
SetDefaultPermissions(ctx context.Context, dto *SaveDashboardDTO, dash *Dashboard, provisioned bool)
UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*Dashboard, error)
ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error
ValidateBasicDashboardProperties(title string, uid string, message string) error
}
type PermissionsRegistrationService interface {
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.2. DO NOT EDIT.
// Code generated by mockery v2.53.3. DO NOT EDIT.
package dashboards
@@ -9,6 +9,8 @@ import (
mock "github.com/stretchr/testify/mock"
model "github.com/grafana/grafana/pkg/services/search/model"
unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// FakeDashboardService is an autogenerated mock type for the DashboardService type
@@ -46,6 +48,24 @@ func (_m *FakeDashboardService) BuildSaveDashboardCommand(ctx context.Context, d
return r0, r1
}
// CleanUpDashboard provides a mock function with given fields: ctx, dashboardUID, orgId
func (_m *FakeDashboardService) CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error {
ret := _m.Called(ctx, dashboardUID, orgId)
if len(ret) == 0 {
panic("no return value specified for CleanUpDashboard")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, int64) error); ok {
r0 = rf(ctx, dashboardUID, orgId)
} else {
r0 = ret.Error(0)
}
return r0
}
// CountDashboardsInOrg provides a mock function with given fields: ctx, orgID
func (_m *FakeDashboardService) CountDashboardsInOrg(ctx context.Context, orgID int64) (int64, error) {
ret := _m.Called(ctx, orgID)
@@ -438,17 +458,70 @@ func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *Fin
return r0, r1
}
// CleanUpDashboard provides a mock function with given fields: ctx, dashboardUID, orgId
func (_m *FakeDashboardService) CleanUpDashboard(ctx context.Context, dashboardUID string, orgId int64) error {
ret := _m.Called(ctx, dashboardUID, orgId)
// SetDefaultPermissions provides a mock function with given fields: ctx, dto, dash, provisioned
func (_m *FakeDashboardService) SetDefaultPermissions(ctx context.Context, dto *SaveDashboardDTO, dash *Dashboard, provisioned bool) {
_m.Called(ctx, dto, dash, provisioned)
}
// UnstructuredToLegacyDashboard provides a mock function with given fields: ctx, item, orgID
func (_m *FakeDashboardService) UnstructuredToLegacyDashboard(ctx context.Context, item *unstructured.Unstructured, orgID int64) (*Dashboard, error) {
ret := _m.Called(ctx, item, orgID)
if len(ret) == 0 {
panic("no return value specified for CleanUpDashboard")
panic("no return value specified for UnstructuredToLegacyDashboard")
}
var r0 *Dashboard
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *unstructured.Unstructured, int64) (*Dashboard, error)); ok {
return rf(ctx, item, orgID)
}
if rf, ok := ret.Get(0).(func(context.Context, *unstructured.Unstructured, int64) *Dashboard); ok {
r0 = rf(ctx, item, orgID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*Dashboard)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *unstructured.Unstructured, int64) error); ok {
r1 = rf(ctx, item, orgID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ValidateBasicDashboardProperties provides a mock function with given fields: title, uid, message
func (_m *FakeDashboardService) ValidateBasicDashboardProperties(title string, uid string, message string) error {
ret := _m.Called(title, uid, message)
if len(ret) == 0 {
panic("no return value specified for ValidateBasicDashboardProperties")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, int64) error); ok {
r0 = rf(ctx, dashboardUID, orgId)
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(title, uid, message)
} else {
r0 = ret.Error(0)
}
return r0
}
// ValidateDashboardRefreshInterval provides a mock function with given fields: minRefreshInterval, targetRefreshInterval
func (_m *FakeDashboardService) ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error {
ret := _m.Called(minRefreshInterval, targetRefreshInterval)
if len(ret) == 0 {
panic("no return value specified for ValidateDashboardRefreshInterval")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(minRefreshInterval, targetRefreshInterval)
} else {
r0 = ret.Error(0)
}
+92 -130
View File
@@ -70,22 +70,98 @@ func (d *dashboardStore) emitEntityEvent() bool {
return d.features != nil && d.features.IsEnabledGlobally(featuremgmt.FlagPanelTitleSearch)
}
// TODO: once the folder service removes usage of this function, remove it here. The dashboard service now implements this
// on the service level for dashboards.
func (d *dashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dashboard *dashboards.Dashboard, overwrite bool) (bool, error) {
func (d *dashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dash *dashboards.Dashboard, overwrite bool) (bool, error) {
ctx, span := tracer.Start(ctx, "dashboards.database.ValidateDashboardBeforesave")
defer span.End()
isParentFolderChanged := false
err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
var err error
isParentFolderChanged, err = getExistingDashboardByIDOrUIDForUpdate(sess, dashboard, overwrite)
if err != nil {
return err
dashWithIdExists := false
var existingById dashboards.Dashboard
// we don't save FolderID in kubernetes object when saving through k8s
// this block guarantees we save dashboards with folder_id and folder_uid in those cases
if !dash.IsFolder && dash.FolderUID != "" && dash.FolderID == 0 { // nolint:staticcheck
var existing dashboards.Dashboard
folderIdFound, err := sess.Where("uid=? AND org_id=?", dash.FolderUID, dash.OrgID).Get(&existing)
if err != nil {
return err
}
if folderIdFound {
dash.FolderID = existing.ID // nolint:staticcheck
}
}
if dash.ID > 0 {
var err error
dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existingById)
if err != nil {
return fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err)
}
if !dashWithIdExists {
return dashboards.ErrDashboardNotFound
}
if dash.UID == "" {
dash.SetUID(existingById.UID)
}
}
dashWithUidExists := false
var existingByUid dashboards.Dashboard
if dash.UID != "" {
var err error
dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgID, dash.UID).Get(&existingByUid)
if err != nil {
return fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err)
}
}
if !dashWithIdExists && !dashWithUidExists {
return nil
}
if dashWithIdExists && dashWithUidExists && existingById.ID != existingByUid.ID {
return dashboards.ErrDashboardWithSameUIDExists
}
existing := existingById
if !dashWithIdExists && dashWithUidExists {
dash.SetID(existingByUid.ID)
dash.SetUID(existingByUid.UID)
existing = existingByUid
}
if (existing.IsFolder && !dash.IsFolder) ||
(!existing.IsFolder && dash.IsFolder) {
return dashboards.ErrDashboardTypeMismatch
}
if !dash.IsFolder && dash.FolderUID != existing.FolderUID {
isParentFolderChanged = true
}
// check for is someone else has written in between
if dash.Version != existing.Version {
if overwrite {
dash.SetVersion(existing.Version)
} else {
return dashboards.ErrDashboardVersionMismatch
}
}
// do not allow plugin dashboard updates without overwrite flag
if existing.PluginID != "" && !overwrite {
return dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID}
}
return nil
})
if err != nil {
return false, err
}
@@ -186,7 +262,7 @@ func (d *dashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd dashb
var result *dashboards.Dashboard
var err error
err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
result, err = saveDashboard(sess, &cmd, d.emitEntityEvent())
result, err = d.saveDashboard(ctx, sess, &cmd, d.emitEntityEvent())
if err != nil {
return err
}
@@ -207,7 +283,7 @@ func (d *dashboardStore) SaveDashboard(ctx context.Context, cmd dashboards.SaveD
var result *dashboards.Dashboard
var err error
err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
result, err = saveDashboard(sess, &cmd, d.emitEntityEvent())
result, err = d.saveDashboard(ctx, sess, &cmd, d.emitEntityEvent())
if err != nil {
return err
}
@@ -324,126 +400,16 @@ func (d *dashboardStore) CountInOrg(ctx context.Context, orgID int64, isFolder b
return r.Count, nil
}
func getExistingDashboardByIDOrUIDForUpdate(sess *db.Session, dash *dashboards.Dashboard, overwrite bool) (bool, error) {
dashWithIdExists := false
isParentFolderChanged := false
var existingById dashboards.Dashboard
if dash.ID > 0 {
var err error
dashWithIdExists, err = sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existingById)
if err != nil {
return false, fmt.Errorf("SQL query for existing dashboard by ID failed: %w", err)
}
if !dashWithIdExists {
return false, dashboards.ErrDashboardNotFound
}
if dash.UID == "" {
dash.SetUID(existingById.UID)
}
}
dashWithUidExists := false
var existingByUid dashboards.Dashboard
if dash.UID != "" {
var err error
dashWithUidExists, err = sess.Where("org_id=? AND uid=?", dash.OrgID, dash.UID).Get(&existingByUid)
if err != nil {
return false, fmt.Errorf("SQL query for existing dashboard by UID failed: %w", err)
}
}
if !dashWithIdExists && !dashWithUidExists {
return false, nil
}
if dashWithIdExists && dashWithUidExists && existingById.ID != existingByUid.ID {
return false, dashboards.ErrDashboardWithSameUIDExists
}
existing := existingById
if !dashWithIdExists && dashWithUidExists {
dash.SetID(existingByUid.ID)
dash.SetUID(existingByUid.UID)
existing = existingByUid
}
if (existing.IsFolder && !dash.IsFolder) ||
(!existing.IsFolder && dash.IsFolder) {
return isParentFolderChanged, dashboards.ErrDashboardTypeMismatch
}
if !dash.IsFolder && dash.FolderUID != existing.FolderUID {
isParentFolderChanged = true
}
// check for is someone else has written in between
if dash.Version != existing.Version {
if overwrite {
dash.SetVersion(existing.Version)
} else {
return isParentFolderChanged, dashboards.ErrDashboardVersionMismatch
}
}
// do not allow plugin dashboard updates without overwrite flag
if existing.PluginID != "" && !overwrite {
return isParentFolderChanged, dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID}
}
return isParentFolderChanged, nil
}
func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitEntityEvent bool) (*dashboards.Dashboard, error) {
func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitEntityEvent bool) (*dashboards.Dashboard, error) {
dash := cmd.GetDashboardModel()
userId := cmd.UserID
if userId == 0 {
userId = -1
isParentFolderChanged, err := d.ValidateDashboardBeforeSave(ctx, dash, cmd.Overwrite)
if err != nil {
return nil, err
}
// we don't save FolderID in kubernetes object when saving through k8s
// this block guarantees we save dashboards with folder_id and folder_uid in those cases
if !dash.IsFolder && dash.FolderUID != "" && dash.FolderID == 0 { // nolint:staticcheck
var existing dashboards.Dashboard
folderIdFound, err := sess.Where("uid=? AND org_id=?", dash.FolderUID, dash.OrgID).Get(&existing)
if err != nil {
return nil, err
}
if folderIdFound {
dash.FolderID = existing.ID // nolint:staticcheck
}
}
if dash.ID > 0 {
var existing dashboards.Dashboard
dashWithIdExists, err := sess.Where("id=? AND org_id=?", dash.ID, dash.OrgID).Get(&existing)
if err != nil {
return nil, err
}
if !dashWithIdExists {
return nil, dashboards.ErrDashboardNotFound
}
// check for is someone else has written in between
if dash.Version != existing.Version {
if cmd.Overwrite {
dash.SetVersion(existing.Version)
} else {
return nil, dashboards.ErrDashboardVersionMismatch
}
}
// do not allow plugin dashboard updates without overwrite flag
if existing.PluginID != "" && !cmd.Overwrite {
return nil, dashboards.UpdatePluginDashboardError{PluginId: existing.PluginID}
}
if isParentFolderChanged {
d.log.Debug("Dashboard parent folder has changed", "dashboard", dash.UID, "newFolder", dash.FolderUID)
}
if dash.UID == "" {
@@ -452,14 +418,12 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE
parentVersion := dash.Version
var affectedRows int64
var err error
if dash.ID == 0 {
dash.SetVersion(1)
dash.Created = time.Now()
dash.CreatedBy = userId
dash.CreatedBy = dash.UpdatedBy
dash.Updated = time.Now()
dash.UpdatedBy = userId
metrics.MApiDashboardInsert.Inc()
affectedRows, err = sess.Nullable("folder_uid").Insert(dash)
} else {
@@ -471,8 +435,6 @@ func saveDashboard(sess *db.Session, cmd *dashboards.SaveDashboardCommand, emitE
dash.Updated = time.Now()
}
dash.UpdatedBy = userId
affectedRows, err = sess.MustCols("folder_id", "folder_uid").Nullable("folder_uid").ID(dash.ID).Update(dash)
}
+5
View File
@@ -117,6 +117,11 @@ var (
StatusCode: 400,
Status: "bad-request",
}
ErrQuotaReached = dashboardaccess.DashboardErr{
Reason: "Dashboard quota reached",
StatusCode: 403,
Status: "quota-reached",
}
ErrFolderNotFound = errors.New("folder not found")
ErrFolderVersionMismatch = errors.New("the folder has been changed by someone else")
@@ -608,6 +608,29 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co
return dr.dashboardStore.GetProvisionedDataByDashboardUID(ctx, orgID, dashboardUID)
}
func (dr *DashboardServiceImpl) ValidateBasicDashboardProperties(title string, uid string, message string) error {
if title == "" {
return dashboards.ErrDashboardTitleEmpty
}
if len(title) > 5000 {
return dashboards.ErrDashboardTitleTooLong
}
// Validate message
if message != "" && len(message) > 500 {
return dashboards.ErrDashboardMessageTooLong
}
if !util.IsValidShortUID(uid) {
return dashboards.ErrDashboardInvalidUid
} else if util.IsShortUIDTooLong(uid) {
return dashboards.ErrDashboardUidTooLong
}
return nil
}
//nolint:gocyclo
func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, dto *dashboards.SaveDashboardDTO,
validateProvisionedDashboard bool) (*dashboards.SaveDashboardCommand, error) {
@@ -621,16 +644,8 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
dash.Data.Set("title", dash.Title)
dash.SetUID(strings.TrimSpace(dash.UID))
if dash.Title == "" {
return nil, dashboards.ErrDashboardTitleEmpty
}
if len(dash.Title) > 5000 {
return nil, dashboards.ErrDashboardTitleTooLong
}
if len(dto.Message) > 500 {
return nil, dashboards.ErrDashboardMessageTooLong
if err := dr.ValidateBasicDashboardProperties(dash.Title, dash.UID, dto.Message); err != nil {
return nil, err
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc()
@@ -643,13 +658,7 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d
return nil, dashboards.ErrDashboardFolderNameExists
}
if !util.IsValidShortUID(dash.UID) {
return nil, dashboards.ErrDashboardInvalidUid
} else if util.IsShortUIDTooLong(dash.UID) {
return nil, dashboards.ErrDashboardUidTooLong
}
if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dash); err != nil {
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dash.Data.Get("refresh").MustString("")); err != nil {
return nil, err
}
@@ -910,13 +919,12 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
return dr.dashboardStore.DeleteOrphanedProvisionedDashboards(ctx, cmd)
}
func validateDashboardRefreshInterval(minRefreshInterval string, dash *dashboards.Dashboard) error {
func (dr *DashboardServiceImpl) ValidateDashboardRefreshInterval(minRefreshInterval string, targetRefreshInterval string) error {
if minRefreshInterval == "" {
return nil
}
refresh := dash.Data.Get("refresh").MustString("")
if refresh == "" || refresh == "auto" {
if targetRefreshInterval == "" || targetRefreshInterval == "auto" {
// since no refresh is set it is a valid refresh rate
return nil
}
@@ -925,9 +933,9 @@ func validateDashboardRefreshInterval(minRefreshInterval string, dash *dashboard
if err != nil {
return fmt.Errorf("parsing min refresh interval %q failed: %w", minRefreshInterval, err)
}
d, err := gtime.ParseDuration(refresh)
d, err := gtime.ParseDuration(targetRefreshInterval)
if err != nil {
return fmt.Errorf("parsing refresh duration %q failed: %w", refresh, err)
return fmt.Errorf("parsing refresh duration %q failed: %w", targetRefreshInterval, err)
}
if d < minRefreshIntervalDur {
@@ -942,7 +950,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
ctx, span := tracer.Start(ctx, "dashboards.service.SaveProvisionedDashboard")
defer span.End()
if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil {
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid",
dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", dr.cfg.MinRefreshInterval)
dto.Dashboard.Data.Set("refresh", dr.cfg.MinRefreshInterval)
@@ -971,7 +979,7 @@ func (dr *DashboardServiceImpl) SaveProvisionedDashboard(ctx context.Context, dt
}
if dto.Dashboard.ID == 0 {
dr.setDefaultPermissions(ctx, dto, dash, true)
dr.SetDefaultPermissions(ctx, dto, dash, true)
}
return dash, nil
@@ -1002,7 +1010,7 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
ctx, span := tracer.Start(ctx, "dashboards.service.SaveDashboard")
defer span.End()
if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil {
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
"dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval",
dr.cfg.MinRefreshInterval)
@@ -1021,7 +1029,7 @@ func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboar
// new dashboard created
if dto.Dashboard.ID == 0 {
dr.setDefaultPermissions(ctx, dto, dash, false)
dr.SetDefaultPermissions(ctx, dto, dash, false)
}
return dash, nil
@@ -1095,7 +1103,7 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo
ctx, span := tracer.Start(ctx, "dashboards.service.ImportDashboard")
defer span.End()
if err := validateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard); err != nil {
if err := dr.ValidateDashboardRefreshInterval(dr.cfg.MinRefreshInterval, dto.Dashboard.Data.Get("refresh").MustString("")); err != nil {
dr.log.Warn("Changing refresh interval for imported dashboard to minimum refresh interval",
"dashboardUid", dto.Dashboard.UID, "dashboardTitle", dto.Dashboard.Title,
"minRefreshInterval", dr.cfg.MinRefreshInterval)
@@ -1112,7 +1120,7 @@ func (dr *DashboardServiceImpl) ImportDashboard(ctx context.Context, dto *dashbo
return nil, err
}
dr.setDefaultPermissions(ctx, dto, dash, false)
dr.SetDefaultPermissions(ctx, dto, dash, false)
return dash, nil
}
@@ -1178,7 +1186,7 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
}
func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *dashboards.SaveDashboardDTO, dash *dashboards.Dashboard, provisioned bool) {
func (dr *DashboardServiceImpl) SetDefaultPermissions(ctx context.Context, dto *dashboards.SaveDashboardDTO, dash *dashboards.Dashboard, provisioned bool) {
ctx, span := tracer.Start(ctx, "dashboards.service.setDefaultPermissions")
defer span.End()
@@ -1765,9 +1773,14 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
meta.SetManagerProperties(m)
meta.SetSourceProperties(s)
// Update will create if not exists (upsert!)
out, err := dr.k8sclient.Update(ctx, obj, cmd.OrgID)
if err != nil {
if err != nil && apierrors.IsNotFound(err) {
// Create if it doesn't already exist.
out, err = dr.k8sclient.Create(ctx, obj, cmd.OrgID)
if err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
@@ -1782,9 +1795,14 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
dashboard.SetPluginIDMeta(obj, cmd.PluginID)
// Update will create if not exists (upsert!)
out, err := dr.k8sclient.Update(ctx, obj, orgID)
if err != nil {
if err != nil && apierrors.IsNotFound(err) {
// Create if it doesn't already exist.
out, err = dr.k8sclient.Create(ctx, obj, orgID)
if err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
@@ -2161,7 +2161,7 @@ func TestCountDashboardsInOrg(t *testing.T) {
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
service.features = featuremgmt.WithFeatures()
fakeStore.On("CountInOrg", mock.Anything, mock.Anything, false).Return(nil, nil).Once()
fakeStore.On("CountInOrg", mock.Anything, mock.Anything, false).Return(int64(1), nil).Once()
_, err := service.CountDashboardsInOrg(context.Background(), 1)
require.NoError(t, err)
fakeStore.AssertExpectations(t)
+7 -10
View File
@@ -1,13 +1,12 @@
// Code generated by mockery v2.52.2. DO NOT EDIT.
// Code generated by mockery v2.53.3. DO NOT EDIT.
package dashboards
import (
context "context"
mock "github.com/stretchr/testify/mock"
quota "github.com/grafana/grafana/pkg/services/quota"
mock "github.com/stretchr/testify/mock"
)
// FakeDashboardStore is an autogenerated mock type for the Store type
@@ -91,12 +90,12 @@ func (_m *FakeDashboardStore) CountDashboardsInFolders(ctx context.Context, requ
return r0, r1
}
// CountInOrg provides a mock function with given fields: ctx, orgID
// CountInOrg provides a mock function with given fields: ctx, orgID, isFolder
func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFolder bool) (int64, error) {
ret := _m.Called(ctx, orgID, isFolder)
if len(ret) == 0 {
panic("no return value specified for Count")
panic("no return value specified for CountInOrg")
}
var r0 int64
@@ -107,9 +106,7 @@ func (_m *FakeDashboardStore) CountInOrg(ctx context.Context, orgID int64, isFol
if rf, ok := ret.Get(0).(func(context.Context, int64, bool) int64); ok {
r0 = rf(ctx, orgID, isFolder)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(int64)
}
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(context.Context, int64, bool) error); ok {
@@ -433,7 +430,7 @@ func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query
return r0, r1
}
// GetOrphanedProvisionedDashboards provides a mock function with given fields: ctx, notIn
// GetOrphanedProvisionedDashboards provides a mock function with given fields: ctx, notIn, orgID
func (_m *FakeDashboardStore) GetOrphanedProvisionedDashboards(ctx context.Context, notIn []string, orgID int64) ([]*Dashboard, error) {
ret := _m.Called(ctx, notIn, orgID)
@@ -493,7 +490,7 @@ func (_m *FakeDashboardStore) GetProvisionedDashboardData(ctx context.Context, n
return r0, r1
}
// GetProvisionedDashboardsByName provides a mock function with given fields: ctx, name
// GetProvisionedDashboardsByName provides a mock function with given fields: ctx, name, orgID
func (_m *FakeDashboardStore) GetProvisionedDashboardsByName(ctx context.Context, name string, orgID int64) ([]*Dashboard, error) {
ret := _m.Called(ctx, name, orgID)
@@ -36,7 +36,6 @@ import (
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/retryer"
)
func TestMain(m *testing.M) {
@@ -210,34 +209,23 @@ providers:
title := "Grafana Dev Overview & Home"
dashboardList := &model.HitList{}
retry := 0
retries := 5
// retry until the provisioned dashboard is ready
err := retryer.Retry(func() (retryer.RetrySignal, error) {
retry++
require.EventuallyWithT(t, func(collect *assert.CollectT) {
u := fmt.Sprintf("http://admin:admin@%s/api/search?query=%s", grafanaListedAddr, url.QueryEscape(title))
// nolint:gosec
resp, err := http.Get(u)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
err = resp.Body.Close()
require.NoError(t, err)
err = json.Unmarshal(b, dashboardList)
require.NoError(t, err)
if dashboardList.Len() == 0 {
if retry >= retries {
return retryer.FuncError, fmt.Errorf("max retries exceeded")
}
t.Log("Dashboard is not ready", "retry", retry)
return retryer.FuncFailure, nil
}
return retryer.FuncComplete, nil
}, retries, time.Millisecond*time.Duration(25), time.Second)
require.NoError(t, err)
assert.Greater(collect, dashboardList.Len(), 0, "Dashboard should be ready")
}, 10*time.Second, 25*time.Millisecond)
var dashboardUID string
var dashboardID int64
@@ -0,0 +1,839 @@
package integration
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/dashboards" // TODO: Check if we can remove this import
"github.com/grafana/grafana/pkg/services/quota"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// TestContext holds common test resources
type TestContext struct {
Helper *apis.K8sTestHelper
AdminUser apis.User
EditorUser apis.User
ViewerUser apis.User
TestFolder *folder.Folder
AdminServiceAccountToken string
EditorServiceAccountToken string
ViewerServiceAccountToken string
OrgID int64
}
// TestIntegrationValidation tests the dashboard K8s API
func TestIntegrationValidation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Create a K8sTestHelper which will set up a real API server
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesClientDashboardsFolders, // Enable dashboard feature
},
})
t.Cleanup(func() {
helper.Shutdown()
})
// Create test contexts organization
org1Ctx := createTestContext(t, helper, helper.Org1)
t.Run("Organization 1 tests", func(t *testing.T) {
t.Run("Dashboard validation tests", func(t *testing.T) {
runDashboardValidationTests(t, org1Ctx)
})
t.Run("Dashboard quota tests", func(t *testing.T) {
runQuotaTests(t, org1Ctx)
})
})
}
// Auth identity types (user or token) with resource client
type Identity struct {
Name string
DashboardClient *apis.K8sResourceClient
FolderClient *apis.K8sResourceClient
Type string // "user" or "token"
}
// TODO: Test plugin dashboard updates with and without overwrite flag
// Run tests for dashboard validations
func runDashboardValidationTests(t *testing.T, ctx TestContext) {
t.Helper()
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
editorClient := getResourceClient(t, ctx.Helper, ctx.EditorUser, getDashboardGVR())
t.Run("Dashboard UID validations", func(t *testing.T) {
// Test creating dashboard with existing UID
t.Run("reject dashboard with existing UID", func(t *testing.T) {
// Create a dashboard with a specific UID
specificUID := "existing-uid-dash"
createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID)
require.NoError(t, err)
// Try to create another dashboard with the same UID
_, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID)
require.Error(t, err)
// Clean up
err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
// Test creating dashboard with too long UID
t.Run("reject dashboard with too long UID", func(t *testing.T) {
// Create a dashboard with a long UID (over 40 chars)
longUID := "this-uid-is-way-too-long-for-a-dashboard-uid-12345678901234567890"
_, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID)
require.Error(t, err)
})
// Test creating dashboard with invalid UID characters
t.Run("reject dashboard with invalid UID characters", func(t *testing.T) {
invalidUID := "invalid/uid/with/slashes"
_, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID)
require.Error(t, err)
})
})
// TODO: Validate both at creation and update
t.Run("Dashboard title validations", func(t *testing.T) {
// Test empty title
t.Run("reject dashboard with empty title", func(t *testing.T) {
_, err := createDashboard(t, adminClient, "", nil, nil)
require.Error(t, err)
})
// Test long title
t.Run("reject dashboard with excessively long title", func(t *testing.T) {
veryLongTitle := strings.Repeat("a", 10000)
_, err := createDashboard(t, adminClient, veryLongTitle, nil, nil)
require.Error(t, err)
})
// Test updating dashboard with empty title
t.Run("reject dashboard update with empty title", func(t *testing.T) {
// First create a valid dashboard
dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil)
require.NoError(t, err)
require.NotNil(t, dash)
// Try to update with empty title
_, err = updateDashboard(t, adminClient, dash, "", nil)
require.Error(t, err)
// Clean up
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
// Test updating dashboard with excessively long title
t.Run("reject dashboard update with excessively long title", func(t *testing.T) {
// First create a valid dashboard
dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil)
require.NoError(t, err)
require.NotNil(t, dash)
// Try to update with excessively long title
veryLongTitle := strings.Repeat("a", 10000)
_, err = updateDashboard(t, adminClient, dash, veryLongTitle, nil)
require.Error(t, err)
// Clean up
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
})
t.Run("Dashboard message validations", func(t *testing.T) {
// Test long message
t.Run("reject dashboard with excessively long update message", func(t *testing.T) {
dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil)
require.NoError(t, err)
veryLongMessage := strings.Repeat("a", 600)
_, err = updateDashboard(t, adminClient, dash, "Dashboard updated with a long message", &veryLongMessage)
require.Error(t, err)
// Clean up
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
})
t.Run("Dashboard schema validations", func(t *testing.T) {
// Test invalid dashboard schema
t.Run("reject dashboard with invalid schema", func(t *testing.T) {
dashObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(),
"kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
"metadata": map[string]interface{}{
"generateName": "test-",
},
// Missing spec
},
}
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.Error(t, err)
})
})
t.Run("Dashboard version handling", func(t *testing.T) {
// Test version increment on update
t.Run("version increments on dashboard update", func(t *testing.T) {
// Create a dashboard with admin
dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil)
require.NoError(t, err, "Failed to create dashboard for version test")
dashUID := dash.GetName()
// Get the initial version
meta, _ := utils.MetaAccessor(dash)
initialGeneration := meta.GetGeneration()
initialRV := meta.GetResourceVersion()
// Update the dashboard
updatedDash, err := updateDashboard(t, adminClient, dash, "Updated Dashboard for Version Test", nil)
require.NoError(t, err)
require.NotNil(t, updatedDash)
// Check that version was incremented
meta, _ = utils.MetaAccessor(updatedDash)
require.Greater(t, meta.GetGeneration(), initialGeneration, "Generation should be incremented after update")
require.NotEqual(t, meta.GetResourceVersion(), initialRV, "Resource version should be changed after update")
// Clean up
err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{})
require.NoError(t, err)
})
// Test generation conflict when updating concurrently
t.Run("reject update with version conflict", func(t *testing.T) {
// Create a dashboard with admin
dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil)
require.NoError(t, err, "Failed to create dashboard for version conflict test")
dashUID := dash.GetName()
// Get the dashboard twice (simulating two users getting it)
dash1, err := adminClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
dash2, err := editorClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
// Update with the first copy
updatedDash1, err := updateDashboard(t, adminClient, dash1, "Updated by first user", nil)
require.NoError(t, err)
require.NotNil(t, updatedDash1)
// Try to update with the second copy (should fail with version conflict)
_, err = updateDashboard(t, editorClient, dash2, "Updated by second user", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "the object has been modified", "Should fail with version conflict error")
// Clean up
err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{})
require.NoError(t, err)
})
// Test setting an explicit generation
t.Run("explicit generation setting is validated", func(t *testing.T) {
t.Skip("Double check expected behavior")
// Create a dashboard with a specific generation
dashObj := createDashboardObject(t, "Dashboard with Explicit Generation", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
meta.SetGeneration(5)
// Create the dashboard
createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.NoError(t, err)
dashUID := createdDash.GetName()
// Fetch the created dashboard
fetchedDash, err := adminClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
// Verify the generation was handled properly
meta, _ = utils.MetaAccessor(fetchedDash)
require.Equal(t, 5, meta.GetGeneration(), "Generation should be 5")
// Clean up
err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{})
require.NoError(t, err)
})
})
t.Run("Dashboard provisioning validations", func(t *testing.T) {
t.Skip("TODO: We need to create provisioned dashboards in two different ways to test this")
// Test updating provisioned dashboard
testCases := []struct {
name string
allowsEdits bool
shouldSucceed bool
}{
{
name: "reject updating provisioned dashboard when allowsEdits is false",
allowsEdits: false,
shouldSucceed: false,
},
{
name: "allow updating provisioned dashboard when allowsEdits is true",
allowsEdits: true,
shouldSucceed: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a dashboard with admin
dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil)
require.NoError(t, err, "Failed to create dashboard for provisioning test")
dashUID := dash.GetName()
// Fetch the created dashboard
fetchedDash, err := adminClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetchedDash)
// Mark the dashboard as provisioned with allowsEdits parameter
provisionedDash := markDashboardObjectAsProvisioned(t, fetchedDash, "test-provider", "test-external-id", "test-checksum", tc.allowsEdits)
// Update the dashboard to apply the provisioning annotations
updatedDash, err := adminClient.Resource.Update(context.Background(), provisionedDash, v1.UpdateOptions{})
require.NoError(t, err)
require.NotNil(t, updatedDash)
// Re-fetch the dashboard after it's marked as provisioned
provisionedFetchedDash, err := editorClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, provisionedFetchedDash)
// Try to update the dashboard using editor (not admin)
dashThatShouldFail, err := updateDashboard(t, editorClient, provisionedFetchedDash, "Updated Provisioned Dashboard", nil)
_ = dashThatShouldFail
if tc.shouldSucceed {
require.NoError(t, err, "Editor should be able to update provisioned dashboard when allowsEdits is true")
// Verify the update succeeded by fetching the dashboard again
updatedDash, err := editorClient.Resource.Get(context.Background(), dashUID, v1.GetOptions{})
require.NoError(t, err)
meta, _ := utils.MetaAccessor(updatedDash)
require.Equal(t, "Updated Provisioned Dashboard", meta.FindTitle(""), "Dashboard title should be updated")
} else {
require.Error(t, err, "Editor should not be able to update provisioned dashboard when allowsEdits is false")
require.Contains(t, err.Error(), "provisioned")
}
// Clean up
err = adminClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{})
require.NoError(t, err)
})
}
})
t.Run("Dashboard refresh interval validations", func(t *testing.T) {
// Create test client
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
// Store original settings to restore after test
origCfg := ctx.Helper.GetEnv().Cfg
origMinRefreshInterval := origCfg.MinRefreshInterval
// Set a fixed min_refresh_interval for all tests to make them predictable
ctx.Helper.GetEnv().Cfg.MinRefreshInterval = "10s"
testCases := []struct {
name string
refreshValue string
shouldSucceed bool
}{
{
name: "reject dashboard with refresh interval below minimum",
refreshValue: "5s",
shouldSucceed: false,
},
{
name: "accept dashboard with refresh interval equal to minimum",
refreshValue: "10s",
shouldSucceed: true,
},
{
name: "accept dashboard with refresh interval above minimum",
refreshValue: "30s",
shouldSucceed: true,
},
{
name: "accept dashboard with auto refresh",
refreshValue: "auto",
shouldSucceed: true,
},
{
name: "accept dashboard with empty refresh",
refreshValue: "",
shouldSucceed: true,
},
{
name: "reject dashboard with invalid refresh format",
refreshValue: "invalid",
shouldSucceed: false,
},
}
for _, tc := range testCases {
tc := tc // Capture for parallel execution
t.Run(tc.name, func(t *testing.T) {
// Create the dashboard with the specified refresh value
dashObj := createDashboardObject(t, "Dashboard with Refresh: "+tc.refreshValue, "", 0)
// Add refresh configuration using MetaAccessor
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["refresh"] = tc.refreshValue
_ = meta.SetSpec(specMap)
dash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
if tc.shouldSucceed {
require.NoError(t, err)
require.NotNil(t, dash)
// Clean up
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
// Restore original settings
ctx.Helper.GetEnv().Cfg.MinRefreshInterval = origMinRefreshInterval
})
t.Run("Dashboard size limit validations", func(t *testing.T) {
t.Run("reject dashboard exceeding size limit", func(t *testing.T) {
t.Skip("Skipping size limit test for now") // TODO: Revisit this.
// Create a dashboard with a specific UID to make it easier to manage
specificUID := "size-limit-test-dash"
dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID)
require.NoError(t, err)
meta, _ := utils.MetaAccessor(dash)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
// Create a large number of panels
var largePanelArray []map[string]interface{}
// Create 500000 simple panels with unique IDs (to exceed max allowed request size)
for i := 0; i < 500000; i++ {
// Create a simple panel with minimal properties
panel := map[string]interface{}{
"id": i,
"type": "graph",
"title": fmt.Sprintf("Panel %d", i),
"description": fmt.Sprintf("Panel description %d", i),
"gridPos": map[string]interface{}{
"h": 8,
"w": 12,
"x": i % 24,
"y": (i / 24) * 8,
},
"targets": []map[string]interface{}{
{
"refId": "A",
"expr": fmt.Sprintf("metric%d", i),
},
},
}
largePanelArray = append(largePanelArray, panel)
}
specMap["panels"] = largePanelArray
err = meta.SetSpec(specMap)
require.NoError(t, err, "Failed to set spec")
// Try to update with too many panels
_, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "exceeds", "Error should mention size or limit exceeded")
// Clean up
err = adminClient.Resource.Delete(context.Background(), specificUID, v1.DeleteOptions{})
require.NoError(t, err)
})
})
}
// Run tests for quota validation
func runQuotaTests(t *testing.T, ctx TestContext) {
t.Helper()
t.Skip("Skipping quota tests for now")
// TODO: Check why we return quota.disabled and also make sure we are able to handle it.
// Get access to services - use the helper environment's HTTP server
quotaService := ctx.Helper.GetEnv().Server.HTTPServer.QuotaService
require.NotNil(t, quotaService, "Quota service should be available")
adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR())
adminUserId, err := identity.UserIdentifier(ctx.AdminUser.Identity.GetID())
require.NoError(t, err)
// Define quota test cases
testCases := []struct {
name string
scope quota.Scope
id int64
scopeParam func(cmd *quota.UpdateQuotaCmd)
}{
{
name: "Organization quota",
scope: quota.OrgScope,
id: ctx.OrgID,
scopeParam: func(cmd *quota.UpdateQuotaCmd) {
cmd.OrgID = ctx.OrgID
},
},
{
name: "User quota",
scope: quota.UserScope,
id: adminUserId,
scopeParam: func(cmd *quota.UpdateQuotaCmd) {
cmd.UserID = adminUserId
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Get current quotas
quotas, err := quotaService.GetQuotasByScope(context.Background(), tc.scope, tc.id)
require.NoError(t, err, "Failed to get quotas")
// Find the dashboard quota and save original value
var originalQuota int64 = -1 // Default if not found
var quotaFound bool
for _, q := range quotas {
if q.Target == string(dashboards.QuotaTarget) {
originalQuota = q.Limit
quotaFound = true
break
}
}
// Set quota to 1 dashboard
updateCmd := &quota.UpdateQuotaCmd{
Target: string(dashboards.QuotaTarget),
Limit: 1,
}
tc.scopeParam(updateCmd)
err = quotaService.Update(context.Background(), updateCmd)
require.NoError(t, err, "Failed to update quota")
// Create first dashboard - should succeed
dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil)
require.NoError(t, err, "Failed to create first dashboard")
// Create second dashboard - should fail due to quota
_, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil)
require.Error(t, err, "Creating second dashboard should fail due to quota")
require.Contains(t, err.Error(), "quota", "Error should mention quota")
// Clean up the dashboard to reset the quota usage
err = adminClient.Resource.Delete(context.Background(), dash1.GetName(), v1.DeleteOptions{})
require.NoError(t, err, "Failed to delete test dashboard")
// Restore the original quota state
if quotaFound {
// If quota existed originally, restore its value
resetCmd := &quota.UpdateQuotaCmd{
Target: string(dashboards.QuotaTarget),
Limit: originalQuota,
}
tc.scopeParam(resetCmd)
err = quotaService.Update(context.Background(), resetCmd)
require.NoError(t, err, "Failed to reset quota")
} else if tc.scope == quota.UserScope {
// If user quota didn't exist originally, delete it
err = quotaService.DeleteQuotaForUser(context.Background(), tc.id)
require.NoError(t, err, "Failed to delete user quota")
}
})
}
}
// Helper function to create test context for an organization
func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.OrgUsers) TestContext {
// Create test folder
folderTitle := "Test Folder " + orgUsers.Admin.Identity.GetLogin()
testFolder, err := createFolder(t, helper, orgUsers.Admin, folderTitle)
require.NoError(t, err, "Failed to create test folder")
// Create test context
return TestContext{
Helper: helper,
AdminUser: orgUsers.Admin,
EditorUser: orgUsers.Editor,
ViewerUser: orgUsers.Viewer,
TestFolder: testFolder,
AdminServiceAccountToken: orgUsers.AdminServiceAccountToken,
EditorServiceAccountToken: orgUsers.EditorServiceAccountToken,
ViewerServiceAccountToken: orgUsers.ViewerServiceAccountToken,
OrgID: orgUsers.Admin.Identity.GetOrgID(),
}
}
// getDashboardGVR returns the dashboard GroupVersionResource
func getDashboardGVR() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: dashboardv1alpha1.DashboardResourceInfo.GroupVersion().Group,
Version: dashboardv1alpha1.DashboardResourceInfo.GroupVersion().Version,
Resource: dashboardv1alpha1.DashboardResourceInfo.GetName(),
}
}
// getFolderGVR returns the folder GroupVersionResource
func getFolderGVR() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: folderv0alpha1.FolderResourceInfo.GroupVersion().Group,
Version: folderv0alpha1.FolderResourceInfo.GroupVersion().Version,
Resource: folderv0alpha1.FolderResourceInfo.GetName(),
}
}
// Get a resource client for the specified user
func getResourceClient(t *testing.T, helper *apis.K8sTestHelper, user apis.User, gvr schema.GroupVersionResource) *apis.K8sResourceClient {
t.Helper()
return helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(user.Identity.GetOrgID()),
GVR: gvr,
})
}
// Get a resource client for the specified service token
// nolint:unused
func getServiceAccountResourceClient(t *testing.T, helper *apis.K8sTestHelper, token string, orgID int64, gvr schema.GroupVersionResource) *apis.K8sResourceClient {
t.Helper()
return helper.GetResourceClient(apis.ResourceClientArgs{
ServiceAccountToken: token,
Namespace: helper.Namespacer(orgID),
GVR: gvr,
})
}
// Create a folder object for testing
func createFolderObject(t *testing.T, title string, namespace string, parentFolderUID string) *unstructured.Unstructured {
t.Helper()
folderObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": folderv0alpha1.FolderResourceInfo.GroupVersion().String(),
"kind": folderv0alpha1.FolderResourceInfo.GroupVersionKind().Kind,
"metadata": map[string]interface{}{
"generateName": "test-folder-",
"namespace": namespace,
},
"spec": map[string]interface{}{
"title": title,
},
},
}
if parentFolderUID != "" {
meta, _ := utils.MetaAccessor(folderObj)
meta.SetFolder(parentFolderUID)
}
return folderObj
}
// Create a folder using Kubernetes API
func createFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, title string) (*folder.Folder, error) {
t.Helper()
// Get a client for the folder resource
folderClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(user.Identity.GetOrgID()),
GVR: getFolderGVR(),
})
// Create a folder resource
folderObj := createFolderObject(t, title, helper.Namespacer(user.Identity.GetOrgID()), "")
// Create the folder using the K8s client
ctx := context.Background()
createdFolder, err := folderClient.Resource.Create(ctx, folderObj, v1.CreateOptions{})
if err != nil {
return nil, err
}
meta, _ := utils.MetaAccessor(createdFolder)
// Create a folder struct to return (for compatibility with existing code)
return &folder.Folder{
UID: createdFolder.GetName(),
Title: meta.FindTitle(""),
}, nil
}
// Create a dashboard object for testing
func createDashboardObject(t *testing.T, title string, folderUID string, generation int64) *unstructured.Unstructured {
t.Helper()
dashObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": dashboardv1alpha1.DashboardResourceInfo.GroupVersion().String(),
"kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
"metadata": map[string]interface{}{
"generateName": "test-",
},
"spec": map[string]interface{}{
"title": title,
},
},
}
// Get the metadata accessor
meta, err := utils.MetaAccessor(dashObj)
require.NoError(t, err, "Failed to get metadata accessor")
// Get the dashboard's spec
spec, err := meta.GetSpec()
require.NoError(t, err, "Failed to get spec")
specMap := spec.(map[string]interface{})
if folderUID != "" {
meta.SetFolder(folderUID)
}
if generation > 0 {
meta.SetGeneration(generation)
}
// Update the spec
err = meta.SetSpec(specMap)
require.NoError(t, err, "Failed to set spec")
return dashObj
}
// Mark dashboard object as provisioned by setting appropriate annotations
func markDashboardObjectAsProvisioned(t *testing.T, dashboard *unstructured.Unstructured, providerName string, externalID string, checksum string, allowsEdits bool) *unstructured.Unstructured {
meta, err := utils.MetaAccessor(dashboard)
require.NoError(t, err)
m := utils.ManagerProperties{}
s := utils.SourceProperties{}
m.Kind = utils.ManagerKindKubectl
m.Identity = providerName
m.AllowsEdits = allowsEdits
s.Path = externalID
s.Checksum = checksum
s.TimestampMillis = 1633046400000
meta.SetManagerProperties(m)
meta.SetSourceProperties(s)
return dashboard
}
// Create a dashboard
func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string) (*unstructured.Unstructured, error) {
t.Helper()
var folderUIDStr string
if folderUID != nil && *folderUID != "" {
folderUIDStr = *folderUID
}
dashObj := createDashboardObject(t, title, folderUIDStr, 0)
// Set the name (UID) if provided
if uid != nil && *uid != "" {
meta, _ := utils.MetaAccessor(dashObj)
meta.SetName(*uid)
// Remove generateName if we're explicitly setting a name
delete(dashObj.Object["metadata"].(map[string]interface{}), "generateName")
}
// Create the dashboard
createdDash, err := client.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
if err != nil {
return nil, err
}
// TODO: Remove once the underlying issue is fixed:
// https://raintank-corp.slack.com/archives/C05FYAPEPKP/p1743111830777889
// This only happens in mode 0.
databaseDash, err := client.Resource.Get(context.Background(), createdDash.GetName(), v1.GetOptions{})
if err != nil {
return nil, err
}
require.NotEqual(t, createdDash.GetUID(), databaseDash.GetUID(), "The underlying UID mismatch bug has been fixed, please remove the redundant read!")
return databaseDash, nil
}
// Update a dashboard
func updateDashboard(t *testing.T, client *apis.K8sResourceClient, dashboard *unstructured.Unstructured, newTitle string, updateMessage *string) (*unstructured.Unstructured, error) {
t.Helper()
meta, _ := utils.MetaAccessor(dashboard)
// Get the spec using MetaAccessor
dashSpec, _ := meta.GetSpec()
specMap := dashSpec.(map[string]interface{})
// Update the title
specMap["title"] = newTitle
// Set the updated spec
_ = meta.SetSpec(specMap)
// Set message if provided
if updateMessage != nil {
meta.SetMessage(*updateMessage)
}
// Update the dashboard
return client.Resource.Update(context.Background(), dashboard, v1.UpdateOptions{})
}
+147 -6
View File
@@ -41,6 +41,7 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/team/teamimpl"
@@ -153,9 +154,22 @@ func (c *K8sTestHelper) Shutdown() {
}
type ResourceClientArgs struct {
User User
Namespace string
GVR schema.GroupVersionResource
// Provide either a user or a service account token
User User
ServiceAccountToken string
Namespace string
GVR schema.GroupVersionResource
}
// Validate ensures that either User or ServiceAccountToken is provided, but not both
func (args ResourceClientArgs) Validate() error {
if (args.User != User{}) && args.ServiceAccountToken != "" {
return fmt.Errorf("cannot provide both User and ServiceAccountToken")
}
if (args.User == User{}) && args.ServiceAccountToken == "" {
return fmt.Errorf("must provide either User or ServiceAccountToken")
}
return nil
}
type K8sResourceClient struct {
@@ -168,12 +182,33 @@ type K8sResourceClient struct {
func (c *K8sTestHelper) GetResourceClient(args ResourceClientArgs) *K8sResourceClient {
c.t.Helper()
// Validate that either User or ServiceAccountToken is provided, but not both
err := args.Validate()
require.NoError(c.t, err)
if args.Namespace == "" {
args.Namespace = c.Namespacer(args.User.Identity.GetOrgID())
if args.User != (User{}) {
args.Namespace = c.Namespacer(args.User.Identity.GetOrgID())
} else {
// For service account token, we need to pass the namespace directly
require.NotEmpty(c.t, args.Namespace, "Namespace must be provided when using ServiceAccountToken")
}
}
client, err := dynamic.NewForConfig(args.User.NewRestConfig())
require.NoError(c.t, err)
var client dynamic.Interface
var clientErr error
if args.User != (User{}) {
client, clientErr = dynamic.NewForConfig(args.User.NewRestConfig())
} else {
// Use service account token for authentication
cfg := &rest.Config{
Host: fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr()),
BearerToken: args.ServiceAccountToken,
}
client, clientErr = dynamic.NewForConfig(cfg)
}
require.NoError(c.t, clientErr)
return &K8sResourceClient{
t: c.t,
@@ -269,6 +304,14 @@ type OrgUsers struct {
Editor User
Viewer User
// Separate standalone service accounts with different roles
AdminServiceAccount serviceaccounts.ServiceAccountDTO
AdminServiceAccountToken string
EditorServiceAccount serviceaccounts.ServiceAccountDTO
EditorServiceAccountToken string
ViewerServiceAccount serviceaccounts.ServiceAccountDTO
ViewerServiceAccountToken string
// The team with admin+editor in it (but not viewer)
Staff team.Team
}
@@ -488,6 +531,16 @@ func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers {
Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil),
}
// Create service accounts
users.AdminServiceAccount = c.CreateServiceAccount(users.Admin, "admin-sa", users.Admin.Identity.GetOrgID(), org.RoleAdmin)
users.AdminServiceAccountToken = c.CreateServiceAccountToken(users.Admin, users.AdminServiceAccount.Id, users.Admin.Identity.GetOrgID(), "admin-token", 0)
users.EditorServiceAccount = c.CreateServiceAccount(users.Admin, "editor-sa", users.Admin.Identity.GetOrgID(), org.RoleEditor)
users.EditorServiceAccountToken = c.CreateServiceAccountToken(users.Admin, users.EditorServiceAccount.Id, users.Admin.Identity.GetOrgID(), "editor-token", 0)
users.ViewerServiceAccount = c.CreateServiceAccount(users.Admin, "viewer-sa", users.Admin.Identity.GetOrgID(), org.RoleViewer)
users.ViewerServiceAccountToken = c.CreateServiceAccountToken(users.Admin, users.ViewerServiceAccount.Id, users.Admin.Identity.GetOrgID(), "viewer-token", 0)
users.Staff = c.CreateTeam("staff", "staff@"+orgName, users.Admin.Identity.GetOrgID())
// Add Admin and Editor to Staff team as Admin and Member, respectively.
@@ -744,3 +797,91 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
}
})
}
// CreateServiceAccount creates a service account with the specified name, organization, and role using the HTTP API
func (c *K8sTestHelper) CreateServiceAccount(executingUser User, name string, orgID int64, role org.RoleType) serviceaccounts.ServiceAccountDTO {
c.t.Helper()
saForm := struct {
Name string `json:"name"`
Role org.RoleType `json:"role"`
IsDisabled bool `json:"isDisabled"`
}{
Name: name,
Role: role,
IsDisabled: false,
}
body, err := json.Marshal(saForm)
require.NoError(c.t, err)
resp := DoRequest(c, RequestParams{
User: executingUser,
Method: http.MethodPost,
Path: "/api/serviceaccounts/",
Body: body,
}, &serviceaccounts.ServiceAccountDTO{})
require.Equal(c.t, http.StatusCreated, resp.Response.StatusCode, "failed to create service account, body: %s", string(resp.Body))
require.NotNil(c.t, resp.Result, "failed to parse response body: %s", string(resp.Body))
return *resp.Result
}
// CreateServiceAccountToken creates a token for the specified service account using the HTTP API
func (c *K8sTestHelper) CreateServiceAccountToken(user User, saID int64, orgID int64, tokenName string, secondsToLive int64) string {
c.t.Helper()
tokenCmd := struct {
Name string `json:"name"`
SecondsToLive int64 `json:"secondsToLive"`
}{
Name: tokenName,
SecondsToLive: secondsToLive,
}
body, err := json.Marshal(tokenCmd)
require.NoError(c.t, err)
resp := DoRequest(c, RequestParams{
User: user,
Method: http.MethodPost,
Path: fmt.Sprintf("/api/serviceaccounts/%d/tokens", saID),
Body: body,
}, &struct {
ID int64 `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
}{})
require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to create token, body: %s", string(resp.Body))
require.NotNil(c.t, resp.Result, "failed to parse response body: %s", string(resp.Body))
return resp.Result.Key
}
// DeleteServiceAccountToken deletes a token for the specified service account using the HTTP API
func (c *K8sTestHelper) DeleteServiceAccountToken(user User, orgID int64, saID int64, tokenID int64) {
c.t.Helper()
resp := DoRequest(c, RequestParams{
User: user,
Method: http.MethodDelete,
Path: fmt.Sprintf("/api/serviceaccounts/%d/tokens/%d", saID, tokenID),
}, &struct{}{})
require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to delete token, body: %s", string(resp.Body))
}
// DeleteServiceAccount deletes a service account for the specified organization and ID using the HTTP API
func (c *K8sTestHelper) DeleteServiceAccount(user User, orgID int64, saID int64) {
c.t.Helper()
resp := DoRequest(c, RequestParams{
User: user,
Method: http.MethodDelete,
Path: fmt.Sprintf("/api/serviceaccounts/%d", saID),
}, &struct{}{})
require.Equal(c.t, http.StatusOK, resp.Response.StatusCode, "failed to delete service account, body: %s", string(resp.Body))
}