Return ownerRef in the /search api
This commit is contained in:
@@ -262,3 +262,5 @@ public/mockServiceWorker.js
|
||||
|
||||
# Ignore grafana/hippocampus local cache folder
|
||||
.hippo
|
||||
|
||||
.cache
|
||||
|
||||
@@ -72,6 +72,8 @@ type DashboardHit struct {
|
||||
Folder string `json:"folder,omitempty"`
|
||||
// The resource is managed
|
||||
ManagedBy ManagedBy `json:"managedBy,omitzero,omitempty"`
|
||||
// Owner references set on the resource metadata
|
||||
OwnerReferences []metav1.OwnerReference `json:"ownerReferences,omitempty"`
|
||||
// Stick untyped extra fields in this object (including the sort value)
|
||||
Field *common.Unstructured `json:"field,omitzero,omitempty"`
|
||||
// When using "real" search, this is the score
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
unstructuredv1 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
@@ -38,19 +39,21 @@ import (
|
||||
|
||||
// The DTO returns everything the UI needs in a single request
|
||||
type SearchHandler struct {
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
resourceClient resource.ResourceClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
func NewSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyDashboardSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher, features)
|
||||
return &SearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
client: searchClient,
|
||||
resourceClient: resourceClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +332,9 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
s.addOwnerReferences(ctx, result, &parsedResults)
|
||||
s.filterByOwner(queryParams.Get("owner"), &parsedResults)
|
||||
|
||||
s.write(w, parsedResults)
|
||||
}
|
||||
|
||||
@@ -489,6 +495,66 @@ func (s *SearchHandler) write(w http.ResponseWriter, obj any) {
|
||||
_ = json.NewEncoder(w).Encode(obj)
|
||||
}
|
||||
|
||||
func (s *SearchHandler) filterByOwner(owner string, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||
if owner == "" || parsedResults == nil {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]dashboardv0alpha1.DashboardHit, 0, len(parsedResults.Hits))
|
||||
for _, hit := range parsedResults.Hits {
|
||||
for _, ref := range hit.OwnerReferences {
|
||||
if ref.Name == owner || string(ref.UID) == owner {
|
||||
filtered = append(filtered, hit)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedResults.Hits = filtered
|
||||
parsedResults.TotalHits = int64(len(filtered))
|
||||
}
|
||||
|
||||
func (s *SearchHandler) addOwnerReferences(ctx context.Context, searchResult *resourcepb.ResourceSearchResponse, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||
if s.resourceClient == nil || searchResult == nil || parsedResults == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if searchResult.Results == nil || len(searchResult.Results.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(searchResult.Results.Rows) != len(parsedResults.Hits) {
|
||||
return
|
||||
}
|
||||
|
||||
for i, row := range searchResult.Results.Rows {
|
||||
key := row.Key
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
readResp, err := s.resourceClient.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||
if err != nil {
|
||||
s.log.Debug("failed to read resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if readResp == nil || readResp.Error != nil || len(readResp.Value) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var obj unstructuredv1.Unstructured
|
||||
if err := obj.UnmarshalJSON(readResp.Value); err != nil {
|
||||
s.log.Debug("failed to unmarshal resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if refs := obj.GetOwnerReferences(); len(refs) > 0 {
|
||||
parsedResults.Hits[i].OwnerReferences = refs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given a namespace and type convert it to a search key
|
||||
func asResourceKey(ns string, k string) (*resourcepb.ResourceKey, error) {
|
||||
key, err := resource.AsResourceKey(ns, k)
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -338,13 +339,184 @@ func TestSearchHandler(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResults), len(p.Hits))
|
||||
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
|
||||
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
|
||||
})
|
||||
t.Run("Adds owner references from resource store", func(t *testing.T) {
|
||||
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d1",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ownerRefs := []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: "team.grafana.app/v1beta1",
|
||||
Kind: "Team",
|
||||
Name: "team-1",
|
||||
UID: "uid-1",
|
||||
},
|
||||
}
|
||||
value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d1",
|
||||
"namespace": "test",
|
||||
"ownerReferences": ownerRefs,
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||
"d1": {
|
||||
Value: value,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "ownerRefs"),
|
||||
client: mockClient,
|
||||
resourceClient: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
var result dashboardv0alpha1.SearchResults
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||
|
||||
require.Len(t, result.Hits, 1)
|
||||
require.Len(t, result.Hits[0].OwnerReferences, 1)
|
||||
assert.Equal(t, ownerRefs[0].Name, result.Hits[0].OwnerReferences[0].Name)
|
||||
assert.Equal(t, ownerRefs[0].Kind, result.Hits[0].OwnerReferences[0].Kind)
|
||||
})
|
||||
t.Run("Filters by owner param", func(t *testing.T) {
|
||||
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d1",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||
},
|
||||
{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "test",
|
||||
Group: dashboardv0alpha1.GROUP,
|
||||
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||
Name: "d2",
|
||||
},
|
||||
Cells: [][]byte{[]byte("Dashboard 2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
TotalHits: 2,
|
||||
}
|
||||
|
||||
ownerRefs := []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: "team.grafana.app/v1beta1",
|
||||
Kind: "Team",
|
||||
Name: "team-1",
|
||||
UID: "uid-1",
|
||||
},
|
||||
}
|
||||
d1Value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d1",
|
||||
"namespace": "test",
|
||||
"ownerReferences": ownerRefs,
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
d2Value, err := json.Marshal(map[string]any{
|
||||
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": "d2",
|
||||
"namespace": "test",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 2",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||
"d1": {Value: d1Value},
|
||||
"d2": {Value: d2Value},
|
||||
},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "ownerRefs-filter"),
|
||||
client: mockClient,
|
||||
resourceClient: mockClient,
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?owner=team-1", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
var result dashboardv0alpha1.SearchResults
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||
|
||||
require.Len(t, result.Hits, 1)
|
||||
assert.Equal(t, int64(1), result.TotalHits)
|
||||
assert.Equal(t, "d1", result.Hits[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
@@ -376,7 +548,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
@@ -466,7 +638,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(p.Hits))
|
||||
@@ -597,7 +769,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
p := &v0alpha1.SearchResults{}
|
||||
p := &dashboardv0alpha1.SearchResults{}
|
||||
err := json.NewDecoder(resp.Body).Decode(p)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
|
||||
@@ -1017,6 +1189,12 @@ type MockClient struct {
|
||||
MockResponses []*resourcepb.ResourceSearchResponse
|
||||
MockCalls []*resourcepb.ResourceSearchRequest
|
||||
CallCount int
|
||||
|
||||
ReadResponses map[string]*resourcepb.ReadResponse
|
||||
DefaultReadResponse *resourcepb.ReadResponse
|
||||
LastReadRequests []*resourcepb.ReadRequest
|
||||
ReadError error
|
||||
LastReadErrorRequest *resourcepb.ReadRequest
|
||||
}
|
||||
|
||||
type MockResult struct {
|
||||
@@ -1080,6 +1258,22 @@ func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, o
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
|
||||
m.LastReadRequests = append(m.LastReadRequests, in)
|
||||
if m.ReadError != nil {
|
||||
m.LastReadErrorRequest = in
|
||||
return nil, m.ReadError
|
||||
}
|
||||
|
||||
if in != nil && in.Key != nil && m.ReadResponses != nil {
|
||||
if resp, ok := m.ReadResponses[in.Key.Name]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.DefaultReadResponse != nil {
|
||||
return m.DefaultReadResponse, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
|
||||
|
||||
Reference in New Issue
Block a user