K8s: Implement folder search (#99781)

This commit is contained in:
Stephanie Hingtgen 2025-01-29 16:44:42 -07:00 committed by GitHub
parent 7007342704
commit 2d491a9367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 368 additions and 4 deletions

View File

@ -490,7 +490,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
SQLStore: sc.db,
Features: features,
QuotaService: quotaSrv,
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc),
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc, folderServiceWithFlagOn, features),
folderService: folderServiceWithFlagOn,
DashboardService: dashboardSvc,
}

View File

@ -31,7 +31,7 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
}
titleIDX := 0
folderIDX := 1
folderIDX := -1
tagsIDX := -1
scoreIDX := 0
explainIDX := 0
@ -80,9 +80,11 @@ func ParseResults(result *resource.ResourceSearchResponse, offset int64) (*v0alp
Resource: row.Key.Resource, // folders | dashboards
Name: row.Key.Name, // The Grafana UID
Title: string(row.Cells[titleIDX]),
Folder: string(row.Cells[folderIDX]),
Field: fields,
}
if folderIDX > 0 && row.Cells[folderIDX] != nil {
hit.Folder = string(row.Cells[folderIDX])
}
if tagsIDX > 0 && row.Cells[tagsIDX] != nil {
_ = json.Unmarshal(row.Cells[tagsIDX], &hit.Tags)
}

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)

View File

@ -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

View File

@ -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)
})
}

View File

@ -4,11 +4,13 @@ import (
"context"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
)
type FakeService struct {
ExpectedFolders []*folder.Folder
ExpectedFolder *folder.Folder
ExpectedHitList model.HitList
ExpectedError error
ExpectedDescendantCounts map[string]int64
}
@ -82,6 +84,11 @@ func (s *FakeService) GetDescendantCountsLegacy(ctx context.Context, q *folder.G
func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
return s.ExpectedFolders, s.ExpectedError
}
func (s *FakeService) SearchFolders(ctx context.Context, q folder.SearchFoldersQuery) (model.HitList, error) {
return s.ExpectedHitList, s.ExpectedError
}
func (s *FakeService) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
return s.ExpectedFolders, s.ExpectedError
}

View File

@ -179,6 +179,15 @@ type GetFoldersQuery struct {
SignedInUser identity.Requester `json:"-"`
}
type SearchFoldersQuery struct {
OrgID int64
UIDs []string
IDs []int64
Title string
Limit int64
SignedInUser identity.Requester `json:"-"`
}
// GetParentsQuery captures the information required by the folder service to
// return a list of all parent folders of a given folder.
type GetParentsQuery struct {

View File

@ -2,6 +2,8 @@ package folder
import (
"context"
"github.com/grafana/grafana/pkg/services/search/model"
)
type Service interface {
@ -40,6 +42,9 @@ type Service interface {
GetFolders(ctx context.Context, q GetFoldersQuery) ([]*Folder, error)
GetFoldersLegacy(ctx context.Context, q GetFoldersQuery) ([]*Folder, error)
// SearchFolders returns a list of folders that match the query.
SearchFolders(ctx context.Context, q SearchFoldersQuery) (model.HitList, error)
// GetChildren returns an array containing all child folders.
GetChildren(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error)
GetChildrenLegacy(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error)

View File

@ -8,7 +8,10 @@ import (
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
@ -17,7 +20,7 @@ import (
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/search")
func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service, dashboardService dashboards.DashboardService) *SearchService {
func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service, dashboardService dashboards.DashboardService, folderService folder.Service, features featuremgmt.FeatureToggles) *SearchService {
s := &SearchService{
Cfg: cfg,
sortOptions: map[string]model.SortOption{
@ -26,6 +29,8 @@ func ProvideService(cfg *setting.Cfg, sqlstore db.DB, starService star.Service,
},
sqlstore: sqlstore,
starService: starService,
folderService: folderService,
features: features,
dashboardService: dashboardService,
}
return s
@ -61,6 +66,8 @@ type SearchService struct {
sqlstore db.DB
starService star.Service
dashboardService dashboards.DashboardService
folderService folder.Service
features featuremgmt.FeatureToggles
}
func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model.HitList, error) {
@ -107,6 +114,20 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model.
dashboardQuery.Sort = sortOpt
}
// if folders are stored in unified storage, we need to use the folder service to query for folders
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) && (query.Type == searchstore.TypeFolder || query.Type == searchstore.TypeAlertFolder) {
hits, err := s.folderService.SearchFolders(ctx, folder.SearchFoldersQuery{
OrgID: query.OrgId,
UIDs: query.FolderUIDs,
IDs: query.FolderIds,
Title: query.Title,
Limit: query.Limit,
SignedInUser: query.SignedInUser,
})
return sortedHits(hits), err
}
hits, err := s.dashboardService.SearchDashboards(ctx, &dashboardQuery)
if err != nil {
return nil, err

View File

@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db/dbtest"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/star/startest"
@ -35,6 +36,7 @@ func TestSearch_SortedResults(t *testing.T) {
sqlstore: db,
starService: ss,
dashboardService: ds,
features: &featuremgmt.FeatureManager{},
}
query := &Query{
@ -76,6 +78,7 @@ func TestSearch_StarredResults(t *testing.T) {
sqlstore: db,
starService: ss,
dashboardService: ds,
features: &featuremgmt.FeatureManager{},
}
query := &Query{