Compare commits

...

7 Commits

Author SHA1 Message Date
Ryan McKinley
e57c30681d merge main 2025-12-11 09:22:29 +03:00
Ryan McKinley
b378907585 Merge remote-tracking branch 'origin/main' into index-owner-reference 2025-12-11 09:19:04 +03:00
Ryan McKinley
62bdae94ed with query 2025-12-09 20:44:34 +03:00
Ryan McKinley
0091b44b2a Merge remote-tracking branch 'origin/main' into index-owner-reference 2025-12-09 17:21:48 +03:00
Ryan McKinley
307e9cdce3 update swagger 2025-12-08 11:43:57 +03:00
Ryan McKinley
66eb5e35cd add to document builder 2025-12-08 11:18:33 +03:00
Ryan McKinley
a95de85062 merge main 2025-12-08 11:07:16 +03:00
16 changed files with 270 additions and 23 deletions

View File

@@ -248,6 +248,7 @@ const injectedRtkApi = api
permission: queryArg.permission,
sort: queryArg.sort,
limit: queryArg.limit,
ownerReference: queryArg.ownerReference,
explain: queryArg.explain,
},
}),
@@ -673,6 +674,8 @@ export type SearchDashboardsAndFoldersApiArg = {
sort?: string;
/** number of results to return */
limit?: number;
/** filter by owner reference in the format {Group}/{Kind}/{Name} */
ownerReference?: string;
/** add debugging info that may help explain why the result matched */
explain?: boolean;
};

View File

@@ -181,6 +181,32 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "ownerReference", // singular
In: "query",
Description: "filter by owner reference in the format {Group}/{Kind}/{Name}",
Required: false,
Schema: spec.StringProperty(),
Examples: map[string]*spec3.Example{
"": {
ExampleProps: spec3.ExampleProps{},
},
"team": {
ExampleProps: spec3.ExampleProps{
Summary: "Team owner reference",
Value: "iam.grafana.app/Team/xyz",
},
},
"user": {
ExampleProps: spec3.ExampleProps{
Summary: "User owner reference",
Value: "iam.grafana.app/User/abc",
},
},
},
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "explain",
@@ -440,6 +466,15 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
})
}
// The ownerReferences filter
if vals, ok := queryParams["ownerReference"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_OWNER_REFERENCES,
Operator: "=",
Values: vals,
})
}
// The libraryPanel filter
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{

View File

@@ -129,6 +129,23 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
Version: runtime.APIVersionInternal,
})
// Allow searching by owner reference
gvk := gv.WithKind("Folder")
err := scheme.AddFieldLabelConversionFunc(
gvk,
func(label, value string) (string, string, error) {
if label == "metadata.name" || label == "metadata.namespace" {
return label, value, nil
}
if label == "search.ownerReference" { // TODO: this should become more general
return label, value, nil
}
return "", "", fmt.Errorf("field label not supported for %s: %s", gvk, label)
})
if err != nil {
return err
}
// If multiple versions exist, then register conversions from zz_generated.conversion.go
// if err := playlist.RegisterConversions(scheme); err != nil {
// return err

View File

