fix: delete subfolder dangling panels (#113419)
* fix: delete subfolder dangling panels and error if used * chore: add observation about library panel DeleteInFolders - logs folders UIDs on DeleteInFolders error * chore: add integration test for blocking library panel deletion and handling dangling library panels * chore: fix integration test on mode 4 and 5
This commit is contained in:
+3
-14
@@ -271,26 +271,15 @@ func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response
|
||||
// 403: forbiddenError
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
|
||||
err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
|
||||
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response {
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
err := hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) {
|
||||
return response.Error(http.StatusForbidden, "Folder could not be deleted because it contains library elements in use", err)
|
||||
}
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
/* TODO: after a decision regarding folder deletion permissions has been made
|
||||
(https://github.com/grafana/grafana-enterprise/issues/5144),
|
||||
remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder
|
||||
and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
|
||||
Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
|
||||
*/
|
||||
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
|
||||
if err != nil {
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, util.DynMap{
|
||||
"message": "Folder deleted",
|
||||
|
||||
@@ -644,6 +644,21 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol
|
||||
return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder)
|
||||
}
|
||||
|
||||
libraryPanelSrv, ok := s.registry[entity.StandardKindLibraryPanel]
|
||||
if !ok {
|
||||
return folder.ErrInternal.Errorf("no library panel service found in registry")
|
||||
}
|
||||
// /* TODO: after a decision regarding folder deletion permissions has been made
|
||||
// (https://github.com/grafana/grafana-enterprise/issues/5144),
|
||||
// remove the following call to DeleteInFolders
|
||||
// and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
|
||||
// Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
|
||||
// */
|
||||
// Obs: DeleteInFolders only deletes dangling library panels (not linked to any dashboard) and throws errors if there are connections
|
||||
if err := libraryPanelSrv.DeleteInFolders(ctx, cmd.OrgID, folders, cmd.SignedInUser); err != nil {
|
||||
s.log.Error("failed to delete dangling library panels in folders", "error", err, "folders", strings.Join(folders, ","))
|
||||
return err
|
||||
}
|
||||
// We need a list of dashboard uids inside the folder to delete related dashboards & public dashboards -
|
||||
// we cannot use the dashboard service directly due to circular dependencies, so use the search client to get the dashboards
|
||||
request := &resourcepb.ResourceSearchRequest{
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
clientrest "k8s.io/client-go/rest"
|
||||
|
||||
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
@@ -33,6 +34,9 @@ import (
|
||||
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/folder/foldertest"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/search/model"
|
||||
@@ -209,6 +213,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
{Action: dashboards.ActionFoldersDelete, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: accesscontrol.ActionAlertingRuleDelete, Scope: dashboards.ScopeFoldersAll},
|
||||
{Action: accesscontrol.ActionLibraryPanelsDelete, Scope: dashboards.ScopeFoldersAll},
|
||||
}),
|
||||
}}
|
||||
|
||||
@@ -219,6 +224,16 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
|
||||
}
|
||||
|
||||
mockDashboardService := dashboards.NewFakeDashboardService(t)
|
||||
mockFolderService := foldertest.NewFakeService()
|
||||
elementService := libraryelements.ProvideService(cfg, db, routing.NewRouteRegister(), mockFolderService, featuremgmt.WithFeatures(), actest.FakeAccessControl{ExpectedEvaluate: true}, mockDashboardService, nil, nil)
|
||||
lps := librarypanels.LibraryPanelService{
|
||||
Cfg: cfg,
|
||||
SQLStore: db,
|
||||
LibraryElementService: elementService,
|
||||
FolderService: mockFolderService,
|
||||
}
|
||||
|
||||
publicDashboardService := publicdashboards.NewFakePublicDashboardServiceWrapper(t)
|
||||
|
||||
fakeK8sClient := new(client.MockK8sHandler)
|
||||
@@ -237,6 +252,7 @@ func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
require.NoError(t, folderService.RegisterService(alertingStore))
|
||||
require.NoError(t, folderService.RegisterService(lps))
|
||||
|
||||
t.Run("Folder service tests", func(t *testing.T) {
|
||||
t.Run("Given user has no permissions", func(t *testing.T) {
|
||||
@@ -877,6 +893,17 @@ func TestIntegrationDeleteFoldersFromApiServer(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, service.RegisterService(alertingStore))
|
||||
|
||||
mockDashboardService := dashboards.NewFakeDashboardService(t)
|
||||
mockFolderService := foldertest.NewFakeService()
|
||||
elementService := libraryelements.ProvideService(cfg, db, routing.NewRouteRegister(), mockFolderService, featuremgmt.WithFeatures(), actest.FakeAccessControl{ExpectedEvaluate: true}, mockDashboardService, nil, nil)
|
||||
lps := librarypanels.LibraryPanelService{
|
||||
Cfg: cfg,
|
||||
SQLStore: db,
|
||||
LibraryElementService: elementService,
|
||||
FolderService: mockFolderService,
|
||||
}
|
||||
require.NoError(t, service.RegisterService(lps))
|
||||
|
||||
t.Run("Should delete folder", func(t *testing.T) {
|
||||
publicDashboardFakeService.On("DeleteByDashboardUIDs", mock.Anything, int64(1), []string{}).Return(nil).Once()
|
||||
dashboardK8sclient.On("Search", mock.Anything, int64(1), mock.Anything).Return(&resourcepb.ResourceSearchResponse{Results: &resourcepb.ResourceTable{}}, nil).Once()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
@@ -1426,6 +1427,287 @@ func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// Test folder deletion with connected (in-use) library panels - should be blocked
|
||||
func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
if !db.IsTestDbSQLite() {
|
||||
t.Skip("test only on sqlite for now")
|
||||
}
|
||||
|
||||
for mode := 0; mode <= 5; mode++ {
|
||||
t.Run(fmt.Sprintf("mode %v - delete blocked by connected library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
|
||||
modeDw := grafanarest.DualWriterMode(mode)
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
folders.RESOURCEGROUP: {
|
||||
DualWriterMode: modeDw,
|
||||
},
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: modeDw,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
})
|
||||
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Create parent and child folders
|
||||
uid := uuid.NewString()[:8]
|
||||
parentUID := fmt.Sprintf("connected-parent-%d-%s", mode, uid)
|
||||
childUID := fmt.Sprintf("connected-child-%d-%s", mode, uid)
|
||||
createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "")
|
||||
createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID)
|
||||
|
||||
// Create library panels in both folders
|
||||
parentLibPanelName := fmt.Sprintf("Connected LP in parent %d-%s", mode, uid)
|
||||
childLibPanelName := fmt.Sprintf("Connected LP in child %d-%s", mode, uid)
|
||||
parentLibPanelUID := createTestLibraryPanel(t, helper, client, parentLibPanelName, parentUID)
|
||||
childLibPanelUID := createTestLibraryPanel(t, helper, client, childLibPanelName, childUID)
|
||||
|
||||
// Create dashboards using library panels (makes them connected)
|
||||
parentDashUID := createDashboardWithLibraryPanel(t, helper, client,
|
||||
fmt.Sprintf("Dashboard with LP in parent %d-%s", mode, uid),
|
||||
parentLibPanelUID, "Connected LP in parent", parentUID)
|
||||
childDashUID := createDashboardWithLibraryPanel(t, helper, client,
|
||||
fmt.Sprintf("Dashboard with LP in child %d-%s", mode, uid),
|
||||
childLibPanelUID, "Connected LP in child", childUID)
|
||||
|
||||
// Attempt to delete the parent folder - should be blocked because library panels are connected
|
||||
parentDelete := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/folders/" + parentUID,
|
||||
}, &folder.Folder{})
|
||||
require.Equal(t, http.StatusForbidden, parentDelete.Response.StatusCode)
|
||||
|
||||
// Verify both folders still exist
|
||||
_, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{})
|
||||
require.NoError(t, getParentErr, "parent folder should still exist after failed deletion")
|
||||
_, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{})
|
||||
require.NoError(t, getChildErr, "child folder should still exist after failed deletion")
|
||||
|
||||
// Verify library panels still exist
|
||||
verifyLibraryPanelExists(t, helper, client, parentLibPanelUID)
|
||||
verifyLibraryPanelExists(t, helper, client, childLibPanelUID)
|
||||
|
||||
// Verify dashboards still exist
|
||||
verifyDashboardExists(t, helper, client, parentDashUID)
|
||||
verifyDashboardExists(t, helper, client, childDashUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test folder deletion with dangling (unconnected) library panels - should succeed and clean up
|
||||
func TestIntegrationFolderDeletionWithDanglingLibraryPanels(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
if !db.IsTestDbSQLite() {
|
||||
t.Skip("test only on sqlite for now")
|
||||
}
|
||||
|
||||
for mode := 0; mode <= 5; mode++ {
|
||||
t.Run(fmt.Sprintf("mode %v - delete succeeds and cleans up dangling library panels in folder and subfolder", grafanarest.DualWriterMode(mode)), func(t *testing.T) {
|
||||
modeDw := grafanarest.DualWriterMode(mode)
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
folders.RESOURCEGROUP: {
|
||||
DualWriterMode: modeDw,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
})
|
||||
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Create parent and child folders
|
||||
uid := uuid.NewString()[:8]
|
||||
parentUID := fmt.Sprintf("dangling-parent-%d-%s", mode, uid)
|
||||
childUID := fmt.Sprintf("dangling-child-%d-%s", mode, uid)
|
||||
createTestFolder(t, helper, client, parentUID, fmt.Sprintf("Parent Folder %d-%s", mode, uid), "")
|
||||
createTestFolder(t, helper, client, childUID, fmt.Sprintf("Child Folder %d-%s", mode, uid), parentUID)
|
||||
|
||||
// Create dangling library panels in both folders (not connected to any dashboard)
|
||||
parentLibPanelUID := createTestLibraryPanel(t, helper, client,
|
||||
fmt.Sprintf("Dangling LP in parent %d-%s", mode, uid), parentUID)
|
||||
childLibPanelUID := createTestLibraryPanel(t, helper, client,
|
||||
fmt.Sprintf("Dangling LP in child %d-%s", mode, uid), childUID)
|
||||
|
||||
// Verify library panels exist before deletion
|
||||
verifyLibraryPanelExists(t, helper, client, parentLibPanelUID)
|
||||
verifyLibraryPanelExists(t, helper, client, childLibPanelUID)
|
||||
|
||||
// Attempt to delete the parent folder - should be blocked because library panels are connected
|
||||
parentDelete := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/folders/" + parentUID,
|
||||
}, &folder.Folder{})
|
||||
require.Equal(t, http.StatusOK, parentDelete.Response.StatusCode, parentDelete.Body)
|
||||
// Verify folders are deleted
|
||||
_, getParentErr := client.Resource.Get(context.Background(), parentUID, metav1.GetOptions{})
|
||||
require.Error(t, getParentErr, "parent folder should not exist after deletion")
|
||||
_, getChildErr := client.Resource.Get(context.Background(), childUID, metav1.GetOptions{})
|
||||
require.Error(t, getChildErr, "child folder should not exist after deletion")
|
||||
|
||||
// Verify dangling library panels were cleaned up
|
||||
verifyLibraryPanelDeleted(t, helper, client, parentLibPanelUID, "dangling library panel in parent should be deleted")
|
||||
verifyLibraryPanelDeleted(t, helper, client, childLibPanelUID, "dangling library panel in child should be deleted")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a folder with specified UID and optional parent
|
||||
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, uid, title, parentUID string) *folder.Folder {
|
||||
t.Helper()
|
||||
|
||||
payload := fmt.Sprintf(`{
|
||||
"title": "%s",
|
||||
"uid": "%s"`, title, uid)
|
||||
|
||||
if parentUID != "" {
|
||||
payload += fmt.Sprintf(`,
|
||||
"parentUid": "%s"`, parentUID)
|
||||
}
|
||||
|
||||
payload += "}"
|
||||
|
||||
folderCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/folders",
|
||||
Body: []byte(payload),
|
||||
}, &folder.Folder{})
|
||||
|
||||
require.NotNil(t, folderCreate.Result)
|
||||
require.Equal(t, uid, folderCreate.Result.UID)
|
||||
|
||||
return folderCreate.Result
|
||||
}
|
||||
|
||||
// Helper function to create a library panel in a folder
|
||||
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, name, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
libPanelPayload := fmt.Sprintf(`{
|
||||
"kind": 1,
|
||||
"name": "%s",
|
||||
"folderUid": "%s",
|
||||
"model": {
|
||||
"type": "text",
|
||||
"title": "%s"
|
||||
}
|
||||
}`, name, folderUID, name)
|
||||
|
||||
libCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/library-elements",
|
||||
Body: []byte(libPanelPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, libCreate.Response)
|
||||
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
|
||||
|
||||
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
|
||||
require.NotEmpty(t, libPanelUID)
|
||||
|
||||
return libPanelUID
|
||||
}
|
||||
|
||||
// Helper function to create a dashboard that uses a library panel
|
||||
func createDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashTitle, libPanelUID, libPanelName, folderUID string) string {
|
||||
t.Helper()
|
||||
|
||||
dashPayload := fmt.Sprintf(`{
|
||||
"dashboard": {
|
||||
"title": "%s",
|
||||
"panels": [{
|
||||
"id": 1,
|
||||
"libraryPanel": {
|
||||
"uid": "%s",
|
||||
"name": "%s"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"folderUid": "%s",
|
||||
"overwrite": false
|
||||
}`, dashTitle, libPanelUID, libPanelName, folderUID)
|
||||
|
||||
dashCreate := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/dashboards/db",
|
||||
Body: []byte(dashPayload),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.NotNil(t, dashCreate.Response)
|
||||
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
|
||||
|
||||
// Extract dashboard UID from response
|
||||
dashUID := (*dashCreate.Result)["uid"].(string)
|
||||
require.NotEmpty(t, dashUID)
|
||||
|
||||
return dashUID
|
||||
}
|
||||
|
||||
// Helper function to verify library panel exists
|
||||
func verifyLibraryPanelExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID string) {
|
||||
t.Helper()
|
||||
|
||||
libGet := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodGet,
|
||||
Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.Equal(t, http.StatusOK, libGet.Response.StatusCode)
|
||||
}
|
||||
|
||||
// Helper function to verify library panel does not exist
|
||||
func verifyLibraryPanelDeleted(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, libPanelUID, message string) {
|
||||
t.Helper()
|
||||
|
||||
libGet := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodGet,
|
||||
Path: fmt.Sprintf("/api/library-elements/%s", libPanelUID),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.Equal(t, http.StatusNotFound, libGet.Response.StatusCode, message)
|
||||
}
|
||||
|
||||
// Helper function to verify dashboard exists by UID
|
||||
func verifyDashboardExists(t *testing.T, helper *apis.K8sTestHelper, client *apis.K8sResourceClient, dashUID string) {
|
||||
t.Helper()
|
||||
|
||||
dashGet := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: client.Args.User,
|
||||
Method: http.MethodGet,
|
||||
Path: fmt.Sprintf("/api/dashboards/uid/%s", dashUID),
|
||||
}, &map[string]interface{}{})
|
||||
|
||||
require.Equal(t, http.StatusOK, dashGet.Response.StatusCode, fmt.Sprintf("dashboard %s should still exist", dashUID))
|
||||
}
|
||||
|
||||
// Test moving folders to root.
|
||||
func TestIntegrationMoveNestedFolderToRootK8S(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
Reference in New Issue
Block a user