Return ownerRef in the /search api

This commit is contained in:
Andrej Ocenas
2025-12-10 14:55:06 +01:00
parent 688d6746c9
commit 726ac07372
4 changed files with 277 additions and 13 deletions
+2
View File
@@ -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
+74 -8
View File
@@ -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)
+199 -5
View File
@@ -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) {