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:
Rafael Bortolon Paulovic
2025-11-06 13:56:32 +01:00
committed by GitHub
parent fd14d4a5ed
commit 7b3145a3c1
4 changed files with 327 additions and 14 deletions
+3 -14
View File
@@ -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()
+282
View File
@@ -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)