mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
7 Commits
docs/add-t
...
index-owne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57c30681d | ||
|
|
b378907585 | ||
|
|
62bdae94ed | ||
|
|
0091b44b2a | ||
|
|
307e9cdce3 | ||
|
|
66eb5e35cd | ||
|
|
a95de85062 |
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user