diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index bd2eeb9e661..4e077e08d9d 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -65,7 +65,8 @@ type DashboardProvisioningService interface { GetProvisionedDashboardData(ctx context.Context, name string) ([]*DashboardProvisioning, error) GetProvisionedDashboardDataByDashboardID(ctx context.Context, dashboardID int64) (*DashboardProvisioning, error) GetProvisionedDashboardDataByDashboardUID(ctx context.Context, orgID int64, dashboardUID string) (*DashboardProvisioning, error) - SaveFolderForProvisionedDashboards(ctx context.Context, cmd *folder.CreateFolderCommand, readerName string) (*folder.Folder, error) + SaveFolderForProvisionedDashboards(ctx context.Context, cmd *folder.CreateFolderCommand, managerIdentity string) (*folder.Folder, error) + UpdateFolderWithManagedByAnnotation(ctx context.Context, folder *folder.Folder, managerIdentity string) (*folder.Folder, error) SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *DashboardProvisioning) (*Dashboard, error) UnprovisionDashboard(ctx context.Context, dashboardID int64) error } diff --git a/pkg/services/dashboards/dashboard_provisioning_mock.go b/pkg/services/dashboards/dashboard_provisioning_mock.go index eca2c66306f..65521984741 100644 --- a/pkg/services/dashboards/dashboard_provisioning_mock.go +++ b/pkg/services/dashboards/dashboard_provisioning_mock.go @@ -5,9 +5,8 @@ package dashboards import ( context "context" - mock "github.com/stretchr/testify/mock" - folder "github.com/grafana/grafana/pkg/services/folder" + mock "github.com/stretchr/testify/mock" ) // FakeDashboardProvisioning is an autogenerated mock type for the DashboardProvisioningService type @@ -141,9 +140,9 @@ func (_m *FakeDashboardProvisioning) GetProvisionedDashboardDataByDashboardUID(c return r0, r1 } -// SaveFolderForProvisionedDashboards provides a mock function with given fields: _a0, _a1 -func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 context.Context, _a1 *folder.CreateFolderCommand, _ string) (*folder.Folder, error) { - ret := _m.Called(_a0, _a1) +// SaveFolderForProvisionedDashboards provides a mock function with given fields: ctx, cmd, readerName +func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(ctx context.Context, cmd *folder.CreateFolderCommand, readerName string) (*folder.Folder, error) { + ret := _m.Called(ctx, cmd, readerName) if len(ret) == 0 { panic("no return value specified for SaveFolderForProvisionedDashboards") @@ -151,19 +150,19 @@ func (_m *FakeDashboardProvisioning) SaveFolderForProvisionedDashboards(_a0 cont var r0 *folder.Folder var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) (*folder.Folder, error)); ok { - return rf(_a0, _a1) + if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand, string) (*folder.Folder, error)); ok { + return rf(ctx, cmd, readerName) } - if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand) *folder.Folder); ok { - r0 = rf(_a0, _a1) + if rf, ok := ret.Get(0).(func(context.Context, *folder.CreateFolderCommand, string) *folder.Folder); ok { + r0 = rf(ctx, cmd, readerName) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*folder.Folder) } } - if rf, ok := ret.Get(1).(func(context.Context, *folder.CreateFolderCommand) error); ok { - r1 = rf(_a0, _a1) + if rf, ok := ret.Get(1).(func(context.Context, *folder.CreateFolderCommand, string) error); ok { + r1 = rf(ctx, cmd, readerName) } else { r1 = ret.Error(1) } @@ -219,6 +218,36 @@ func (_m *FakeDashboardProvisioning) UnprovisionDashboard(ctx context.Context, d return r0 } +// UpdateFolderWithManagedByAnnotation provides a mock function with given fields: ctx, _a1, readerName +func (_m *FakeDashboardProvisioning) UpdateFolderWithManagedByAnnotation(ctx context.Context, _a1 *folder.Folder, readerName string) (*folder.Folder, error) { + ret := _m.Called(ctx, _a1, readerName) + + if len(ret) == 0 { + panic("no return value specified for UpdateFolderWithManagedByAnnotation") + } + + var r0 *folder.Folder + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *folder.Folder, string) (*folder.Folder, error)); ok { + return rf(ctx, _a1, readerName) + } + if rf, ok := ret.Get(0).(func(context.Context, *folder.Folder, string) *folder.Folder); ok { + r0 = rf(ctx, _a1, readerName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*folder.Folder) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *folder.Folder, string) error); ok { + r1 = rf(ctx, _a1, readerName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // NewFakeDashboardProvisioning creates a new instance of FakeDashboardProvisioning. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewFakeDashboardProvisioning(t interface { diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 193d5566eae..7d329290b88 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -990,6 +990,27 @@ func (dr *DashboardServiceImpl) SaveFolderForProvisionedDashboards(ctx context.C return f, nil } +// UpdateFolderWithManagedByAnnotation implements dashboards.DashboardProvisioningService. +func (dr *DashboardServiceImpl) UpdateFolderWithManagedByAnnotation(ctx context.Context, f *folder.Folder, readerName string) (*folder.Folder, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.UpdateFolderWithManagedByAnnotation") + defer span.End() + + ctx, ident := identity.WithServiceIdentity(ctx, f.OrgID) + updated, err := dr.folderService.Update(ctx, &folder.UpdateFolderCommand{ + UID: f.UID, + OrgID: f.OrgID, + SignedInUser: ident, + ManagerKindClassicFP: readerName, // nolint:staticcheck + Overwrite: true, + Version: f.Version, + }) + if err != nil { + dr.log.Error("failed to update folder for provisioned dashboards", "folder", f.Title, "org", f.OrgID, "err", err) + return nil, err + } + return updated, nil +} + func (dr *DashboardServiceImpl) SaveDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO, allowUiUpdate bool) (*dashboards.Dashboard, error) { ctx, span := tracer.Start(ctx, "dashboards.service.SaveDashboard") diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 74259652fbf..4a3e0d15d90 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -5,7 +5,6 @@ package dashboards import ( context "context" - quota "github.com/grafana/grafana/pkg/services/quota" mock "github.com/stretchr/testify/mock" ) @@ -32,36 +31,6 @@ func (_m *FakeDashboardStore) CleanupAfterDelete(ctx context.Context, cmd *Delet return r0 } -// Count provides a mock function with given fields: _a0, _a1 -func (_m *FakeDashboardStore) Count(_a0 context.Context, _a1 *quota.ScopeParameters) (*quota.Map, error) { - ret := _m.Called(_a0, _a1) - - if len(ret) == 0 { - panic("no return value specified for Count") - } - - var r0 *quota.Map - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, *quota.ScopeParameters) (*quota.Map, error)); ok { - return rf(_a0, _a1) - } - if rf, ok := ret.Get(0).(func(context.Context, *quota.ScopeParameters) *quota.Map); ok { - r0 = rf(_a0, _a1) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*quota.Map) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, *quota.ScopeParameters) error); ok { - r1 = rf(_a0, _a1) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // 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) diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 30aea5f2985..f9f7af3b8ea 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -562,14 +562,15 @@ func (s *Service) updateOnApiServer(ctx context.Context, cmd *folder.UpdateFolde user := cmd.SignedInUser - foldr, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ - UID: cmd.UID, - OrgID: cmd.OrgID, - NewTitle: cmd.NewTitle, - NewDescription: cmd.NewDescription, - SignedInUser: user, - Overwrite: cmd.Overwrite, - Version: cmd.Version, + folder, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + UID: cmd.UID, + OrgID: cmd.OrgID, + NewTitle: cmd.NewTitle, + NewDescription: cmd.NewDescription, + SignedInUser: user, + Overwrite: cmd.Overwrite, + Version: cmd.Version, + ManagerKindClassicFP: cmd.ManagerKindClassicFP, // nolint:staticcheck }) if err != nil { @@ -579,7 +580,7 @@ func (s *Service) updateOnApiServer(ctx context.Context, cmd *folder.UpdateFolde if cmd.NewTitle != nil { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() - if err := s.publishFolderFullPathUpdatedEventViaApiServer(ctx, foldr.Updated, cmd.OrgID, cmd.UID); err != nil { + if err := s.publishFolderFullPathUpdatedEventViaApiServer(ctx, folder.Updated, cmd.OrgID, cmd.UID); err != nil { return nil, err } } @@ -587,7 +588,7 @@ func (s *Service) updateOnApiServer(ctx context.Context, cmd *folder.UpdateFolde // always expose the dashboard store sequential ID metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() - return foldr, nil + return folder, nil } func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFolderCommand) error { diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go index 8f062d722f9..c3dd6347b11 100644 --- a/pkg/services/folder/folderimpl/unifiedstore.go +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -97,6 +97,10 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF return nil, err } updated := obj.DeepCopy() + meta, err := utils.MetaAccessor(updated) + if err != nil { + return nil, err + } if cmd.NewTitle != nil { err = unstructured.SetNestedField(updated.Object, *cmd.NewTitle, "spec", "title") @@ -111,10 +115,6 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF } } if cmd.NewParentUID != nil { - meta, err := utils.MetaAccessor(updated) - if err != nil { - return nil, err - } meta.SetFolder(*cmd.NewParentUID) } else { // only compare versions if not moving the folder @@ -123,6 +123,14 @@ func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateF } } + // nolint:staticcheck + if cmd.ManagerKindClassicFP != "" { + meta.SetManagerProperties(utils.ManagerProperties{ + Kind: utils.ManagerKindClassicFP, + Identity: cmd.ManagerKindClassicFP, + }) + } + out, err := ss.k8sclient.Update(ctx, updated, cmd.OrgID, v1.UpdateOptions{ FieldValidation: v1.FieldValidationIgnore, }) diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index 3eeedcb2e08..5dbfa4e945c 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -158,6 +158,13 @@ type UpdateFolderCommand struct { Overwrite bool `json:"overwrite"` SignedInUser identity.Requester `json:"-"` + + // When running classic file provisioning with folders saved in kubernetes, + // folders will be marked with a manager of kind ManagerKindClassicFP + // NOTE: this is ignored when running legacy SQL storage + // + // Deprecated: this should only be used by the legacy file provisioning system + ManagerKindClassicFP string `json:"-"` } // MoveFolderCommand captures the information required by the folder service diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 061a5f3f60d..e0c24c6ab07 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -6,7 +6,8 @@ import ( "os" "time" - dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" + dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" + folderV1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -57,8 +58,17 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das return nil, fmt.Errorf("%v: %w", "Failed to initialize file readers", err) } - if dual != nil && !dual.ShouldManage(dashboard.DashboardResourceInfo.GroupResource()) { - dual = nil // not actively managed + if dual != nil { + foldersInUnified, _ := dual.ReadFromUnified(context.Background(), folderV1.FolderResourceInfo.GroupResource()) + if foldersInUnified { + for _, reader := range fileReaders { + reader.foldersInUnified = true + } + } + + if !dual.ShouldManage(dashboardV1.DashboardResourceInfo.GroupResource()) { + dual = nil // not actively managed + } } d := &Provisioner{ @@ -78,7 +88,7 @@ func New(ctx context.Context, configDirectory string, provisioner dashboards.Das func (provider *Provisioner) Provision(ctx context.Context) error { // skip provisioning during migrations to prevent multi-replica instances from crashing when another replica is migrating if provider.dual != nil { - status, _ := provider.dual.Status(context.Background(), dashboard.DashboardResourceInfo.GroupResource()) + status, _ := provider.dual.Status(context.Background(), dashboardV1.DashboardResourceInfo.GroupResource()) if status.Migrating > 0 { provider.log.Info("dashboard migrations are running, skipping provisioning", "elapsed", time.Since(time.UnixMilli(status.Migrating))) return nil diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index e5de306f9f1..73cd50fef90 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -40,6 +40,7 @@ type FileReader struct { dashboardStore utils.DashboardStore FoldersFromFilesStructure bool folderService folder.Service + foldersInUnified bool mux sync.RWMutex usageTracker *usageTracker @@ -382,6 +383,14 @@ func (fr *FileReader) getOrCreateFolder(ctx context.Context, cfg *config, servic return 0, "", dashboards.ErrFolderInvalidUID } + // When we expect folders in unified storage, they should have a manager indicated + if err == nil && result != nil && result.ManagedBy == "" && fr.foldersInUnified { + result, err = service.UpdateFolderWithManagedByAnnotation(ctx, result, fr.Cfg.Name) + if err != nil { + return 0, "", fmt.Errorf("unable to update provisioned folder") + } + } + // dashboard folder not found. create one. if errors.Is(err, dashboards.ErrFolderNotFound) { createCmd := &folder.CreateFolderCommand{ diff --git a/pkg/services/provisioning/dashboards/file_reader_test.go b/pkg/services/provisioning/dashboards/file_reader_test.go index 798d6e62d5a..631b11bbc51 100644 --- a/pkg/services/provisioning/dashboards/file_reader_test.go +++ b/pkg/services/provisioning/dashboards/file_reader_test.go @@ -145,7 +145,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) { cfg.Folder = "Team A" fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once() - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{ID: 1}, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{ID: 1}, nil).Once() fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{ID: 2}, nil).Times(2) reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc) reader.dashboardProvisioningService = fakeService @@ -324,7 +324,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) { cfg.Options["foldersFromFilesStructure"] = true fakeService.On("GetProvisionedDashboardData", mock.Anything, configName).Return(nil, nil).Once() - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, configName).Return(&folder.Folder{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(3) reader, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc) @@ -362,7 +362,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) { cfg2 := &config{Name: "2", Type: "file", OrgID: 1, Folder: "f2", Options: map[string]any{"path": containingID}} fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return(nil, nil).Times(2) - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(2) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(2) reader1, err := NewDashboardFileReader(cfg1, logger, nil, fakeStore, folderSvc) @@ -410,7 +410,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) { "folder": defaultDashboards, }, } - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{ID: 1}, nil).Once() + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, cfg.Name).Return(&folder.Folder{ID: 1}, nil).Once() r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc) require.NoError(t, err) diff --git a/pkg/services/provisioning/dashboards/validator_test.go b/pkg/services/provisioning/dashboards/validator_test.go index e5346bed303..d74fda1ff8d 100644 --- a/pkg/services/provisioning/dashboards/validator_test.go +++ b/pkg/services/provisioning/dashboards/validator_test.go @@ -66,7 +66,7 @@ func TestIntegrationDuplicatesValidator(t *testing.T) { fakeStore := &fakeDashboardStore{} r, err := NewDashboardFileReader(cfg, logger, nil, fakeStore, folderSvc) require.NoError(t, err) - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(6) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(6) fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return([]*dashboards.DashboardProvisioning{}, nil).Times(4) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5) _, folderUID, err := r.getOrCreateFolder(ctx, cfg, fakeService, folderName) @@ -183,7 +183,7 @@ func TestIntegrationDuplicatesValidator(t *testing.T) { }) t.Run("Duplicates validator should restrict write access only for readers with duplicates", func(t *testing.T) { - fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(5) + fakeService.On("SaveFolderForProvisionedDashboards", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{}, nil).Times(5) fakeService.On("GetProvisionedDashboardData", mock.Anything, mock.AnythingOfType("string")).Return([]*dashboards.DashboardProvisioning{}, nil).Times(3) fakeService.On("SaveProvisionedDashboard", mock.Anything, mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil).Times(5) fakeStore := &fakeDashboardStore{} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 3f831f9d4a3..b40b6136a46 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -88,6 +88,7 @@ func ProvideService( resourcePermissions: resourcePermissions, tracer: tracer, migratePrometheusType: promTypeMigrationProvider.Run, + dual: dual, } if err := s.setDashboardProvisioner(); err != nil {