diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index c3a712a8811..999586b13a1 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" dashboard "github.com/grafana/grafana/pkg/apis/dashboard" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" @@ -54,8 +55,9 @@ type dashboardSqlAccess struct { provisioning provisioning.ProvisioningService // Use for writing (not reading) - dashStore dashboards.Store - softDelete bool + dashStore dashboards.Store + softDelete bool + dashboardSearchClient legacysearcher.DashboardSearchClient // Typically one... the server wrapper subscribers []chan *resource.WrittenEvent @@ -68,12 +70,14 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, provisioning provisioning.ProvisioningService, softDelete bool, ) DashboardAccess { + dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore) return &dashboardSqlAccess{ - sql: sql, - namespacer: namespacer, - dashStore: dashStore, - provisioning: provisioning, - softDelete: softDelete, + sql: sql, + namespacer: namespacer, + dashStore: dashStore, + provisioning: provisioning, + softDelete: softDelete, + dashboardSearchClient: *dashboardSearchClient, } } diff --git a/pkg/registry/apis/dashboard/legacy/storage.go b/pkg/registry/apis/dashboard/legacy/storage.go index 0f88fdf15a5..e5f51bf78f9 100644 --- a/pkg/registry/apis/dashboard/legacy/storage.go +++ b/pkg/registry/apis/dashboard/legacy/storage.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "time" claims "github.com/grafana/authlib/types" @@ -255,9 +254,8 @@ func (a *dashboardSqlAccess) Read(ctx context.Context, req *resource.ReadRequest return a.ReadResource(ctx, req), nil } -// TODO: this needs to be implemented func (a *dashboardSqlAccess) Search(ctx context.Context, req *resource.ResourceSearchRequest) (*resource.ResourceSearchResponse, error) { - return nil, fmt.Errorf("not yet (filter)") + return a.dashboardSearchClient.Search(ctx, req) } func (a *dashboardSqlAccess) ListRepositoryObjects(ctx context.Context, req *resource.ListRepositoryObjectsRequest) (*resource.ListRepositoryObjectsResponse, error) { @@ -270,35 +268,5 @@ func (a *dashboardSqlAccess) CountRepositoryObjects(context.Context, *resource.C // GetStats implements ResourceServer. func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resource.ResourceStatsRequest) (*resource.ResourceStatsResponse, error) { - info, err := claims.ParseNamespace(req.Namespace) - if err != nil { - return nil, fmt.Errorf("unable to read namespace") - } - if info.OrgID == 0 { - return nil, fmt.Errorf("invalid OrgID found in namespace") - } - - if len(req.Kinds) != 1 { - return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback") - } - - parts := strings.SplitN(req.Kinds[0], "/", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid kind") - } - - count, err := a.dashStore.CountInOrg(ctx, info.OrgID) - if err != nil { - return nil, err - } - - return &resource.ResourceStatsResponse{ - Stats: []*resource.ResourceStatsResponse_Stats{ - { - Group: parts[0], - Resource: parts[1], - Count: count, - }, - }, - }, nil + return a.dashboardSearchClient.GetStats(ctx, req) } diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go new file mode 100644 index 00000000000..ac221ba86e2 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go @@ -0,0 +1,128 @@ +package legacysearcher + +import ( + "context" + "fmt" + "strings" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "google.golang.org/grpc" +) + +type DashboardSearchClient struct { + resource.ResourceIndexClient + dashboardStore dashboards.Store +} + +func NewDashboardSearchClient(dashboardStore dashboards.Store) *DashboardSearchClient { + return &DashboardSearchClient{dashboardStore: dashboardStore} +} + +func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) { + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + if req.Query == "*" { + req.Query = "" + } + + // TODO add missing support for the following query params: + // - tag + // - starred (won't support) + // - page (check) + // - type + // - sort + // - deleted + // - permission + // - dashboardIds + // - dashboardUIDs + // - folderIds + // - folderUIDs + // - sort (default by title) + query := &dashboards.FindPersistedDashboardsQuery{ + Title: req.Query, + Limit: req.Limit, + // FolderUIDs: req.FolderUIDs, + SignedInUser: user, + } + + // TODO need to test this + // emptyResponse, err := a.dashService.GetSharedDashboardUIDsQuery(ctx, query) + + // if err != nil { + // return nil, err + // } else if emptyResponse { + // return nil, nil + // } + + res, err := c.dashboardStore.FindDashboards(ctx, query) + if err != nil { + return nil, err + } + + // TODO sort if query.Sort == "" see sortedHits in services/search/service.go + + searchFields := resource.StandardSearchFields() + list := &resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + searchFields.Field(resource.SEARCH_FIELD_TITLE), + searchFields.Field(resource.SEARCH_FIELD_FOLDER), + // searchFields.Field(resource.SEARCH_FIELD_TAGS), + }, + }, + } + + for _, dashboard := range res { + list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{ + Key: &resource.ResourceKey{ + Namespace: "default", + Group: "dashboard.grafana.app", + Resource: "dashboards", + Name: dashboard.UID, + }, + Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID)}, // TODO add tag + }) + } + + return list, nil +} + +func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) { + info, err := claims.ParseNamespace(req.Namespace) + if err != nil { + return nil, fmt.Errorf("unable to read namespace") + } + if info.OrgID == 0 { + return nil, fmt.Errorf("invalid OrgID found in namespace") + } + + if len(req.Kinds) != 1 { + return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback") + } + + parts := strings.SplitN(req.Kinds[0], "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid kind") + } + + count, err := c.dashboardStore.CountInOrg(ctx, info.OrgID) + if err != nil { + return nil, err + } + + return &resource.ResourceStatsResponse{ + Stats: []*resource.ResourceStatsResponse_Stats{ + { + Group: parts[0], + Resource: parts[1], + Count: count, + }, + }, + }, nil +} diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 91d9217ed98..7f866943cbe 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/builder" dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/util/errhttp" ) @@ -32,9 +33,10 @@ type SearchHandler struct { tracer trace.Tracer } -func NewSearchHandler(client resource.ResourceIndexClient, tracer trace.Tracer) *SearchHandler { +func NewSearchHandler(client resource.ResourceIndexClient, tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler { + searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, client, legacyDashboardSearcher) return &SearchHandler{ - client: client, + client: searchClient, log: log.New("grafana-apiserver.dashboards.search"), tracer: tracer, } @@ -332,7 +334,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { searchRequest.Options.Fields = append(searchRequest.Options.Fields, namesFilter...) } - // Run the query result, err := s.client.Search(ctx, searchRequest) if err != nil { errhttp.Write(ctx, err, w) diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 03dcd6cd9d5..23d7ad18278 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -7,13 +7,173 @@ import ( "testing" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "google.golang.org/grpc" ) +func TestSearchFallback(t *testing.T) { + t.Run("should hit legacy search handler on mode 0", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + if mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + }) + + t.Run("should hit legacy search handler on mode 1", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + if mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + }) + + t.Run("should hit legacy search handler on mode 2", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + if mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + }) + + t.Run("should hit unified storage search handler on mode 3", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + if mockLegacyClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + }) + + t.Run("should hit unified storage search handler on mode 4", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + if mockLegacyClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + }) + + t.Run("should hit unified storage search handler on mode 5", func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, + }, + } + searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + if mockLegacyClient.LastSearchRequest != nil { + t.Fatalf("expected Search NOT to be called, but it was") + } + }) +} + func TestSearchHandlerFields(t *testing.T) { // Create a mock client mockClient := &MockClient{} diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index a6d05ec2de0..535cb0cb08b 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -71,6 +72,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) + legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore) builder := &DashboardsAPIBuilder{ log: log.New("grafana-apiserver.dashboards.v0alpha1"), DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{ @@ -80,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, features: features, accessControl: accessControl, unified: unified, - search: dashboard.NewSearchHandler(unified, tracing), + search: dashboard.NewSearchHandler(unified, tracing, cfg, legacyDashboardSearcher), legacy: &dashboard.DashboardStorage{ Resource: dashboardv0alpha1.DashboardResourceInfo, diff --git a/pkg/services/apiserver/client/client.go b/pkg/services/apiserver/client/client.go index 8d054fc7a9b..2e25ca07ee2 100644 --- a/pkg/services/apiserver/client/client.go +++ b/pkg/services/apiserver/client/client.go @@ -12,8 +12,11 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher" "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" k8sUser "k8s.io/apiserver/pkg/authentication/user" k8sRequest "k8s.io/apiserver/pkg/endpoints/request" @@ -40,12 +43,14 @@ type k8sHandler struct { searcher resource.ResourceIndexClient } -func NewK8sHandler(namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient) K8sHandler { +func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store) K8sHandler { + legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore) + searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, searcher, legacySearcher) return &k8sHandler{ namespacer: namespacer, gvr: gvr, restConfigProvider: restConfigProvider, - searcher: searcher, + searcher: searchClient, } } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 0f2957ee0ce..3a46f051a7d 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -91,7 +91,7 @@ func ProvideDashboardServiceImpl( restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient, quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper, ) (*DashboardServiceImpl, error) { - k8sHandler := client.NewK8sHandler(request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified) + k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore) dashSvc := &DashboardServiceImpl{ cfg: cfg, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 934fa3152a1..c78e455fa24 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -540,6 +540,8 @@ type Cfg struct { HttpsSkipVerify bool } +const UnifiedStorageConfigKeyDashboard = "dashboards.dashboard.grafana.app" + type UnifiedStorageConfig struct { DualWriterMode rest.DualWriterMode DualWriterPeriodicDataSyncJobEnabled bool diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod index b70c1800e01..c2b1383e9c9 100644 --- a/pkg/storage/unified/resource/go.mod +++ b/pkg/storage/unified/resource/go.mod @@ -17,6 +17,7 @@ require ( github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible github.com/grafana/grafana-plugin-sdk-go v0.263.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d + github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/prometheus/client_golang v1.20.5 @@ -120,7 +121,6 @@ require ( github.com/grafana/grafana-app-sdk/logging v0.29.0 // indirect github.com/grafana/grafana-aws-sdk v0.31.5 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect - github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grafana/sqlds/v4 v4.1.3 // indirect diff --git a/pkg/storage/unified/resource/search_client.go b/pkg/storage/unified/resource/search_client.go new file mode 100644 index 00000000000..43e0faa7b1e --- /dev/null +++ b/pkg/storage/unified/resource/search_client.go @@ -0,0 +1,20 @@ +package resource + +import ( + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/setting" +) + +func NewSearchClient(cfg *setting.Cfg, unifiedStorageConfigKey string, unifiedClient ResourceIndexClient, legacyClient ResourceIndexClient) ResourceIndexClient { + config, ok := cfg.UnifiedStorage[unifiedStorageConfigKey] + if !ok { + return legacyClient + } + + switch config.DualWriterMode { + case rest.Mode0, rest.Mode1, rest.Mode2: + return legacyClient + default: + return unifiedClient + } +}