From 726ac073721cd11ba20925e808d3544fb67ab9dc Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Wed, 10 Dec 2025 14:55:06 +0100 Subject: [PATCH] Return ownerRef in the /search api --- .gitignore | 2 + .../pkg/apis/dashboard/v0alpha1/search.go | 2 + pkg/registry/apis/dashboard/search.go | 82 ++++++- pkg/registry/apis/dashboard/search_test.go | 204 +++++++++++++++++- 4 files changed, 277 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 5302a698c2f..802c41839f0 100644 --- a/.gitignore +++ b/.gitignore @@ -262,3 +262,5 @@ public/mockServiceWorker.js # Ignore grafana/hippocampus local cache folder .hippo + +.cache diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/search.go b/apps/dashboard/pkg/apis/dashboard/v0alpha1/search.go index dffca7bc430..f149c787dc3 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/search.go +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/search.go @@ -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 diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 19e5b71e0d9..8904845fee8 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -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) diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index ab7f05e7d00..ca8b44087ae 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -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) {