@@ -11,6 +11,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/storage"
@@ -120,6 +121,22 @@ func toListRequest(k *resourcepb.ResourceKey, opts storage.ListOptions) (*resour
if opts.Predicate.Field != nil && !opts.Predicate.Field.Empty() {
requirements := opts.Predicate.Field.Requirements()
for _, r := range requirements {
// NOTE: requires: scheme.AddFieldLabelConversionFunc(
if r.Field == "search.ownerReference" {
if len(requirements) > 1 {
return nil, predicate, apierrors.NewBadRequest("search.ownerReference only supports one requirement")
}
req.Options.Fields = []*resourcepb.Requirement{{
Key: r.Field,
Operator: string(r.Operator),
Values: []string{r.Value},
}}
// with only one requirement, we do not need to transform the predicate to exclude this pseudo field
predicate.Field = fields.Everything()
break
}
requirement := &resourcepb.Requirement{Key: r.Field, Operator: string(r.Operator)}
if r.Value != "" {
requirement.Values = append(requirement.Values, r.Value)

View File

@@ -101,6 +101,11 @@ type IndexableDocument struct {
// metadata, annotations, or external data linked at index time
Fields map[string]any `json:"fields,omitempty"`
// The list of owner references,
// each value is of the form {group}/{kind}/{name}
// ex: iam.grafana.app/Team/abc-engineering
OwnerReferences []string `json:"ownerReferences,omitempty"`
// Maintain a list of resource references.
// Someday this will likely be part of https://github.com/grafana/gamma
References ResourceReferences `json:"references,omitempty"`
@@ -217,6 +222,10 @@ func NewIndexableDocument(key *resourcepb.ResourceKey, rv int64, obj utils.Grafa
if err != nil && tt != nil {
doc.Updated = tt.UnixMilli()
}
for _, owner := range obj.GetOwnerReferences() {
gv, _ := schema.ParseGroupVersion(owner.APIVersion)
doc.OwnerReferences = append(doc.OwnerReferences, fmt.Sprintf("%s/%s/%s", gv.Group, owner.Kind, owner.Name))
}
return doc.UpdateCopyFields()
}
@@ -295,6 +304,7 @@ const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title b
const SEARCH_FIELD_DESCRIPTION = "description"
const SEARCH_FIELD_TAGS = "tags"
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
const SEARCH_FIELD_OWNER_REFERENCES = "ownerReferences"
const SEARCH_FIELD_FOLDER = "folder"
const SEARCH_FIELD_CREATED = "created"

View File

@@ -48,6 +48,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
"id": "something"
},
"managedBy": "repo:something",
"ownerReferences": [
"iam.grafana.app/Team/engineering",
"iam.grafana.app/User/test"
],
"source": {
"path": "path/in/system.json",
"checksum": "xyz"

View File

@@ -16,10 +16,41 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
for _, v := range req.Options.Fields {
if v.Key == "metadata.name" && v.Operator == `=` {
names = v.Values
continue
}
// TODO: support other field selectors
// Search by owner reference
if v.Key == "search.ownerReference" {
if len(req.Options.Fields) > 1 {
return &resourcepb.ListResponse{
Error: NewBadRequestError("multiple fields found"),
}
}
results, err := s.Search(ctx, &resourcepb.ResourceSearchRequest{
Fields: []string{}, // no extra fields
Options: &resourcepb.ListOptions{
Key: req.Options.Key,
Fields: []*resourcepb.Requirement{{
Key: SEARCH_FIELD_OWNER_REFERENCES,
Operator: v.Operator,
Values: v.Values,
}},
},
})
if err != nil {
return &resourcepb.ListResponse{
Error: AsErrorResult(err),
}
}
if len(results.Results.Rows) < 1 { // nothing found
return &resourcepb.ListResponse{
ResourceVersion: 1, // TODO, search result should include when it was indexed
}
}
for _, res := range results.Results.Rows {
names = append(names, res.Key.Name)
}
}
}
// The required names
@@ -42,9 +73,6 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
Value: found.Value,
ResourceVersion: found.ResourceVersion,
})
if found.ResourceVersion > rsp.ResourceVersion {
rsp.ResourceVersion = found.ResourceVersion
}
}
}
return rsp

View File

@@ -22,7 +22,6 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/dskit/backoff"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apimachinery/validation"
"github.com/grafana/grafana/pkg/infra/log"

View File

@@ -13,7 +13,16 @@
"grafana.app/repoPath": "path/in/system.json",
"grafana.app/repoHash": "xyz",
"grafana.app/updatedTimestamp": "2024-07-01T10:11:12Z"
}
},
"ownerReferences": [{
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "Team",
"name": "engineering"
}, {
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "User",
"name": "test"
}]
},
"spec": {
"title": "Test Playlist from Unified Storage",

View File

@@ -1559,17 +1559,20 @@ var termFields = []string{
// Convert a "requirement" into a bleve query
func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query, *resourcepb.ErrorResult) {
switch selection.Operator(req.Operator) {
case selection.Equals, selection.DoubleEquals:
case selection.Equals:
if len(req.Values) == 0 {
return query.NewMatchAllQuery(), nil
}
// FIXME: special case for login and email to use term query only because those fields are using keyword analyzer
// This should be fixed by using the info from the schema
if (req.Key == "login" || req.Key == "email") && len(req.Values) == 1 {
tq := bleve.NewTermQuery(req.Values[0])
tq.SetField(prefix + req.Key)
return tq, nil
if len(req.Values) == 1 {
switch req.Key {
case "login", "email", resource.SEARCH_FIELD_OWNER_REFERENCES:
tq := bleve.NewTermQuery(req.Values[0])
tq.SetField(prefix + req.Key)
return tq, nil
}
}
if len(req.Values) == 1 {
@@ -1585,11 +1588,6 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
return query.NewConjunctionQuery(conjuncts), nil
case selection.NotEquals:
case selection.DoesNotExist:
case selection.GreaterThan:
case selection.LessThan:
case selection.Exists:
case selection.In:
if len(req.Values) == 0 {
return query.NewMatchAllQuery(), nil
@@ -1622,6 +1620,14 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
boolQuery.AddMust(notEmptyQuery)
return boolQuery, nil
// will fall through to the BadRequestError
case selection.DoubleEquals:
case selection.NotEquals:
case selection.DoesNotExist:
case selection.GreaterThan:
case selection.LessThan:
case selection.Exists:
}
return nil, resource.NewBadRequestError(
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),

View File

@@ -60,7 +60,7 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
tagsMapping := &mapping.FieldMapping{
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_TAGS,
Type: "text",
Analyzer: keyword.Name,
@@ -69,8 +69,18 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: false,
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
})
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_OWNER_REFERENCES, &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_OWNER_REFERENCES,
Type: "text",
Analyzer: keyword.Name,
Store: false,
Index: true,
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: false,
})
folderMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_FOLDER,

View File

@@ -36,6 +36,7 @@ func TestDocumentMapping(t *testing.T) {
Checksum: "ooo",
TimestampMillis: 1234,
},
OwnerReferences: []string{"iam.grafana.app/Team/devops", "iam.grafana.app/User/xyz"},
}
data.UpdateCopyFields()
@@ -49,5 +50,5 @@ func TestDocumentMapping(t *testing.T) {
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
fmt.Printf("DOC: size %d\n", doc.Size())
require.Equal(t, 17, len(doc.Fields))
require.Equal(t, 19, len(doc.Fields))
}

View File

@@ -16,5 +16,9 @@
"kind": "repo",
"id": "MyGIT"
},
"managedBy": "repo:MyGIT"
"managedBy": "repo:MyGIT",
"ownerReferences": [
"iam.grafana.app/Team/engineering",
"iam.grafana.app/User/test"
]
}

