K8s: Implement folder search (#99781)

This commit is contained in:
Stephanie Hingtgen
2025-01-29 17:44:42 -06:00
committed by GitHub
parent 7007342704
commit 2d491a9367
10 changed files with 368 additions and 4 deletions
+12
View File
@@ -34,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/store/entity"
@@ -167,6 +168,17 @@ func (s *Service) DBMigration(db db.DB) {
s.log.Debug("syncing dashboard and folder tables finished")
}
func (s *Service) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) {
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) {
// TODO:
// - implement filtering by alerting folders and k6 folders (see the dashboards store `FindDashboards` method for reference)
// - implement fallback on search client in unistore to go to legacy store (will need to read from dashboard store)
return s.searchFoldersFromApiServer(ctx, q)
}
return nil, fmt.Errorf("cannot be called on the legacy folder service")
}
func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) {
return s.getFoldersFromApiServer(ctx, q)
@@ -3,6 +3,7 @@ package folderimpl
import (
"context"
"fmt"
"strconv"
"strings"
"time"
@@ -20,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -28,9 +30,11 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/search"
"github.com/grafana/grafana/pkg/util"
)
@@ -169,6 +173,101 @@ func (s *Service) getFromApiServer(ctx context.Context, q *folder.GetFolderQuery
return f, err
}
// searchFoldesFromApiServer uses the search grpc connection to search folders and returns the hit list
func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.SearchFoldersQuery) (model.HitList, error) {
if query.OrgID == 0 {
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query.OrgID = requester.GetOrgID()
}
request := &resource.ResourceSearchRequest{
Options: &resource.ListOptions{
Key: &resource.ResourceKey{
Namespace: s.k8sclient.getNamespace(query.OrgID),
Group: v0alpha1.FolderResourceInfo.GroupVersionResource().Group,
Resource: v0alpha1.FolderResourceInfo.GroupVersionResource().Resource,
},
Fields: []*resource.Requirement{},
Labels: []*resource.Requirement{},
},
Limit: 100000}
if len(query.UIDs) > 0 {
request.Options.Fields = []*resource.Requirement{{
Key: resource.SEARCH_FIELD_NAME,
Operator: string(selection.In),
Values: query.UIDs,
}}
} else if len(query.IDs) > 0 {
values := make([]string, len(query.IDs))
for i, id := range query.IDs {
values[i] = strconv.FormatInt(id, 10)
}
request.Options.Labels = append(request.Options.Labels, &resource.Requirement{
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
Operator: string(selection.In),
Values: values,
})
}
if query.Title != "" {
// allow wildcard search
request.Query = "*" + strings.ToLower(query.Title) + "*"
}
if query.Limit > 0 {
request.Limit = query.Limit
}
client := s.k8sclient.getSearcher(ctx)
res, err := client.Search(ctx, request)
if err != nil {
return nil, err
}
parsedResults, err := dashboardsearch.ParseResults(res, 0)
if err != nil {
return nil, err
}
hitList := make([]*model.Hit, len(parsedResults.Hits))
foldersMap := map[string]*folder.Folder{}
for i, item := range parsedResults.Hits {
f, ok := foldersMap[item.Folder]
if !ok {
f, err = s.Get(ctx, &folder.GetFolderQuery{
UID: &item.Folder,
OrgID: query.OrgID,
SignedInUser: query.SignedInUser,
})
if err != nil {
return nil, err
}
foldersMap[item.Folder] = f
}
slug := slugify.Slugify(item.Title)
hitList[i] = &model.Hit{
ID: item.Field.GetNestedInt64(search.DASHBOARD_LEGACY_ID),
UID: item.Name,
OrgID: query.OrgID,
Title: item.Title,
URI: "db/" + slug,
URL: dashboards.GetFolderURL(item.Name, slug),
Type: model.DashHitFolder,
FolderUID: item.Folder,
FolderTitle: f.Title,
FolderID: f.ID, // nolint:staticcheck
}
}
return hitList, nil
}
func (s *Service) getFolderByIDFromApiServer(ctx context.Context, id int64, orgID int64) (*folder.Folder, error) {
if id == 0 {
return &folder.GeneralFolder, nil
@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"k8s.io/client-go/dynamic"
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -33,6 +34,7 @@ import (
"github.com/grafana/grafana/pkg/services/guardian"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
@@ -605,6 +607,81 @@ func (r resourceClientMock) Search(ctx context.Context, in *resource.ResourceSea
}, nil
}
if in.Query == "*test*" {
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "_id",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resource.ResourceTableRow{
{
Key: &resource.ResourceKey{
Name: "uid",
Resource: "folders",
},
Cells: [][]byte{
[]byte("123"),
[]byte("testing-123"),
[]byte("parent-uid"),
},
},
},
},
TotalHits: 1,
}, nil
}
if len(in.Options.Fields) > 0 &&
in.Options.Fields[0].Key == resource.SEARCH_FIELD_NAME &&
in.Options.Fields[0].Operator == "in" &&
len(in.Options.Fields[0].Values) > 0 {
rows := []*resource.ResourceTableRow{}
for i, row := range in.Options.Fields[0].Values {
rows = append(rows, &resource.ResourceTableRow{
Key: &resource.ResourceKey{
Name: row,
Resource: "folders",
},
Cells: [][]byte{
[]byte(fmt.Sprintf("%d", i)), // set legacy id as the row id
[]byte(fmt.Sprintf("folder%d", i)), // set title as folder + row id
[]byte(""),
},
})
}
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
{
Name: "_id",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "title",
Type: resource.ResourceTableColumnDefinition_STRING,
},
{
Name: "folder",
Type: resource.ResourceTableColumnDefinition_STRING,
},
},
Rows: rows,
},
TotalHits: int64(len(rows)),
}, nil
}
// not found
return &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{},
@@ -628,3 +705,132 @@ func (r resourceClientMock) GetBlob(ctx context.Context, in *resource.GetBlobReq
func (r resourceClientMock) IsHealthy(ctx context.Context, in *resource.HealthCheckRequest, opts ...grpc.CallOption) (*resource.HealthCheckResponse, error) {
return nil, nil
}
type mockFoldersK8sCli struct {
mock.Mock
searcher resourceClientMock
}
func (m *mockFoldersK8sCli) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) {
args := m.Called(ctx, orgID)
return args.Get(0).(dynamic.ResourceInterface), args.Bool(1)
}
func (m *mockFoldersK8sCli) getNamespace(orgID int64) string {
if orgID == 1 {
return "default"
}
return fmt.Sprintf("orgs-%d", orgID)
}
func (m *mockFoldersK8sCli) getSearcher(ctx context.Context) resource.ResourceClient {
return m.searcher
}
func TestSearchFoldersFromApiServer(t *testing.T) {
fakeK8sClient := new(mockFoldersK8sCli)
service := Service{
k8sclient: fakeK8sClient,
features: featuremgmt.WithFeatures(featuremgmt.FlagKubernetesFoldersServiceV2),
}
fakeK8sClient.On("getSearcher", mock.Anything).Return(fakeK8sClient)
user := &user.SignedInUser{OrgID: 1}
ctx := identity.WithRequester(context.Background(), user)
t.Run("Should search by uids if provided", func(t *testing.T) {
query := folder.SearchFoldersQuery{
UIDs: []string{"uid1", "uid2"},
IDs: []int64{1, 2}, // will ignore these because uid is passed in
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "uid1",
// no parent folder is returned, so the general folder should be set
FolderID: 0,
FolderTitle: "General",
// orgID should be taken from signed in user
OrgID: 1,
// the rest should be automatically set when parsing the hit results from search
Type: model.DashHitFolder,
URI: "db/folder0",
Title: "folder0",
URL: "/dashboards/f/uid1/folder0",
},
{
UID: "uid2",
FolderID: 0,
FolderTitle: "General",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/folder1",
Title: "folder1",
URL: "/dashboards/f/uid2/folder1",
},
}
require.Equal(t, expectedResult, result)
})
t.Run("Search by ID if uids are not provided", func(t *testing.T) {
query := folder.SearchFoldersQuery{
IDs: []int64{123},
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "foo",
FolderID: 0,
FolderTitle: "General",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/folder1",
Title: "folder1",
URL: "/dashboards/f/foo/folder1",
},
}
require.Equal(t, expectedResult, result)
})
t.Run("Search by title, wildcard should be added to search request (won't match in search mock if not)", func(t *testing.T) {
// the search here will return a parent, this will be the parent folder returned when we query for it to add to the hit info
fakeFolderStore := folder.NewFakeStore()
fakeFolderStore.ExpectedFolder = &folder.Folder{
UID: "parent-uid",
ID: 2,
Title: "parent title",
}
service.unifiedStore = fakeFolderStore
guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{
CanSaveValue: true,
CanViewValue: true,
})
query := folder.SearchFoldersQuery{
Title: "test",
SignedInUser: user,
}
result, err := service.searchFoldersFromApiServer(ctx, query)
require.NoError(t, err)
expectedResult := model.HitList{
{
UID: "uid",
FolderID: 2,
FolderTitle: "parent title",
FolderUID: "parent-uid",
OrgID: 1,
Type: model.DashHitFolder,
URI: "db/testing-123",
Title: "testing-123",
URL: "/dashboards/f/uid/testing-123",
},
}
require.Equal(t, expectedResult, result)
})
}