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