View File

@@ -9,7 +9,16 @@
"annotations": {
"grafana.app/createdBy": "user:1",
"grafana.app/repoName": "MyGIT"
}
},
"ownerReferences": [{
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "Team",
"name": "engineering"
}, {
"apiVersion": "iam.grafana.app/v1alpha1",
"kind": "User",
"name": "test"
}]
},
"spec": {
"title": "test-aaa"

View File

@@ -2183,3 +2183,79 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
require.Equal(t, expectedLabels, accessor.GetLabels())
require.Equal(t, expectedAnnotations, accessor.GetAnnotations())
}
// Test finding folders with an owner
func TestIntegrationFolderWithOwner(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableAnonymous: true,
AppModeProduction: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
folders.RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
},
"dashboards.dashboard.grafana.app": {
DualWriterMode: grafanarest.Mode5,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
})
client := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvr,
})
// Without owner
folder := &unstructured.Unstructured{
Object: map[string]any{
"spec": map[string]any{
"title": "Folder without owner",
},
},
}
folder.SetName("folderA")
out, err := client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, folder.GetName(), out.GetName())
// with owner
folder = &unstructured.Unstructured{
Object: map[string]any{
"spec": map[string]any{
"title": "Folder with owner",
},
},
}
folder.SetName("folderB")
folder.SetOwnerReferences([]metav1.OwnerReference{{
APIVersion: "iam.grafana.app/v0alpha1",
Kind: "Team",
Name: "engineering",
UID: "123456", // required by k8s
}})
out, err = client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, folder.GetName(), out.GetName())
// Get everything
results, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
require.Equal(t, []string{"folderA", "folderB"}, getNames(results.Items))
// Find results with a specific owner
results, err = client.Resource.List(context.Background(), metav1.ListOptions{
FieldSelector: "search.ownerReference=iam.grafana.app/Team/engineering",
})
require.NoError(t, err)
require.Equal(t, []string{"folderB"}, getNames(results.Items))
}
func getNames(items []unstructured.Unstructured) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item.GetName())
}
return names
}

View File

@@ -1864,6 +1864,25 @@
"format": "int64"
}
},
{
"name": "ownerReference",
"in": "query",
"description": "filter by owner reference in the format {Group}/{Kind}/{Name}",
"schema": {
"type": "string"
},
"examples": {
"": {},
"team": {
"summary": "Team owner reference",
"value": "iam.grafana.app/Team/xyz"
},
"user": {
"summary": "User owner reference",
"value": "iam.grafana.app/User/abc"
}
}
},
{
"name": "explain",
"in": "query",