UnifiedSearch: Introduce a ResourceIndex interface and bleve implementation (#96826)
Co-authored-by: Scott Lepper <scott.lepper@gmail.com>
This commit is contained in:
co-authored by
Scott Lepper
parent
bbae396db4
commit
c6848d4b68
@@ -159,11 +159,8 @@ func NewIndexableDocument(key *ResourceKey, rv int64, obj utils.GrafanaMetaAcces
|
||||
return doc
|
||||
}
|
||||
|
||||
func StandardDocumentBuilder() DocumentBuilderInfo {
|
||||
return DocumentBuilderInfo{
|
||||
Builder: &standardDocumentBuilder{},
|
||||
Fields: StandardSearchFields(),
|
||||
}
|
||||
func StandardDocumentBuilder() DocumentBuilder {
|
||||
return &standardDocumentBuilder{}
|
||||
}
|
||||
|
||||
type standardDocumentBuilder struct{}
|
||||
@@ -295,6 +292,30 @@ func StandardSearchFields() SearchableDocumentFields {
|
||||
FreeText: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_TAGS,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
IsArray: true,
|
||||
Description: "Unique tags",
|
||||
Properties: &ResourceTableColumnDefinition_Properties{
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_FOLDER,
|
||||
Type: ResourceTableColumnDefinition_STRING,
|
||||
Description: "Kubernetes name for the folder",
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_RV,
|
||||
Type: ResourceTableColumnDefinition_INT64,
|
||||
Description: "resource version",
|
||||
},
|
||||
{
|
||||
Name: SEARCH_FIELD_CREATED,
|
||||
Type: ResourceTableColumnDefinition_INT64,
|
||||
Description: "created timestamp", // date?
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic("failed to initialize standard search fields")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestStandardDocumentBuilder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := StandardDocumentBuilder().Builder
|
||||
builder := StandardDocumentBuilder()
|
||||
|
||||
body, err := os.ReadFile("testdata/playlist-resource.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,7 @@ message WatchEvent {
|
||||
Resource previous = 4;
|
||||
}
|
||||
|
||||
// This will soon be deprecated/replaced with ResourceSearchRequest
|
||||
message SearchRequest {
|
||||
// query string for chosen implementation (currently just bleve)
|
||||
string query = 1;
|
||||
@@ -342,6 +343,92 @@ message SearchRequest {
|
||||
repeated string filters = 10;
|
||||
}
|
||||
|
||||
// Search within a single resource
|
||||
message ResourceSearchRequest {
|
||||
message Sort {
|
||||
string field = 1;
|
||||
bool desc = 2; // defaults to ascending
|
||||
}
|
||||
|
||||
message Facet {
|
||||
string field = 1;
|
||||
int64 limit = 2;
|
||||
// For now, only term queries, eventually?
|
||||
// numeric queries
|
||||
// date queries
|
||||
}
|
||||
|
||||
// The key must include namespace + group + resource
|
||||
ListOptions options = 1;
|
||||
|
||||
// To search additional resource types, add additional keys to this list
|
||||
// NOTE: queries will only support federation across kinds with common fields
|
||||
repeated ResourceKey federated = 2;
|
||||
|
||||
// When a query exists, it is parsed and used to influence
|
||||
// query string for chosen implementation (currently just bleve)
|
||||
// The score is only relevant when a query exists
|
||||
string query = 3;
|
||||
|
||||
// max results
|
||||
int64 limit = 4;
|
||||
|
||||
// where to start the query (eg, From)
|
||||
int64 offset = 5;
|
||||
|
||||
// sorting
|
||||
repeated Sort sortBy = 6;
|
||||
|
||||
// calculate field statistics
|
||||
map<string,Facet> facet = 7;
|
||||
|
||||
// the return fields (empty will return everything)
|
||||
repeated string fields = 8;
|
||||
|
||||
// explain each result (added to the each row)
|
||||
bool explain = 9;
|
||||
}
|
||||
|
||||
message ResourceSearchResponse {
|
||||
message Facet {
|
||||
string field = 1;
|
||||
// The distinct terms
|
||||
int64 total = 2;
|
||||
// The number of documents that do *not* have this field
|
||||
int64 missing = 3;
|
||||
// Top term stats
|
||||
repeated TermFacet terms = 4;
|
||||
// numeric range
|
||||
// date range facets
|
||||
}
|
||||
|
||||
message TermFacet {
|
||||
string term = 1;
|
||||
int64 count = 2;
|
||||
}
|
||||
|
||||
// Error details
|
||||
ErrorResult error = 1;
|
||||
|
||||
// All results exist within this key
|
||||
ResourceKey key = 2;
|
||||
|
||||
// Query results
|
||||
ResourceTable results = 3;
|
||||
|
||||
// The total hit count
|
||||
uint64 total_hits = 4;
|
||||
|
||||
// indicates how expensive was the query with respect to bytes read
|
||||
uint64 query_cost = 5;
|
||||
|
||||
// maximum score across all fields
|
||||
double max_score = 6;
|
||||
|
||||
// Facet results
|
||||
map<string,Facet> facet = 7;
|
||||
}
|
||||
|
||||
message GroupBy {
|
||||
string name = 1;
|
||||
int64 limit = 2;
|
||||
@@ -352,6 +439,7 @@ message Group {
|
||||
int64 count = 2;
|
||||
}
|
||||
|
||||
// This will soon be deprecated/replaced with ResourceSearchResponse
|
||||
message SearchResponse {
|
||||
repeated ResourceWrapper items = 1;
|
||||
repeated Group groups = 2;
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
)
|
||||
|
||||
type NamespacedResource struct {
|
||||
@@ -20,8 +22,52 @@ type NamespacedResource struct {
|
||||
Resource string
|
||||
}
|
||||
|
||||
// All fields are set
|
||||
func (s *NamespacedResource) Valid() bool {
|
||||
return s.Namespace != "" && s.Group != "" && s.Resource != ""
|
||||
}
|
||||
|
||||
type ResourceIndex interface {
|
||||
// Add a document to the index. Note it may not be searchable until after flush is called
|
||||
Write(doc *IndexableDocument) error
|
||||
|
||||
// Mark a resource as deleted. Note it may not be searchable until after flush is called
|
||||
Delete(key *ResourceKey) error
|
||||
|
||||
// Make sure any changes to the index are flushed and available in the next search/origin calls
|
||||
Flush() error
|
||||
|
||||
// Search within a namespaced resource
|
||||
// When working with federated queries, the additional indexes will be passed in explicitly
|
||||
Search(ctx context.Context, access authz.AccessClient, req *ResourceSearchRequest, federate []ResourceIndex) (*ResourceSearchResponse, error)
|
||||
|
||||
// Execute an origin query -- access control is not not checked for each item
|
||||
// NOTE: this will likely be used for provisioning, or it will be removed
|
||||
Origin(ctx context.Context, req *OriginRequest) (*OriginResponse, error)
|
||||
}
|
||||
|
||||
// SearchBackend contains the technology specific logic to support search
|
||||
type SearchBackend interface {
|
||||
// TODO
|
||||
// This will return nil if the key does not exist
|
||||
GetIndex(ctx context.Context, key NamespacedResource) (ResourceIndex, error)
|
||||
|
||||
// Build an index from scratch
|
||||
BuildIndex(ctx context.Context,
|
||||
key NamespacedResource,
|
||||
|
||||
// When the size is known, it will be passed along here
|
||||
// Depending on the size, the backend may choose different options (eg: memory vs disk)
|
||||
size int64,
|
||||
|
||||
// The last known resource version (can be used to know that nothing has changed)
|
||||
resourceVersion int64,
|
||||
|
||||
// The non-standard index fields
|
||||
fields SearchableDocumentFields,
|
||||
|
||||
// The builder will write all documents before returning
|
||||
builder func(index ResourceIndex) (int64, error),
|
||||
) (ResourceIndex, error)
|
||||
}
|
||||
|
||||
const tracingPrexfixSearch = "unified_search."
|
||||
@@ -119,7 +165,7 @@ func (s *searchSupport) init(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (any, int64, error) {
|
||||
func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size int64, rv int64) (ResourceIndex, int64, error) {
|
||||
_, span := s.tracer.Start(ctx, tracingPrexfixSearch+"Build")
|
||||
defer span.End()
|
||||
|
||||
@@ -127,10 +173,57 @@ func (s *searchSupport) build(ctx context.Context, nsr NamespacedResource, size
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
fields := s.builders.GetFields(nsr)
|
||||
|
||||
s.log.Debug(fmt.Sprintf("TODO, build %+v (size:%d, rv:%d) // builder:%+v\n", nsr, size, rv, builder))
|
||||
|
||||
return nil, 0, nil
|
||||
key := &ResourceKey{
|
||||
Group: nsr.Group,
|
||||
Resource: nsr.Resource,
|
||||
Namespace: nsr.Namespace,
|
||||
}
|
||||
index, err := s.search.BuildIndex(ctx, nsr, size, rv, fields, func(index ResourceIndex) (int64, error) {
|
||||
rv, err = s.storage.ListIterator(ctx, &ListRequest{
|
||||
Limit: 1000000000000, // big number
|
||||
Options: &ListOptions{
|
||||
Key: key,
|
||||
},
|
||||
}, func(iter ListIterator) error {
|
||||
for iter.Next() {
|
||||
if err = iter.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the key name
|
||||
// Or should we read it from the body?
|
||||
key.Name = iter.Name()
|
||||
|
||||
// Convert it to an indexable document
|
||||
doc, err := builder.BuildDocument(ctx, key, iter.ResourceVersion(), iter.Value())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// And finally write it to the index
|
||||
if err = index.Write(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
return rv, err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = index.Flush()
|
||||
}
|
||||
|
||||
// rv is the last RV we read. when watching, we must add all events since that time
|
||||
return index, rv, err
|
||||
}
|
||||
|
||||
type builderCache struct {
|
||||
@@ -140,6 +233,9 @@ type builderCache struct {
|
||||
// Possible blob support
|
||||
blob BlobSupport
|
||||
|
||||
// searchable fields initialized once on startup
|
||||
fields map[schema.GroupResource]SearchableDocumentFields
|
||||
|
||||
// lookup by group, then resource (namespace)
|
||||
// This is only modified at startup, so we do not need mutex for access
|
||||
lookup map[string]map[string]DocumentBuilderInfo
|
||||
@@ -151,6 +247,7 @@ type builderCache struct {
|
||||
|
||||
func newBuilderCache(cfg []DocumentBuilderInfo, nsCacheSize int, ttl time.Duration) (*builderCache, error) {
|
||||
cache := &builderCache{
|
||||
fields: make(map[schema.GroupResource]SearchableDocumentFields),
|
||||
lookup: make(map[string]map[string]DocumentBuilderInfo),
|
||||
ns: expirable.NewLRU[NamespacedResource, DocumentBuilder](nsCacheSize, nil, ttl),
|
||||
}
|
||||
@@ -173,10 +270,17 @@ func newBuilderCache(cfg []DocumentBuilderInfo, nsCacheSize int, ttl time.Durati
|
||||
cache.lookup[b.GroupResource.Group] = g
|
||||
}
|
||||
g[b.GroupResource.Resource] = b
|
||||
|
||||
// Any custom fields
|
||||
cache.fields[b.GroupResource] = b.Fields
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (s *builderCache) GetFields(key NamespacedResource) SearchableDocumentFields {
|
||||
return s.fields[schema.GroupResource{Group: key.Group, Resource: key.Resource}]
|
||||
}
|
||||
|
||||
// context is typically background. Holds an LRU cache for a
|
||||
func (s *builderCache) get(ctx context.Context, key NamespacedResource) (DocumentBuilder, error) {
|
||||
g, ok := s.lookup[key.Group]
|
||||
|
||||
@@ -7,13 +7,18 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
reflect "reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data/utils/jsoniter"
|
||||
)
|
||||
|
||||
@@ -74,6 +79,10 @@ func (x *ResourceTable) ToK8s() (metav1.Table, error) {
|
||||
}
|
||||
} else if r.Key != nil {
|
||||
obj := &metav1.PartialObjectMetadata{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: r.Key.Resource, // :(
|
||||
APIVersion: r.Key.Group, // :(
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: r.Key.Name,
|
||||
Namespace: r.Key.Namespace,
|
||||
@@ -102,6 +111,8 @@ type TableBuilder struct {
|
||||
hasDuplicateNames bool
|
||||
}
|
||||
|
||||
type ResourceColumnEncoder = func(v any) ([]byte, error)
|
||||
|
||||
func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, error) {
|
||||
table := &TableBuilder{
|
||||
ResourceTable: ResourceTable{
|
||||
@@ -124,6 +135,15 @@ func NewTableBuilder(cols []*ResourceTableColumnDefinition) (*TableBuilder, erro
|
||||
return table, err
|
||||
}
|
||||
|
||||
func (x *TableBuilder) Encoders() []ResourceColumnEncoder {
|
||||
encoders := make([]ResourceColumnEncoder, len(x.Columns))
|
||||
for i, f := range x.Columns {
|
||||
v := x.lookup[f.Name]
|
||||
encoders[i] = v.Encode
|
||||
}
|
||||
return encoders
|
||||
}
|
||||
|
||||
func (x *TableBuilder) AddRow(key *ResourceKey, rv int64, vals map[string]any) error {
|
||||
row := &ResourceTableRow{
|
||||
Key: key,
|
||||
@@ -395,6 +415,8 @@ func (x *resourceTableColumn) Encode(v any) ([]byte, error) {
|
||||
f = int64(typed)
|
||||
case float32:
|
||||
f = int64(typed)
|
||||
case float64:
|
||||
f = int64(typed)
|
||||
case uint64:
|
||||
f = int64(typed)
|
||||
case uint:
|
||||
@@ -547,3 +569,29 @@ func (x *resourceTableColumn) Decode(buff []byte) (any, error) {
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// AssertTableSnapshot will match a ResourceTable vs the saved value
|
||||
func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) {
|
||||
t.Helper()
|
||||
|
||||
k8sTable, err := table.ToK8s()
|
||||
require.NoError(t, err, "unable to create table response", path)
|
||||
actual, err := json.MarshalIndent(k8sTable, "", " ")
|
||||
require.NoError(t, err, "unable to write table json", path)
|
||||
|
||||
// Safe to disable, this is a test.
|
||||
// nolint:gosec
|
||||
expected, err := os.ReadFile(path)
|
||||
if err != nil || len(expected) < 1 {
|
||||
assert.Fail(t, "missing file: %s", path)
|
||||
} else if assert.JSONEq(t, string(expected), string(actual)) {
|
||||
return // everything is OK
|
||||
}
|
||||
|
||||
// Write the snapshot
|
||||
// Safe to disable, this is a test.
|
||||
// nolint:gosec
|
||||
err = os.WriteFile(path, actual, 0600)
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("Updated table snapshot: %s\n", path)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,15 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// AssertTableSnapshot will match a ResourceTable vs the saved value
|
||||
func AssertTableSnapshot(t *testing.T, path string, table *ResourceTable) {
|
||||
t.Helper()
|
||||
|
||||
k8sTable, err := table.ToK8s()
|
||||
require.NoError(t, err, "unable to create table response", path)
|
||||
actual, err := json.MarshalIndent(k8sTable, "", " ")
|
||||
require.NoError(t, err, "unable to write table json", path)
|
||||
|
||||
// Safe to disable, this is a test.
|
||||
// nolint:gosec
|
||||
expected, err := os.ReadFile(path)
|
||||
if err != nil || len(expected) < 1 {
|
||||
assert.Fail(t, "missing file")
|
||||
} else if assert.JSONEq(t, string(expected), string(actual)) {
|
||||
return // everything is OK
|
||||
}
|
||||
|
||||
// Write the snapshot
|
||||
// Safe to disable, this is a test.
|
||||
// nolint:gosec
|
||||
err = os.WriteFile(path, actual, 0600)
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("Updated table snapshot: %s\n", path)
|
||||
}
|
||||
|
||||
func TestTableFormat(t *testing.T) {
|
||||
columns := []*ResourceTableColumnDefinition{
|
||||
{
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
]
|
||||
],
|
||||
"object": {
|
||||
"kind": "xyz",
|
||||
"apiVersion": "ggg",
|
||||
"metadata": {
|
||||
"name": "aaa",
|
||||
"namespace": "default",
|
||||
@@ -60,6 +62,8 @@
|
||||
]
|
||||
],
|
||||
"object": {
|
||||
"kind": "xyz",
|
||||
"apiVersion": "ggg",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "default",
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/search"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
const tracingPrexfixBleve = "unified_search.bleve."
|
||||
|
||||
var _ resource.SearchBackend = &bleveBackend{}
|
||||
var _ resource.ResourceIndex = &bleveIndex{}
|
||||
|
||||
type bleveOptions struct {
|
||||
// The root folder where file objects are saved
|
||||
Root string
|
||||
|
||||
// The resource count where values switch from memory to file based
|
||||
FileThreshold int64
|
||||
|
||||
// How big should a batch get before flushing
|
||||
// ?? not totally sure the units
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
type bleveBackend struct {
|
||||
tracer trace.Tracer
|
||||
log *slog.Logger
|
||||
opts bleveOptions
|
||||
|
||||
// cache info
|
||||
cache map[resource.NamespacedResource]*bleveIndex
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewBleveBackend(opts bleveOptions, tracer trace.Tracer, reg prometheus.Registerer) *bleveBackend {
|
||||
b := &bleveBackend{
|
||||
log: slog.Default().With("logger", "bleve-backend"),
|
||||
tracer: tracer,
|
||||
cache: make(map[resource.NamespacedResource]*bleveIndex),
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
if reg != nil {
|
||||
b.log.Info("TODO, register metrics collectors!")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// This will return nil if the key does not exist
|
||||
func (b *bleveBackend) GetIndex(ctx context.Context, key resource.NamespacedResource) (resource.ResourceIndex, error) {
|
||||
b.cacheMu.RLock()
|
||||
defer b.cacheMu.RUnlock()
|
||||
|
||||
idx, ok := b.cache[key]
|
||||
if ok {
|
||||
return idx, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build an index from scratch
|
||||
func (b *bleveBackend) BuildIndex(ctx context.Context,
|
||||
key resource.NamespacedResource,
|
||||
|
||||
// When the size is known, it will be passed along here
|
||||
// Depending on the size, the backend may choose different options (eg: memory vs disk)
|
||||
size int64,
|
||||
|
||||
// The last known resource version can be used to know that we can skip calling the builder
|
||||
resourceVersion int64,
|
||||
|
||||
// the non-standard searchable fields
|
||||
fields resource.SearchableDocumentFields,
|
||||
|
||||
// The builder will write all documents before returning
|
||||
builder func(index resource.ResourceIndex) (int64, error),
|
||||
) (resource.ResourceIndex, error) {
|
||||
b.cacheMu.Lock()
|
||||
defer b.cacheMu.Unlock()
|
||||
|
||||
_, span := b.tracer.Start(ctx, tracingPrexfixBleve+"BuildIndex")
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
var index bleve.Index
|
||||
|
||||
mapper := getBleveMappings(fields)
|
||||
|
||||
if size > b.opts.FileThreshold {
|
||||
dir := filepath.Join(b.opts.Root, key.Namespace, fmt.Sprintf("%s.%s", key.Resource, key.Group))
|
||||
index, err = bleve.New(dir, mapper)
|
||||
if err == nil {
|
||||
b.log.Info("TODO, check last RV so we can see if the numbers have changed", "dir", dir)
|
||||
}
|
||||
} else {
|
||||
index, err = bleve.NewMemOnly(mapper)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Batch all the changes
|
||||
idx := &bleveIndex{
|
||||
key: key,
|
||||
index: index,
|
||||
batch: index.NewBatch(),
|
||||
batchSize: b.opts.BatchSize,
|
||||
fields: fields,
|
||||
standard: resource.StandardSearchFields(),
|
||||
}
|
||||
|
||||
idx.allFields, err = getAllFields(idx.standard, fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = builder(idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Flush the batch
|
||||
err = idx.Flush()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.cache[key] = idx
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
type bleveIndex struct {
|
||||
key resource.NamespacedResource
|
||||
index bleve.Index
|
||||
|
||||
standard resource.SearchableDocumentFields
|
||||
fields resource.SearchableDocumentFields
|
||||
|
||||
// The values returned with all
|
||||
allFields []*resource.ResourceTableColumnDefinition
|
||||
|
||||
// only valid in single thread
|
||||
batch *bleve.Batch
|
||||
batchSize int // ??? not totally sure the units here
|
||||
}
|
||||
|
||||
// Write implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Write(v *resource.IndexableDocument) error {
|
||||
// remove references (for now!)
|
||||
v.References = nil
|
||||
if b.batch != nil {
|
||||
err := b.batch.Index(v.Key.SearchID(), v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() > b.batchSize {
|
||||
err = b.index.Batch(b.batch)
|
||||
b.batch.Reset() // clear the batch
|
||||
}
|
||||
return err // nil
|
||||
}
|
||||
return b.index.Index(v.Key.SearchID(), v)
|
||||
}
|
||||
|
||||
// Delete implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Delete(key *resource.ResourceKey) error {
|
||||
if b.batch != nil {
|
||||
return fmt.Errorf("unexpected delete while building batch")
|
||||
}
|
||||
return b.index.Delete(key.SearchID())
|
||||
}
|
||||
|
||||
// Flush implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Flush() (err error) {
|
||||
if b.batch != nil {
|
||||
err = b.index.Batch(b.batch)
|
||||
b.batch.Reset()
|
||||
b.batch = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Origin implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Origin(ctx context.Context, req *resource.OriginRequest) (*resource.OriginResponse, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// Search implements resource.DocumentIndex.
|
||||
func (b *bleveIndex) Search(
|
||||
ctx context.Context,
|
||||
access authz.AccessClient,
|
||||
req *resource.ResourceSearchRequest,
|
||||
federate []resource.ResourceIndex, // For federated queries, these will match the values in req.federate
|
||||
) (*resource.ResourceSearchResponse, error) {
|
||||
if req.Options == nil || req.Options.Key == nil {
|
||||
return &resource.ResourceSearchResponse{
|
||||
Error: resource.NewBadRequestError("missing query key"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
response := &resource.ResourceSearchResponse{
|
||||
Error: b.verifyKey(req.Options.Key),
|
||||
}
|
||||
if response.Error != nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Verifies the index federation
|
||||
index, err := b.getIndex(req, federate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert protobuf request to bleve request
|
||||
searchrequest, e := toBleveSearchRequest(req, access)
|
||||
if e != nil {
|
||||
response.Error = e
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Show all fields when nothing is selected
|
||||
if len(searchrequest.Fields) < 1 && req.Limit > 0 {
|
||||
f, err := b.index.Fields()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
searchrequest.Fields = f
|
||||
}
|
||||
|
||||
res, err := index.Search(searchrequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.TotalHits = res.Total
|
||||
response.QueryCost = res.Cost
|
||||
response.MaxScore = res.MaxScore
|
||||
|
||||
response.Results, err = b.hitsToTable(searchrequest.Fields, res.Hits, req.Explain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write frame as JSON
|
||||
//response.Frame, err = frame.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parse the facet fields
|
||||
for k, v := range res.Facets {
|
||||
f := &resource.ResourceSearchResponse_Facet{
|
||||
Field: v.Field,
|
||||
Total: int64(v.Total),
|
||||
Missing: int64(v.Missing),
|
||||
}
|
||||
if v.Terms != nil {
|
||||
for _, t := range v.Terms.Terms() {
|
||||
f.Terms = append(f.Terms, &resource.ResourceSearchResponse_TermFacet{
|
||||
Term: t.Term,
|
||||
Count: int64(t.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
if response.Facet == nil {
|
||||
response.Facet = make(map[string]*resource.ResourceSearchResponse_Facet)
|
||||
}
|
||||
response.Facet[k] = f
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// make sure the request key matches the index
|
||||
func (b *bleveIndex) verifyKey(key *resource.ResourceKey) *resource.ErrorResult {
|
||||
if key.Namespace != b.key.Namespace {
|
||||
return resource.NewBadRequestError("namespace mismatch (expected " + b.key.Namespace + ")")
|
||||
}
|
||||
if key.Group != b.key.Group {
|
||||
return resource.NewBadRequestError("group mismatch (expected " + b.key.Group + ")")
|
||||
}
|
||||
if key.Resource != b.key.Resource {
|
||||
return resource.NewBadRequestError("resource mismatch (expected " + b.key.Resource + ")")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bleveIndex) getIndex(
|
||||
req *resource.ResourceSearchRequest,
|
||||
federate []resource.ResourceIndex,
|
||||
) (bleve.Index, error) {
|
||||
if len(req.Federated) != len(federate) {
|
||||
return nil, fmt.Errorf("federation is misconfigured")
|
||||
}
|
||||
|
||||
// Search across resources using
|
||||
// https://blevesearch.com/docs/IndexAlias/
|
||||
if len(federate) > 0 {
|
||||
all := []bleve.Index{b.index}
|
||||
for i, extra := range federate {
|
||||
typedindex, ok := extra.(*bleveIndex)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("federated indexes must be the same type")
|
||||
}
|
||||
if typedindex.verifyKey(req.Federated[i]) != nil {
|
||||
return nil, fmt.Errorf("federated index keys do not match")
|
||||
}
|
||||
all = append(all, typedindex.index)
|
||||
}
|
||||
return bleve.NewIndexAlias(all...), nil
|
||||
}
|
||||
return b.index, nil
|
||||
}
|
||||
|
||||
func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) {
|
||||
searchrequest := &bleve.SearchRequest{
|
||||
Fields: req.Fields,
|
||||
Size: int(req.Limit),
|
||||
From: int(req.Offset),
|
||||
Explain: req.Explain,
|
||||
}
|
||||
|
||||
// Currently everything is within an AND query
|
||||
queries := []query.Query{}
|
||||
if len(req.Options.Labels) > 0 {
|
||||
for _, v := range req.Options.Labels {
|
||||
q, err := requirementQuery(v, "labels.")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries = append(queries, q)
|
||||
}
|
||||
}
|
||||
if len(req.Options.Fields) > 0 {
|
||||
for _, v := range req.Options.Fields {
|
||||
q, err := requirementQuery(v, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries = append(queries, q)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Query != "" {
|
||||
// ??? Should expose the full power of query parsing here?
|
||||
// it is great for exploration, but also hard to change in the future
|
||||
q := bleve.NewQueryStringQuery(req.Query)
|
||||
queries = append(queries, q)
|
||||
}
|
||||
|
||||
if access != nil {
|
||||
// TODO AUTHZ!!!!
|
||||
// Need to add an authz filter into the mix
|
||||
// See: https://github.com/grafana/grafana/blob/v11.3.0/pkg/services/searchV2/bluge.go
|
||||
// NOTE, we likely want to pass in the already called checker because the resource server
|
||||
// will first need to check if we can see anything (or everything!) for this resource
|
||||
fmt.Printf("TODO... check authorization")
|
||||
}
|
||||
|
||||
switch len(queries) {
|
||||
case 0:
|
||||
searchrequest.Query = bleve.NewMatchAllQuery()
|
||||
case 1:
|
||||
searchrequest.Query = queries[0]
|
||||
default:
|
||||
searchrequest.Query = bleve.NewConjunctionQuery(queries...) // AND
|
||||
}
|
||||
|
||||
for k, v := range req.Facet {
|
||||
if searchrequest.Facets == nil {
|
||||
searchrequest.Facets = make(bleve.FacetsRequest)
|
||||
}
|
||||
searchrequest.Facets[k] = bleve.NewFacetRequest(v.Field, int(v.Limit))
|
||||
}
|
||||
|
||||
// Add the sort fields
|
||||
for _, sort := range req.SortBy {
|
||||
// hardcoded (for now)
|
||||
if strings.HasPrefix(sort.Field, "stats.") {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortField{
|
||||
Field: sort.Field,
|
||||
Desc: sort.Desc,
|
||||
Type: search.SortFieldAsNumber, // force for now!
|
||||
Mode: search.SortFieldDefault, // ???
|
||||
Missing: search.SortFieldMissingLast,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Default support
|
||||
input := sort.Field
|
||||
if sort.Desc {
|
||||
input = "-" + sort.Field
|
||||
}
|
||||
s := search.ParseSearchSortString(input)
|
||||
searchrequest.Sort = append(searchrequest.Sort, s)
|
||||
}
|
||||
|
||||
// Always sort by *something*, otherwise the order is unstable
|
||||
if len(searchrequest.Sort) == 0 {
|
||||
searchrequest.Sort = append(searchrequest.Sort, &search.SortDocID{
|
||||
Desc: false,
|
||||
})
|
||||
}
|
||||
|
||||
return searchrequest, nil
|
||||
}
|
||||
|
||||
// Convert a "requirement" into a bleve query
|
||||
func requirementQuery(req *resource.Requirement, prefix string) (query.Query, *resource.ErrorResult) {
|
||||
switch selection.Operator(req.Operator) {
|
||||
case selection.Equals, selection.DoubleEquals:
|
||||
if len(req.Values) != 1 {
|
||||
return nil, resource.NewBadRequestError("equals query can have one value")
|
||||
}
|
||||
q := query.NewMatchQuery(req.Values[0])
|
||||
q.FieldVal = prefix + req.Key
|
||||
return q, nil
|
||||
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
case selection.In:
|
||||
case selection.NotIn:
|
||||
}
|
||||
return nil, resource.NewBadRequestError(
|
||||
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *bleveIndex) hitsToTable(selectFields []string, hits search.DocumentMatchCollection, explain bool) (*resource.ResourceTable, error) {
|
||||
fields := []*resource.ResourceTableColumnDefinition{}
|
||||
for _, name := range selectFields {
|
||||
if name == "_all" {
|
||||
fields = b.allFields
|
||||
break
|
||||
}
|
||||
|
||||
f := b.standard.Field(name)
|
||||
if f == nil && b.fields != nil {
|
||||
f = b.fields.Field(name)
|
||||
}
|
||||
if f == nil {
|
||||
// Labels as a string
|
||||
if strings.HasPrefix(name, "labels.") {
|
||||
f = &resource.ResourceTableColumnDefinition{
|
||||
Name: name,
|
||||
Type: resource.ResourceTableColumnDefinition_STRING,
|
||||
}
|
||||
}
|
||||
|
||||
// return nil, fmt.Errorf("unknown response field: " + name)
|
||||
if f == nil {
|
||||
continue // OK for now
|
||||
}
|
||||
}
|
||||
fields = append(fields, f)
|
||||
}
|
||||
if explain {
|
||||
fields = append(fields, b.standard.Field(resource.SEARCH_FIELD_EXPLAIN))
|
||||
}
|
||||
|
||||
builder, err := resource.NewTableBuilder(fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoders := builder.Encoders()
|
||||
|
||||
table := &resource.ResourceTable{
|
||||
Columns: fields,
|
||||
Rows: make([]*resource.ResourceTableRow, hits.Len()),
|
||||
}
|
||||
for rowID, match := range hits {
|
||||
row := &resource.ResourceTableRow{
|
||||
Key: &resource.ResourceKey{},
|
||||
Cells: make([][]byte, len(fields)),
|
||||
}
|
||||
table.Rows[rowID] = row
|
||||
|
||||
err := row.Key.ReadSearchID(match.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, f := range fields {
|
||||
if f.Name == resource.SEARCH_FIELD_ID {
|
||||
row.Cells[i] = []byte(match.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// QUICK QUICK... more options yes
|
||||
v := match.Fields[f.Name]
|
||||
if v != nil {
|
||||
// Encode the value to protobuf
|
||||
row.Cells[i], err = encoders[i](v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error encoding (row:%d/col:%d) %v %w", rowID, i, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func getAllFields(standard resource.SearchableDocumentFields, custom resource.SearchableDocumentFields) ([]*resource.ResourceTableColumnDefinition, error) {
|
||||
fields := []*resource.ResourceTableColumnDefinition{
|
||||
standard.Field(resource.SEARCH_FIELD_ID),
|
||||
standard.Field(resource.SEARCH_FIELD_TITLE),
|
||||
standard.Field(resource.SEARCH_FIELD_TAGS),
|
||||
standard.Field(resource.SEARCH_FIELD_FOLDER),
|
||||
standard.Field(resource.SEARCH_FIELD_RV),
|
||||
standard.Field(resource.SEARCH_FIELD_CREATED),
|
||||
}
|
||||
|
||||
if custom != nil {
|
||||
for _, name := range custom.Fields() {
|
||||
f := custom.Field(name)
|
||||
if f.Priority > 10 {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, f)
|
||||
}
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field == nil {
|
||||
return nil, fmt.Errorf("invalid all field")
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
func getBleveMappings(fields resource.SearchableDocumentFields) mapping.IndexMapping {
|
||||
mapper := bleve.NewIndexMapping()
|
||||
mapper.DefaultMapping = getBleveDocMappings(fields)
|
||||
return mapper
|
||||
}
|
||||
|
||||
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
|
||||
mapper := bleve.NewDocumentStaticMapping()
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
Name: "title",
|
||||
Type: "text",
|
||||
// TODO - if we don't want title to be a keyword, we can use this
|
||||
// set the title field to use keyword analyzer so it sorts by the whole phrase
|
||||
// https://github.com/blevesearch/bleve/issues/417#issuecomment-245273022
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: true,
|
||||
IncludeInAll: true,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
Name: "description",
|
||||
Type: "text",
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
Name: "tags",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
mapper.AddFieldMapping(&mapping.FieldMapping{
|
||||
Name: "folder",
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: true,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: true, // will be needed for authz client
|
||||
})
|
||||
|
||||
mapper.Dynamic = true
|
||||
|
||||
return mapper
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/document"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
func TestDocumentMapping(t *testing.T) {
|
||||
mappings := getBleveMappings(nil)
|
||||
data := resource.IndexableDocument{
|
||||
Title: "title",
|
||||
Description: "descr",
|
||||
Tags: []string{"a", "b"},
|
||||
Created: 12345,
|
||||
Folder: "xyz",
|
||||
CreatedBy: "user:ryan",
|
||||
Labels: map[string]string{
|
||||
"a": "b",
|
||||
"x": "y",
|
||||
},
|
||||
RV: 1234,
|
||||
RepoInfo: &utils.ResourceRepositoryInfo{
|
||||
Name: "nnn",
|
||||
Path: "ppp",
|
||||
Hash: "hhh",
|
||||
},
|
||||
}
|
||||
|
||||
doc := document.NewDocument("id")
|
||||
err := mappings.MapDocument(doc, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, f := range doc.Fields {
|
||||
fmt.Printf("%s = %+v\n", f.Name(), f.Value())
|
||||
}
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 15, len(doc.Fields))
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/store/kind/dashboard"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
)
|
||||
|
||||
func TestBleveBackend(t *testing.T) {
|
||||
dashboardskey := &resource.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "dashboard.grafana.app",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
folderKey := &resource.ResourceKey{
|
||||
Namespace: dashboardskey.Namespace,
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
}
|
||||
tmpdir, err := os.CreateTemp("", "bleve-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := NewBleveBackend(
|
||||
bleveOptions{
|
||||
Root: tmpdir.Name(),
|
||||
FileThreshold: 5, // with more than 5 items we create a file on disk
|
||||
},
|
||||
tracing.NewNoopTracerService(),
|
||||
nil,
|
||||
)
|
||||
|
||||
rv := int64(10)
|
||||
ctx := context.Background()
|
||||
var dashboardsIndex resource.ResourceIndex
|
||||
var foldersIndex resource.ResourceIndex
|
||||
|
||||
t.Run("build dashboards", func(t *testing.T) {
|
||||
key := dashboardskey
|
||||
info, err := DashboardBuilder(func(ctx context.Context, namespace string, blob resource.BlobSupport) (resource.DocumentBuilder, error) {
|
||||
return &DashboardDocumentBuilder{
|
||||
Namespace: namespace,
|
||||
Blob: blob,
|
||||
Stats: NewDashboardStatsLookup(nil), // empty stats
|
||||
DatasourceLookup: dashboard.CreateDatasourceLookup([]*dashboard.DatasourceQueryResult{{}}),
|
||||
}, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}, 2, rv, info.Fields, func(index resource.ResourceIndex) (int64, error) {
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "aaa",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
},
|
||||
Title: "bbb (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
|
||||
DASHBOARD_ERRORS_TODAY: 25,
|
||||
},
|
||||
Tags: []string{"aa", "bb"},
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 2,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "bbb",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
},
|
||||
Title: "aaa (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
|
||||
DASHBOARD_ERRORS_TODAY: 40,
|
||||
},
|
||||
Tags: []string{"aa"},
|
||||
Labels: map[string]string{
|
||||
"region": "east",
|
||||
},
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 3,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "ccc",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "dash",
|
||||
},
|
||||
Title: "ccc (dash)",
|
||||
Folder: "xxx",
|
||||
Fields: map[string]any{
|
||||
DASHBOARD_LEGACY_ID: 12,
|
||||
},
|
||||
Tags: []string{"aa"},
|
||||
Labels: map[string]string{
|
||||
"region": "west",
|
||||
},
|
||||
})
|
||||
return rv, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, index)
|
||||
dashboardsIndex = index
|
||||
|
||||
rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: key,
|
||||
},
|
||||
Limit: 100000,
|
||||
SortBy: []*resource.ResourceSearchRequest_Sort{
|
||||
{Field: "title", Desc: true}, // ccc,bbb,aaa
|
||||
},
|
||||
Facet: map[string]*resource.ResourceSearchRequest_Facet{
|
||||
"tags": {
|
||||
Field: "tags",
|
||||
Limit: 100,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, rsp.Error)
|
||||
require.NotNil(t, rsp.Results)
|
||||
require.NotNil(t, rsp.Facet)
|
||||
|
||||
// Match the results
|
||||
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
|
||||
|
||||
// Get the tags facets
|
||||
facet, ok := rsp.Facet["tags"]
|
||||
require.True(t, ok)
|
||||
disp, err := json.MarshalIndent(facet, "", " ")
|
||||
require.NoError(t, err)
|
||||
//fmt.Printf("%s\n", disp)
|
||||
require.JSONEq(t, `{
|
||||
"field": "tags",
|
||||
"total": 4,
|
||||
"terms": [
|
||||
{
|
||||
"term": "aa",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"term": "bb",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}`, string(disp))
|
||||
})
|
||||
|
||||
t.Run("build folders", func(t *testing.T) {
|
||||
key := folderKey
|
||||
var fields resource.SearchableDocumentFields
|
||||
|
||||
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}, 2, rv, fields, func(index resource.ResourceIndex) (int64, error) {
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "zzz",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "folder",
|
||||
},
|
||||
Title: "zzz (folder)",
|
||||
})
|
||||
_ = index.Write(&resource.IndexableDocument{
|
||||
RV: 2,
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "yyy",
|
||||
Namespace: "ns",
|
||||
Group: "g",
|
||||
Resource: "folder",
|
||||
},
|
||||
Title: "yyy (folder)",
|
||||
Labels: map[string]string{
|
||||
"region": "west",
|
||||
},
|
||||
})
|
||||
return rv, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, index)
|
||||
foldersIndex = index
|
||||
|
||||
rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: key,
|
||||
},
|
||||
Limit: 100000,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, rsp.Error)
|
||||
require.NotNil(t, rsp.Results)
|
||||
require.Nil(t, rsp.Facet)
|
||||
|
||||
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-folder.json"), rsp.Results)
|
||||
})
|
||||
|
||||
t.Run("simple federation", func(t *testing.T) {
|
||||
// The other tests must run first to build the indexes
|
||||
require.NotNil(t, dashboardsIndex)
|
||||
require.NotNil(t, foldersIndex)
|
||||
|
||||
// Use a federated query to get both results together, sorted by title
|
||||
rsp, err := dashboardsIndex.Search(ctx, nil, &resource.ResourceSearchRequest{
|
||||
Options: &resource.ListOptions{
|
||||
Key: dashboardskey,
|
||||
},
|
||||
Fields: []string{
|
||||
"title", "_id",
|
||||
},
|
||||
Federated: []*resource.ResourceKey{
|
||||
folderKey, // This will join in the
|
||||
},
|
||||
Limit: 100000,
|
||||
SortBy: []*resource.ResourceSearchRequest_Sort{
|
||||
{Field: "title", Desc: false},
|
||||
},
|
||||
Facet: map[string]*resource.ResourceSearchRequest_Facet{
|
||||
"region": {
|
||||
Field: "labels.region",
|
||||
Limit: 100,
|
||||
},
|
||||
},
|
||||
}, []resource.ResourceIndex{foldersIndex}) // << note the folder index matches the federation request
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, rsp.Error)
|
||||
require.NotNil(t, rsp.Results)
|
||||
require.NotNil(t, rsp.Facet)
|
||||
|
||||
// Sorted across two indexes
|
||||
sorted := []string{}
|
||||
for _, row := range rsp.Results.Rows {
|
||||
sorted = append(sorted, string(row.Cells[0]))
|
||||
}
|
||||
require.Equal(t, []string{
|
||||
"aaa (dash)",
|
||||
"bbb (dash)",
|
||||
"ccc (dash)",
|
||||
"yyy (folder)",
|
||||
"zzz (folder)",
|
||||
}, sorted)
|
||||
|
||||
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-federated.json"), rsp.Results)
|
||||
|
||||
facet, ok := rsp.Facet["region"]
|
||||
require.True(t, ok)
|
||||
disp, err := json.MarshalIndent(facet, "", " ")
|
||||
require.NoError(t, err)
|
||||
// fmt.Printf("%s\n", disp)
|
||||
// NOTE, the west values come from *both* dashboards and folders
|
||||
require.JSONEq(t, `{
|
||||
"field": "labels.region",
|
||||
"total": 3,
|
||||
"missing": 2,
|
||||
"terms": [
|
||||
{
|
||||
"term": "west",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"term": "east",
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
}`, string(disp))
|
||||
})
|
||||
}
|
||||
@@ -26,7 +26,11 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui
|
||||
})
|
||||
|
||||
return []resource.DocumentBuilderInfo{
|
||||
resource.StandardDocumentBuilder(),
|
||||
// The default builder
|
||||
resource.DocumentBuilderInfo{
|
||||
Builder: resource.StandardDocumentBuilder(),
|
||||
},
|
||||
// Dashboard builder
|
||||
dashboards,
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestDashboardDocumentBuilder(t *testing.T) {
|
||||
})
|
||||
|
||||
// Standard
|
||||
builder = resource.StandardDocumentBuilder().Builder
|
||||
builder = resource.StandardDocumentBuilder()
|
||||
doSnapshotTests(t, builder, "folder", key, []string{
|
||||
"aaa",
|
||||
"bbb",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"columnDefinitions": [
|
||||
{
|
||||
"name": "_id",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Display name for the resource",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique tags",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "folder",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Kubernetes name for the folder",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "rv",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "resource version",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "created",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "created timestamp",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "schema_version",
|
||||
"type": "number",
|
||||
"format": "int32",
|
||||
"description": "Numeric version saying when the schema was saved",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "link_count",
|
||||
"type": "number",
|
||||
"format": "int32",
|
||||
"description": "How many links appear on the page",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "panel_types",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "How many links appear on the page",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/ccc",
|
||||
"ccc (dash)",
|
||||
[
|
||||
"aa"
|
||||
],
|
||||
"xxx",
|
||||
3,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "ccc",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/aaa",
|
||||
"bbb (dash)",
|
||||
[
|
||||
"aa",
|
||||
"bb"
|
||||
],
|
||||
"xxx",
|
||||
1,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "aaa",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/dash/bbb",
|
||||
"aaa (dash)",
|
||||
[
|
||||
"aa"
|
||||
],
|
||||
"xxx",
|
||||
2,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"columnDefinitions": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Display name for the resource",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "_id",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"aaa (dash)",
|
||||
"ns/g/dash/bbb"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "bbb",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"bbb (dash)",
|
||||
"ns/g/dash/aaa"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "aaa",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ccc (dash)",
|
||||
"ns/g/dash/ccc"
|
||||
],
|
||||
"object": {
|
||||
"kind": "dash",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "ccc",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"yyy (folder)",
|
||||
"ns/g/folder/yyy"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "yyy",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"zzz (folder)",
|
||||
"ns/g/folder/zzz"
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "zzz",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"columnDefinitions": [
|
||||
{
|
||||
"name": "_id",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique Identifier. {namespace}/{group}/{resource}/{name}",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Display name for the resource",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Unique tags",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "folder",
|
||||
"type": "string",
|
||||
"format": "",
|
||||
"description": "Kubernetes name for the folder",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "rv",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "resource version",
|
||||
"priority": 0
|
||||
},
|
||||
{
|
||||
"name": "created",
|
||||
"type": "number",
|
||||
"format": "int64",
|
||||
"description": "created timestamp",
|
||||
"priority": 0
|
||||
}
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/folder/yyy",
|
||||
"yyy (folder)",
|
||||
null,
|
||||
null,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "yyy",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
"ns/g/folder/zzz",
|
||||
"zzz (folder)",
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"object": {
|
||||
"kind": "folder",
|
||||
"apiVersion": "g",
|
||||
"metadata": {
|
||||
"name": "zzz",
|
||||
"namespace": "ns",
|
||||
"creationTimestamp": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user