From 94262fd09506e0320a13fb8d0a3c46731dac590b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 22 Nov 2024 23:26:56 +0300 Subject: [PATCH] Search: expose search on dashboard apiserver (v0alpha1) (#96907) --- pkg/registry/apis/dashboard/authorizer.go | 3 +- pkg/registry/apis/dashboard/search.go | 122 ++++++++++ .../apis/dashboard/v0alpha1/register.go | 15 +- pkg/services/apiserver/builder/helper.go | 6 + pkg/storage/unified/resource/server.go | 3 + pkg/tests/apis/dashboard/dashboards_test.go | 210 ------------------ 6 files changed, 147 insertions(+), 212 deletions(-) create mode 100644 pkg/registry/apis/dashboard/search.go diff --git a/pkg/registry/apis/dashboard/authorizer.go b/pkg/registry/apis/dashboard/authorizer.go index d7c02a9c159..8f783486833 100644 --- a/pkg/registry/apis/dashboard/authorizer.go +++ b/pkg/registry/apis/dashboard/authorizer.go @@ -16,7 +16,8 @@ import ( func GetAuthorizer(dashboardService dashboards.DashboardService, l log.Logger) authorizer.Authorizer { return authorizer.AuthorizerFunc( func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { + // Use the standard authorizer + if !attr.IsResourceRequest() || attr.GetResource() == "search" { return authorizer.DecisionNoOpinion, "", nil } diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go new file mode 100644 index 00000000000..b15a8deac64 --- /dev/null +++ b/pkg/registry/apis/dashboard/search.go @@ -0,0 +1,122 @@ +package dashboard + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +// The DTO returns everything the UI needs in a single request +type SearchConnector struct { + newFunc func() runtime.Object + client resource.ResourceIndexClient + log log.Logger +} + +func NewSearchConnector( + client resource.ResourceIndexClient, + newFunc func() runtime.Object, +) (rest.Storage, error) { + v := &SearchConnector{ + client: client, + newFunc: newFunc, + log: log.New("grafana-apiserver.dashboards.search"), + } + return v, nil +} + +var ( + _ rest.Connecter = (*SearchConnector)(nil) + _ rest.StorageMetadata = (*SearchConnector)(nil) + _ rest.Scoper = (*SearchConnector)(nil) + _ rest.SingularNameProvider = (*SearchConnector)(nil) +) + +func (s *SearchConnector) New() runtime.Object { + return s.newFunc() +} + +func (s *SearchConnector) Destroy() { +} + +func (s *SearchConnector) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *SearchConnector) GetSingularName() string { + return "Search" +} + +func (s *SearchConnector) ConnectMethods() []string { + return []string{"GET"} +} + +func (s *SearchConnector) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +func (s *SearchConnector) ProducesMIMETypes(verb string) []string { + return nil +} + +func (s *SearchConnector) ProducesObject(verb string) interface{} { + return s.newFunc() +} + +func (s *SearchConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + user, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queryParams, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + responder.Error(err) + return + } + + // get limit and offset from query params + limit := 0 + offset := 0 + if queryParams.Has("limit") { + limit, _ = strconv.Atoi(queryParams.Get("limit")) + } + if queryParams.Has("offset") { + offset, _ = strconv.Atoi(queryParams.Get("offset")) + } + + searchRequest := &resource.SearchRequest{ + Tenant: user.GetNamespace(), //<< not necessary it is in the namespace (and user context) + Kind: strings.Split(queryParams.Get("kind"), ","), + QueryType: queryParams.Get("queryType"), + Query: queryParams.Get("query"), + Limit: int64(limit), + Offset: int64(offset), + } + + // TODO... actually query + result, err := s.client.Search(r.Context(), searchRequest) + if err != nil { + responder.Error(err) + return + } + + jj, err := json.Marshal(result) + if err != nil { + responder.Error(err) + return + } + _, _ = w.Write(jj) + }), nil +} diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go index 804d5e4e02d..81186005bdf 100644 --- a/pkg/registry/apis/dashboard/v0alpha1/register.go +++ b/pkg/registry/apis/dashboard/v0alpha1/register.go @@ -177,6 +177,14 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver return err } + // Requires hack in to resolve with no name: + // pkg/services/apiserver/builder/helper.go#L58 + storage["search"], err = dashboard.NewSearchConnector(b.unified, + func() runtime.Object { return &dashboardv0alpha1.DashboardWithAccessInfo{} }) // TODO... replace with a real model + if err != nil { + return err + } + // Expose read only library panels storage[dashboardv0alpha1.LibraryPanelResourceInfo.StoragePath()] = &dashboard.LibraryPanelStore{ Access: b.legacy.Access, @@ -202,8 +210,13 @@ func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op delete(oas.Paths.Paths, root+dashboardv0alpha1.DashboardResourceInfo.GroupResource().Resource) delete(oas.Paths.Paths, root+"watch/"+dashboardv0alpha1.DashboardResourceInfo.GroupResource().Resource) + // Resolve the empty name + sub := oas.Paths.Paths[root+"search/{name}"] + oas.Paths.Paths[root+"search"] = sub + delete(oas.Paths.Paths, root+"search/{name}") + // The root API discovery list - sub := oas.Paths.Paths[root] + sub = oas.Paths.Paths[root] if sub != nil && sub.Get != nil { sub.Get.Tags = []string{"API Discovery"} // sorts first in the list } diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 911f76093af..e44d3598169 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -54,6 +54,12 @@ var PathRewriters = []filters.PathRewriter{ return matches[1] + "/name" // connector requires a name }, }, + { + Pattern: regexp.MustCompile(`(/apis/dashboard.grafana.app/v0alpha1/namespaces/.*/search$)`), + ReplaceFunc: func(matches []string) string { + return matches[1] + "/name" // connector requires a name + }, + }, { Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`), ReplaceFunc: func(matches []string) string { diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index db2909914af..24ae438506c 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -927,6 +927,9 @@ func (s *server) Search(ctx context.Context, req *SearchRequest) (*SearchRespons if err := s.Init(ctx); err != nil { return nil, err } + if s.index == nil { + return nil, fmt.Errorf("search index not configured") + } return s.index.Search(ctx, req) } diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 6815e2fca2b..b9037e9d71e 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -196,214 +196,4 @@ func TestIntegrationDashboardsApp(t *testing.T) { }) runDashboardTest(t, helper) }) - - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: false, // required for experimental APIs - DisableAnonymous: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagKubernetesDashboardsAPI, // Required to start the example service - }, - }) - - _, err := helper.NewDiscoveryClient().ServerResourcesForGroupVersion("dashboard.grafana.app/v0alpha1") - require.NoError(t, err) - - t.Run("Check discovery client", func(t *testing.T) { - disco := helper.GetGroupVersionInfoJSON("dashboard.grafana.app") - - require.JSONEq(t, `[ - { - "freshness": "Current", - "resources": [ - { - "resource": "dashboards", - "responseKind": { - "group": "", - "kind": "Dashboard", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "dashboard", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "DashboardWithAccessInfo", - "version": "" - }, - "subresource": "dto", - "verbs": [ - "get" - ] - }, - { - "responseKind": { - "group": "", - "kind": "PartialObjectMetadataList", - "version": "" - }, - "subresource": "history", - "verbs": [ - "get" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - }, - { - "resource": "librarypanels", - "responseKind": { - "group": "", - "kind": "LibraryPanel", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "librarypanel", - "verbs": [ - "get", - "list" - ] - } - ], - "version": "v2alpha1" - }, - { - "freshness": "Current", - "resources": [ - { - "resource": "dashboards", - "responseKind": { - "group": "", - "kind": "Dashboard", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "dashboard", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "DashboardWithAccessInfo", - "version": "" - }, - "subresource": "dto", - "verbs": [ - "get" - ] - }, - { - "responseKind": { - "group": "", - "kind": "PartialObjectMetadataList", - "version": "" - }, - "subresource": "history", - "verbs": [ - "get" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - }, - { - "resource": "librarypanels", - "responseKind": { - "group": "", - "kind": "LibraryPanel", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "librarypanel", - "verbs": [ - "get", - "list" - ] - } - ], - "version": "v1alpha1" - }, - { - "freshness": "Current", - "resources": [ - { - "resource": "dashboards", - "responseKind": { - "group": "", - "kind": "Dashboard", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "dashboard", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "DashboardWithAccessInfo", - "version": "" - }, - "subresource": "dto", - "verbs": [ - "get" - ] - }, - { - "responseKind": { - "group": "", - "kind": "PartialObjectMetadataList", - "version": "" - }, - "subresource": "history", - "verbs": [ - "get" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - }, - { - "resource": "librarypanels", - "responseKind": { - "group": "", - "kind": "LibraryPanel", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "librarypanel", - "verbs": [ - "get", - "list" - ] - } - ], - "version": "v0alpha1" - } -]`, disco) - }) }