Files
2025-06-24 14:54:07 -05:00

452 lines
14 KiB
Go

package resource
import (
"context"
"fmt"
"strings"
"sync"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
// Convert raw resource bytes into an IndexableDocument
type DocumentBuilder interface {
// Convert raw bytes into an document that can be written
BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*IndexableDocument, error)
}
// Registry of the searchable document fields
type SearchableDocumentFields interface {
Fields() []string
Field(name string) *resourcepb.ResourceTableColumnDefinition
}
// Some kinds will require special processing for their namespace
type NamespacedDocumentSupplier = func(ctx context.Context, namespace string, blob BlobSupport) (DocumentBuilder, error)
// Register how documents can be built for a resource
type DocumentBuilderInfo struct {
// The target resource (empty will be used to match anything)
GroupResource schema.GroupResource
// Defines the searchable fields
// NOTE: this does not include the root/common fields, only values specific to the the builder
Fields SearchableDocumentFields
// simple/static builders that do not depend on the environment can be declared once
Builder DocumentBuilder
// Complicated builders (eg dashboards!) will be declared dynamically and managed by the ResourceServer
Namespaced NamespacedDocumentSupplier
}
type DocumentBuilderSupplier interface {
GetDocumentBuilders() ([]DocumentBuilderInfo, error)
}
// IndexableDocument can be written to a ResourceIndex
// Although public, this is *NOT* an end user interface
type IndexableDocument struct {
// The resource key
Key *resourcepb.ResourceKey `json:"key"`
// The k8s name
Name string `json:"name,omitempty"`
// Resource version for the resource (if known)
RV int64 `json:"rv,omitempty"`
// The generic display name
Title string `json:"title,omitempty"`
// internal field for searching title with ngrams
TitleNgram string `json:"title_ngram,omitempty"`
// internal sort field for title ( don't set this directly )
TitlePhrase string `json:"title_phrase,omitempty"`
// A generic description -- helpful in global search
Description string `json:"description,omitempty"`
// Like dashboard tags
Tags []string `json:"tags,omitempty"`
// Generic metadata labels
Labels map[string]string `json:"labels,omitempty"`
// The folder (K8s name)
Folder string `json:"folder,omitempty"`
// The first time this resource was saved
Created int64 `json:"created,omitempty"`
// Who created the resource (will be in the form `user:uid`)
CreatedBy string `json:"createdBy,omitempty"`
// The last time a user updated the spec
Updated int64 `json:"updated,omitempty"`
// Who updated the resource (will be in the form `user:uid`)
UpdatedBy string `json:"updatedBy,omitempty"`
// Searchable nested keys
// The key should exist from the fields defined in DocumentBuilderInfo
// This should not contain duplicate information from the results above
// The meaning of these fields changes depending on the field type
// These values typically come from the Spec, but may also come from status
// metadata, annotations, or external data linked at index time
Fields map[string]any `json:"fields,omitempty"`
// Maintain a list of resource references.
// Someday this will likely be part of https://github.com/grafana/gamma
References ResourceReferences `json:"references,omitempty"`
// internal field for mapping references to kind ( don't set this directly )
Reference map[string][]string `json:"reference,omitempty"` // map of kind to list of names
// When the resource is managed by an upstream repository
Manager *utils.ManagerProperties `json:"manager,omitempty"`
// indexed only field for faceting manager info
ManagedBy string `json:"managedBy,omitempty"`
// When the manager knows about file paths
Source *utils.SourceProperties `json:"source,omitempty"`
}
func (m *IndexableDocument) UpdateCopyFields() *IndexableDocument {
m.TitleNgram = m.Title
m.TitlePhrase = strings.ToLower(m.Title) // Lowercase for case-insensitive sorting ?? in the analyzer?
if m.Manager != nil {
m.ManagedBy = fmt.Sprintf("%s:%s", m.Manager.Kind, m.Manager.Identity)
}
m.Reference = make(map[string][]string)
for _, ref := range m.References {
// Group and Version are ignored for now. This could be revisited.
m.Reference[ref.Kind] = append(m.Reference[ref.Kind], ref.Name)
}
return m
}
func (m *IndexableDocument) Type() string {
return m.Key.Resource
}
type ResourceReference struct {
Relation string `json:"relation"` // eg: depends-on
Group string `json:"group,omitempty"` // the api group
Version string `json:"version,omitempty"` // the api version
Kind string `json:"kind,omitempty"` // panel, data source (for now)
Name string `json:"name"` // the UID / panel name
}
func (m ResourceReference) String() string {
var sb strings.Builder
sb.WriteString(m.Relation)
sb.WriteString(">>")
sb.WriteString(m.Group)
if m.Version != "" {
sb.WriteString("/")
sb.WriteString(m.Version)
}
if m.Kind != "" {
sb.WriteString("/")
sb.WriteString(m.Kind)
}
sb.WriteString("/")
sb.WriteString(m.Name)
return sb.String()
}
// Sortable list of references
type ResourceReferences []ResourceReference
func (m ResourceReferences) Len() int { return len(m) }
func (m ResourceReferences) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
func (m ResourceReferences) Less(i, j int) bool {
a := m[i].String()
b := m[j].String()
return strings.Compare(a, b) > 0
}
// Create a new indexable document based on a generic k8s resource
func NewIndexableDocument(key *resourcepb.ResourceKey, rv int64, obj utils.GrafanaMetaAccessor) *IndexableDocument {
title := obj.FindTitle(key.Name)
if title == key.Name {
// TODO: something wrong with FindTitle
spec, err := obj.GetSpec()
if err == nil {
specValue, ok := spec.(map[string]any)
if ok {
specTitle, ok := specValue["title"].(string)
if ok {
title = specTitle
}
}
}
}
doc := &IndexableDocument{
Key: key,
RV: rv,
Name: key.Name,
Title: title, // We always want *something* to display
Labels: obj.GetLabels(),
Folder: obj.GetFolder(),
CreatedBy: obj.GetCreatedBy(),
UpdatedBy: obj.GetUpdatedBy(),
}
m, ok := obj.GetManagerProperties()
if ok {
doc.Manager = &m
doc.ManagedBy = fmt.Sprintf("%s:%s", m.Kind, m.Identity)
}
s, ok := obj.GetSourceProperties()
if ok {
doc.Source = &s
}
ts := obj.GetCreationTimestamp()
if !ts.Time.IsZero() {
doc.Created = ts.UnixMilli()
}
tt, err := obj.GetUpdatedTimestamp()
if err != nil && tt != nil {
doc.Updated = tt.UnixMilli()
}
return doc.UpdateCopyFields()
}
func StandardDocumentBuilder() DocumentBuilder {
return &standardDocumentBuilder{}
}
type standardDocumentBuilder struct{}
func (s *standardDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*IndexableDocument, error) {
tmp := &unstructured.Unstructured{}
err := tmp.UnmarshalJSON(value)
if err != nil {
return nil, err
}
obj, err := utils.MetaAccessor(tmp)
if err != nil {
return nil, err
}
doc := NewIndexableDocument(key, rv, obj)
return doc, nil
}
type searchableDocumentFields struct {
names []string
fields map[string]*resourceTableColumn
}
// This requires unique names
func NewSearchableDocumentFields(columns []*resourcepb.ResourceTableColumnDefinition) (SearchableDocumentFields, error) {
f := &searchableDocumentFields{
names: make([]string, len(columns)),
fields: make(map[string]*resourceTableColumn),
}
for i, c := range columns {
if f.fields[c.Name] != nil {
return nil, fmt.Errorf("duplicate name")
}
col, err := newResourceTableColumn(c, i)
if err != nil {
return nil, err
}
f.names[i] = c.Name
f.fields[c.Name] = col
}
return f, nil
}
func (x *searchableDocumentFields) Fields() []string {
return x.names
}
func (x *searchableDocumentFields) Field(name string) *resourcepb.ResourceTableColumnDefinition {
name = strings.TrimPrefix(name, SEARCH_FIELD_PREFIX)
f, ok := x.fields[name]
if ok {
return f.def
}
return nil
}
const SEARCH_FIELD_PREFIX = "fields."
const SEARCH_FIELD_ID = "_id" // {namespace}/{group}/{resource}/{name}
const SEARCH_FIELD_LEGACY_ID = utils.LabelKeyDeprecatedInternalID
const SEARCH_FIELD_KIND = "kind" // resource ( for federated index filtering )
const SEARCH_FIELD_GROUP_RESOURCE = "gr" // group/resource
const SEARCH_FIELD_NAMESPACE = "namespace"
const SEARCH_FIELD_NAME = "name"
const SEARCH_FIELD_RV = "rv"
const SEARCH_FIELD_TITLE = "title"
const SEARCH_FIELD_TITLE_NGRAM = "title_ngram"
const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title by full phrase
const SEARCH_FIELD_DESCRIPTION = "description"
const SEARCH_FIELD_TAGS = "tags"
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
const SEARCH_FIELD_FOLDER = "folder"
const SEARCH_FIELD_CREATED = "created"
const SEARCH_FIELD_CREATED_BY = "createdBy"
const SEARCH_FIELD_UPDATED = "updated"
const SEARCH_FIELD_UPDATED_BY = "updatedBy"
const SEARCH_FIELD_MANAGED_BY = "managedBy" // {kind}:{id}
const SEARCH_FIELD_MANAGER_KIND = "manager.kind"
const SEARCH_FIELD_MANAGER_ID = "manager.id"
const SEARCH_FIELD_SOURCE_PATH = "source.path"
const SEARCH_FIELD_SOURCE_CHECKSUM = "source.checksum"
const SEARCH_FIELD_SOURCE_TIME = "source.timestampMillis"
const SEARCH_FIELD_SCORE = "_score" // the match score
const SEARCH_FIELD_EXPLAIN = "_explain" // score explanation as JSON object
var standardSearchFieldsInit sync.Once
var standardSearchFields SearchableDocumentFields
func StandardSearchFields() SearchableDocumentFields {
standardSearchFieldsInit.Do(func() {
var err error
standardSearchFields, err = NewSearchableDocumentFields([]*resourcepb.ResourceTableColumnDefinition{
{
Name: SEARCH_FIELD_ID,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Unique Identifier. {namespace}/{group}/{resource}/{name}",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
NotNull: true,
},
},
{
Name: SEARCH_FIELD_GROUP_RESOURCE,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "The resource kind: {group}/{resource}",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
NotNull: true,
},
},
{
Name: SEARCH_FIELD_NAMESPACE,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Tenant isolation",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
NotNull: true,
},
},
{
Name: SEARCH_FIELD_NAME,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Kubernetes name. Unique identifier within a namespace+group+resource",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
NotNull: true,
},
},
{
Name: SEARCH_FIELD_TITLE,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Display name for the resource",
},
{
Name: SEARCH_FIELD_DESCRIPTION,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "An account of the resource.",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
FreeText: true,
},
},
{
Name: SEARCH_FIELD_TAGS,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "Unique tags",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
Filterable: true,
},
},
{
Name: SEARCH_FIELD_FOLDER,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Kubernetes name for the folder",
},
{
Name: SEARCH_FIELD_RV,
Type: resourcepb.ResourceTableColumnDefinition_INT64,
Description: "resource version",
},
{
Name: SEARCH_FIELD_CREATED,
Type: resourcepb.ResourceTableColumnDefinition_INT64,
Description: "created timestamp", // date?
},
{
Name: SEARCH_FIELD_EXPLAIN,
Type: resourcepb.ResourceTableColumnDefinition_OBJECT,
Description: "Explain why this result matches (depends on the engine)",
},
{
Name: SEARCH_FIELD_SCORE,
Type: resourcepb.ResourceTableColumnDefinition_DOUBLE,
Description: "The search score",
},
{
Name: SEARCH_FIELD_LEGACY_ID,
Type: resourcepb.ResourceTableColumnDefinition_INT64,
Description: "Deprecated legacy id of the resource",
},
{
Name: SEARCH_FIELD_MANAGER_KIND,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Type of manager, which is responsible for managing the resource",
},
// TODO: below fields only need to be returned from search, but do not need to be searchable
{
Name: SEARCH_FIELD_MANAGER_ID,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: SEARCH_FIELD_SOURCE_TIME,
Type: resourcepb.ResourceTableColumnDefinition_INT64,
},
{
Name: SEARCH_FIELD_SOURCE_PATH,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: SEARCH_FIELD_SOURCE_CHECKSUM,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
})
if err != nil {
panic("failed to initialize standard search fields")
}
})
return standardSearchFields
}
// // Helper function to convert everything except the "Fields" property to values
// // NOTE: this is really to help testing things absent real backend index
// func IndexableDocumentStandardFields(doc *IndexableDocument) map[string]any {
// fields := make(map[string]any)
// // These should always exist
// fields[SEARCH_FIELD_ID] = doc.Key.SearchID()
// fields[SEARCH_FIELD_NAMESPACE] = doc.Key.Namespace
// fields[SEARCH_FIELD_NAME] = doc.Key.Name
// fields[SEARCH_FIELD_GROUP_RESOURCE] = fmt.Sprintf("%s/%s", doc.Key.Group, doc.Key.Resource)
// fields[SEARCH_FIELD_TITLE] = doc.Title
// return fields
// }