K8s: Folders: Add pagination for children (#100978)
This commit is contained in:
committed by
GitHub
parent
ff1b22297c
commit
95278d7552
@@ -33,6 +33,8 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const folderSearchLimit = 100000
|
||||
|
||||
func (s *Service) getFoldersFromApiServer(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
|
||||
if q.SignedInUser == nil {
|
||||
return nil, folder.ErrBadRequest.Errorf("missing signed in user")
|
||||
@@ -171,7 +173,7 @@ func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.S
|
||||
Fields: []*resource.Requirement{},
|
||||
Labels: []*resource.Requirement{},
|
||||
},
|
||||
Limit: 100000}
|
||||
Limit: folderSearchLimit}
|
||||
|
||||
if len(query.UIDs) > 0 {
|
||||
request.Options.Fields = []*resource.Requirement{{
|
||||
@@ -269,7 +271,7 @@ func (s *Service) getFolderByIDFromApiServer(ctx context.Context, id int64, orgI
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: 100000}
|
||||
Limit: folderSearchLimit}
|
||||
|
||||
res, err := s.k8sclient.Search(ctx, orgID, request)
|
||||
if err != nil {
|
||||
@@ -322,7 +324,7 @@ func (s *Service) getFolderByTitleFromApiServer(ctx context.Context, orgID int64
|
||||
},
|
||||
Labels: []*resource.Requirement{},
|
||||
},
|
||||
Limit: 100000}
|
||||
Limit: folderSearchLimit}
|
||||
|
||||
if parentUID != nil {
|
||||
req := []*resource.Requirement{{
|
||||
@@ -671,7 +673,7 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: 100000}
|
||||
Limit: folderSearchLimit}
|
||||
|
||||
res, err := s.dashboardK8sClient.Search(ctx, cmd.OrgID, request)
|
||||
if err != nil {
|
||||
|
||||
@@ -554,7 +554,7 @@ func TestSearchFoldersFromApiServer(t *testing.T) {
|
||||
},
|
||||
Labels: []*resource.Requirement{},
|
||||
},
|
||||
Limit: 100000}).Return(&resource.ResourceSearchResponse{
|
||||
Limit: folderSearchLimit}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
@@ -649,7 +649,7 @@ func TestSearchFoldersFromApiServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: 100000}).Return(&resource.ResourceSearchResponse{
|
||||
Limit: folderSearchLimit}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
@@ -716,7 +716,7 @@ func TestSearchFoldersFromApiServer(t *testing.T) {
|
||||
},
|
||||
Query: "*test*",
|
||||
Fields: dashboardsearch.IncludeFields,
|
||||
Limit: 100000}).Return(&resource.ResourceSearchResponse{
|
||||
Limit: folderSearchLimit}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
@@ -829,7 +829,7 @@ func TestDeleteFoldersFromApiServer(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: 100000}).Return(&resource.ResourceSearchResponse{
|
||||
Limit: folderSearchLimit}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
|
||||
@@ -8,14 +8,19 @@ import (
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -159,26 +164,71 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa
|
||||
}
|
||||
|
||||
func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetChildrenQuery) ([]*folder.Folder, error) {
|
||||
out, err := ss.k8sclient.List(ctx, q.OrgID, v1.ListOptions{})
|
||||
// the general folder is saved as an empty string in the database
|
||||
if q.UID == folder.GeneralFolderUID {
|
||||
q.UID = ""
|
||||
}
|
||||
if q.Limit == 0 {
|
||||
q.Limit = folderSearchLimit
|
||||
}
|
||||
if q.Page == 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
req := &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_FOLDER,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{q.UID},
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: q.Limit,
|
||||
// legacy fallback search requires page, unistore requires offset,
|
||||
// so set both
|
||||
Offset: q.Limit * (q.Page - 1),
|
||||
Page: q.Page,
|
||||
}
|
||||
|
||||
// only filter the folder UIDs if they are provided in the query
|
||||
if len(q.FolderUIDs) > 0 {
|
||||
req.Options.Fields = append(req.Options.Fields, &resource.Requirement{
|
||||
Key: resource.SEARCH_FIELD_NAME,
|
||||
Operator: string(selection.In),
|
||||
Values: q.FolderUIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// now, get children of the parent folder
|
||||
out, err := ss.k8sclient.Search(ctx, q.OrgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := dashboardsearch.ParseResults(out, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowK6Folder := (q.SignedInUser != nil && q.SignedInUser.IsIdentityType(claims.TypeServiceAccount))
|
||||
hits := make([]*folder.Folder, 0)
|
||||
for _, item := range out.Items {
|
||||
// convert item to legacy folder format
|
||||
f, err := ss.UnstructuredToLegacyFolder(ctx, &item)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("unable to convert unstructured item to legacy folder %w", err)
|
||||
for _, item := range res.Hits {
|
||||
// filter out k6 folders if request is not from a service account
|
||||
if item.Name == accesscontrol.K6FolderUID && !allowK6Folder {
|
||||
continue
|
||||
}
|
||||
|
||||
// it we are at root level, skip subfolder
|
||||
if q.UID == "" && f.ParentUID != "" {
|
||||
continue // query filter
|
||||
// search only returns a subset of info, get all info of the folder
|
||||
item, err := ss.k8sclient.Get(ctx, item.Name, q.OrgID, v1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if we are at a nested folder, then skip folders that don't belong to parentUid
|
||||
if q.UID != "" && !strings.EqualFold(f.ParentUID, q.UID) {
|
||||
continue
|
||||
|
||||
f, err := ss.UnstructuredToLegacyFolder(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hits = append(hits, f)
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package folderimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/client"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
)
|
||||
|
||||
func TestComputeFullPath(t *testing.T) {
|
||||
@@ -75,3 +84,155 @@ func TestComputeFullPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChildren(t *testing.T) {
|
||||
mockCli := new(client.MockK8sHandler)
|
||||
store := FolderUnifiedStoreImpl{
|
||||
k8sclient: mockCli,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
orgID := int64(2)
|
||||
|
||||
t.Run("should be able to find children folders, and set defaults for pages", func(t *testing.T) {
|
||||
mockCli.On("Search", mock.Anything, orgID, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_FOLDER,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{"folder1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: folderSearchLimit, // should default to folderSearchLimit
|
||||
Offset: 0, // should be set as limit * (page - 1)
|
||||
Page: 1, // should be set to 1 by default
|
||||
}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: "folder", Type: resource.ResourceTableColumnDefinition_STRING},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: "folder2", Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: "folder3", Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder2", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder2"},
|
||||
},
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder3", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder3"},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
// don't set page or limit - should be automatically added
|
||||
result, err := store.GetChildren(ctx, folder.GetChildrenQuery{
|
||||
UID: "folder1",
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, "folder2", result[0].UID)
|
||||
require.Equal(t, "folder3", result[1].UID)
|
||||
})
|
||||
|
||||
t.Run("pages should be able to be set, general folder should be turned to empty string, and folder uids should be passed in", func(t *testing.T) {
|
||||
mockCli.On("Search", mock.Anything, orgID, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Fields: []*resource.Requirement{
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_FOLDER,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{""}, // should be an empty string if general is passed in
|
||||
},
|
||||
{
|
||||
Key: resource.SEARCH_FIELD_NAME,
|
||||
Operator: string(selection.In),
|
||||
Values: []string{"folder2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Limit: 10,
|
||||
Offset: 20, // should be set as limit * (page - 1)
|
||||
Page: 3,
|
||||
}).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: "folder", Type: resource.ResourceTableColumnDefinition_STRING},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: "folder2", Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil).Once()
|
||||
mockCli.On("Get", mock.Anything, "folder2", orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": "folder2"},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
result, err := store.GetChildren(ctx, folder.GetChildrenQuery{
|
||||
UID: "general",
|
||||
OrgID: orgID,
|
||||
Limit: 10,
|
||||
Page: 3,
|
||||
FolderUIDs: []string{"folder2"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, "folder2", result[0].UID)
|
||||
})
|
||||
|
||||
t.Run("k6 folder should only be returned to service accounts", func(t *testing.T) {
|
||||
mockCli.On("Search", mock.Anything, orgID, mock.Anything).Return(&resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: "folder", Type: resource.ResourceTableColumnDefinition_STRING},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{Name: accesscontrol.K6FolderUID, Resource: "folder"},
|
||||
Cells: [][]byte{[]byte("folder1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 1,
|
||||
}, nil)
|
||||
mockCli.On("Get", mock.Anything, accesscontrol.K6FolderUID, orgID, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{"name": accesscontrol.K6FolderUID},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
result, err := store.GetChildren(ctx, folder.GetChildrenQuery{
|
||||
UID: "folder",
|
||||
OrgID: orgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 0)
|
||||
|
||||
result, err = store.GetChildren(ctx, folder.GetChildrenQuery{
|
||||
UID: "folder",
|
||||
OrgID: orgID,
|
||||
SignedInUser: &identity.StaticRequester{Type: claims.TypeServiceAccount},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user