wire up the ui to the new search api (#97866)

wire up the ui to the new search api

Co-authored-by: Scott Lepper <scott.lepper@gmail.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Dan Cech <dcech@grafana.com>
This commit is contained in:
Scott Lepper
2024-12-19 13:21:48 -05:00
committed by GitHub
co-authored by Ryan McKinley Dan Cech
parent 86a13ea4bd
commit a8f347144d
27 changed files with 563 additions and 271 deletions
+29 -2
View File
@@ -53,12 +53,18 @@ type IndexableDocument struct {
// The resource key
Key *ResourceKey `json:"key"`
// The resource type ( for federated indexes )
Kind string `json:"kind,omitempty"`
// Resource version for the resource (if known)
RV int64 `json:"rv,omitempty"`
// The generic display name
Title string `json:"title,omitempty"`
// internal sort field for title ( don't set this directly )
TitleSort string `json:"title_sort,omitempty"`
// A generic description -- helpful in global search
Description string `json:"description,omitempty"`
@@ -99,6 +105,10 @@ type IndexableDocument struct {
RepoInfo *utils.ResourceRepositoryInfo `json:"repository,omitempty"`
}
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
@@ -138,10 +148,26 @@ func (m ResourceReferences) Less(i, j int) bool {
// Create a new indexable document based on a generic k8s resource
func NewIndexableDocument(key *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,
Kind: key.Resource,
RV: rv,
Title: obj.FindTitle(key.Name), // We always want *something* to display
Title: title, // We always want *something* to display
TitleSort: title,
Labels: obj.GetLabels(),
Folder: obj.GetFolder(),
CreatedBy: obj.GetCreatedBy(),
@@ -178,7 +204,6 @@ func (s *standardDocumentBuilder) BuildDocument(ctx context.Context, key *Resour
}
doc := NewIndexableDocument(key, rv, obj)
doc.Title = obj.FindTitle(key.Name)
return doc, nil
}
@@ -220,11 +245,13 @@ func (x *searchableDocumentFields) Field(name string) *ResourceTableColumnDefini
}
const SEARCH_FIELD_ID = "_id" // {namespace}/{group}/{resource}/{name}
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_SORT = "title_sort"
const SEARCH_FIELD_DESCRIPTION = "description"
const SEARCH_FIELD_TAGS = "tags"
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
@@ -33,8 +33,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
"resource": "playlists",
"name": "test1"
},
"kind": "playlists",
"rv": 10,
"title": "test1",
"title": "test playlist unified storage",
"title_sort": "test playlist unified storage",
"created": 1717236672000,
"createdBy": "user:ABC",
"updatedBy": "user:XYZ",
@@ -43,5 +45,5 @@ func TestStandardDocumentBuilder(t *testing.T) {
"path": "15",
"hash": "xyz"
}
}`, string(jj))
}`, string(jj))
}
+91 -53
View File
@@ -305,27 +305,9 @@ func (b *bleveIndex) Search(
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),
})
}
}
f := newResponseFacet(v)
if response.Facet == nil {
response.Facet = make(map[string]*resource.ResourceSearchResponse_Facet)
}
@@ -387,7 +369,7 @@ func (b *bleveIndex) getIndex(
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")
return nil, fmt.Errorf("federated index keys do not match (%v != %v)", typedindex, req.Federated[i])
}
all = append(all, typedindex.index)
}
@@ -397,11 +379,16 @@ func (b *bleveIndex) getIndex(
}
func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) {
facets := bleve.FacetsRequest{}
for _, f := range req.Facet {
facets[f.Field] = bleve.NewFacetRequest(f.Field, int(f.Limit))
}
searchrequest := &bleve.SearchRequest{
Fields: req.Fields,
Size: int(req.Limit),
From: int(req.Offset),
Explain: req.Explain,
Facets: facets,
}
// Currently everything is within an AND query
@@ -415,6 +402,7 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
queries = append(queries, q)
}
}
// filters
if len(req.Options.Fields) > 0 {
for _, v := range req.Options.Fields {
q, err := requirementQuery(v, "")
@@ -425,12 +413,7 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
}
}
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)
}
queries = append(queries, newTextQuery(req))
if access != nil {
// TODO AUTHZ!!!!
@@ -458,30 +441,11 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
}
// 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)
}
sorting := getSortFields(req)
searchrequest.SortBy(sorting)
// Always sort by *something*, otherwise the order is unstable
if len(searchrequest.Sort) == 0 {
if len(sorting) == 0 {
searchrequest.Sort = append(searchrequest.Sort, &search.SortDocID{
Desc: false,
})
@@ -490,23 +454,71 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce
return searchrequest, nil
}
func getSortFields(req *resource.ResourceSearchRequest) []string {
sorting := []string{}
for _, sort := range req.SortBy {
input := sort.Field
if field, ok := textSortFields[input]; ok {
input = field
}
if sort.Desc {
input = "-" + input
}
sorting = append(sorting, input)
}
return sorting
}
// fields that we went to sort by the full text
var textSortFields = map[string]string{
resource.SEARCH_FIELD_TITLE: resource.SEARCH_FIELD_TITLE + "_sort",
}
// 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")
if len(req.Values) == 0 {
return query.NewMatchAllQuery(), nil
}
if len(req.Values[0]) == 1 {
q := query.NewMatchQuery(req.Values[0])
q.FieldVal = prefix + req.Key
return q, nil
}
q := query.NewMatchQuery(req.Values[0])
q.FieldVal = prefix + req.Key
return q, nil
conjuncts := []query.Query{}
for _, v := range req.Values {
q := query.NewMatchQuery(v)
q.FieldVal = prefix + req.Key
conjuncts = append(conjuncts, q)
}
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
}
if len(req.Values) == 1 {
q := query.NewMatchQuery(req.Values[0])
q.FieldVal = prefix + req.Key
return q, nil
}
disjuncts := []query.Query{}
for _, v := range req.Values {
q := query.NewMatchQuery(v)
q.FieldVal = prefix + req.Key
disjuncts = append(disjuncts, q)
}
return query.NewDisjunctionQuery(disjuncts), nil
case selection.NotIn:
}
return nil, resource.NewBadRequestError(
@@ -615,3 +627,29 @@ func getAllFields(standard resource.SearchableDocumentFields, custom resource.Se
}
return fields, nil
}
func newResponseFacet(v *search.FacetResult) *resource.ResourceSearchResponse_Facet {
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),
})
}
}
return f
}
func newTextQuery(req *resource.ResourceSearchRequest) query.Query {
if req.Query == "" || req.Query == "*" {
return bleve.NewMatchAllQuery()
}
// TODO: wildcard query?
// return bleve.NewWildcardQuery(req.Query)
return bleve.NewFuzzyQuery(req.Query)
}
+25 -21
View File
@@ -16,21 +16,20 @@ func getBleveMappings(fields resource.SearchableDocumentFields) mapping.IndexMap
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
mapper := bleve.NewDocumentStaticMapping()
mapper.AddFieldMapping(&mapping.FieldMapping{
Name: resource.SEARCH_FIELD_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{
// for sorting by title
titleSortMapping := bleve.NewKeywordFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE_SORT, titleSortMapping)
// for searching by title
titleSearchMapping := bleve.NewTextFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TITLE, titleSearchMapping)
// for filtering by kind/resource ( federated search )
kindMapping := bleve.NewTextFieldMapping()
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_KIND, kindMapping)
descriptionMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_DESCRIPTION,
Type: "text",
Store: true,
@@ -38,9 +37,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
IncludeTermVectors: false,
IncludeInAll: false,
DocValues: false,
})
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
mapper.AddFieldMapping(&mapping.FieldMapping{
tagsMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_TAGS,
Type: "text",
Analyzer: keyword.Name,
@@ -49,9 +49,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: false,
})
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
mapper.AddFieldMapping(&mapping.FieldMapping{
folderMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_FOLDER,
Type: "text",
Analyzer: keyword.Name,
@@ -60,9 +61,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: true, // will be needed for authz client
})
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_FOLDER, folderMapping)
mapper.AddFieldMapping(&mapping.FieldMapping{
repoMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_REPOSITORY,
Type: "text",
Analyzer: keyword.Name,
@@ -71,8 +73,10 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: true,
})
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_REPOSITORY, repoMapping)
// TODO: we use the static mapper. why set dynamic to true?
mapper.Dynamic = true
return mapper
@@ -42,5 +42,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, 15, len(doc.Fields))
require.Equal(t, 17, len(doc.Fields))
}
+24 -25
View File
@@ -66,11 +66,12 @@ func TestBleveBackend(t *testing.T) {
Key: &resource.ResourceKey{
Name: "aaa",
Namespace: "ns",
Group: "g",
Resource: "dash",
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Title: "aaa (dash)",
Folder: "xxx",
Title: "aaa (dash)",
TitleSort: "aaa (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_LEGACY_ID: 12,
DASHBOARD_PANEL_TYPES: []string{"timeseries", "table"},
@@ -83,11 +84,12 @@ func TestBleveBackend(t *testing.T) {
Key: &resource.ResourceKey{
Name: "bbb",
Namespace: "ns",
Group: "g",
Resource: "dash",
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Title: "bbb (dash)",
Folder: "xxx",
Title: "bbb (dash)",
TitleSort: "bbb (dash)",
Folder: "xxx",
Fields: map[string]any{
DASHBOARD_LEGACY_ID: 12,
DASHBOARD_PANEL_TYPES: []string{"timeseries"},
@@ -103,11 +105,12 @@ func TestBleveBackend(t *testing.T) {
Key: &resource.ResourceKey{
Name: "ccc",
Namespace: "ns",
Group: "g",
Resource: "dash",
Group: "dashboard.grafana.app",
Resource: "dashboards",
},
Title: "ccc (dash)",
Folder: "zzz",
Title: "ccc (dash)",
TitleSort: "ccc (dash)",
Folder: "zzz",
RepoInfo: &utils.ResourceRepositoryInfo{
Name: "r0",
},
@@ -145,11 +148,7 @@ func TestBleveBackend(t *testing.T) {
require.NotNil(t, rsp.Results)
require.NotNil(t, rsp.Facet)
t.Run("x", func(t *testing.T) {
t.Skip("flakey tests - skipping") // sort seems different in CI... sometimes!
// Match the results
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
})
resource.AssertTableSnapshot(t, filepath.Join("testdata", "manual-dashboard.json"), rsp.Results)
// Get the tags facets
facet, ok := rsp.Facet["tags"]
@@ -193,20 +192,22 @@ func TestBleveBackend(t *testing.T) {
Key: &resource.ResourceKey{
Name: "zzz",
Namespace: "ns",
Group: "g",
Resource: "folder",
Group: "folder.grafana.app",
Resource: "folders",
},
Title: "zzz (folder)",
Title: "zzz (folder)",
TitleSort: "zzz (folder)",
})
_ = index.Write(&resource.IndexableDocument{
RV: 2,
Key: &resource.ResourceKey{
Name: "yyy",
Namespace: "ns",
Group: "g",
Resource: "folder",
Group: "folder.grafana.app",
Resource: "folders",
},
Title: "yyy (folder)",
Title: "yyy (folder)",
TitleSort: "yyy (folder)",
Labels: map[string]string{
"region": "west",
},
@@ -232,8 +233,6 @@ func TestBleveBackend(t *testing.T) {
})
t.Run("simple federation", func(t *testing.T) {
t.Skip("flakey tests - skipping") // sort seems different in CI... sometimes!
// The other tests must run first to build the indexes
require.NotNil(t, dashboardsIndex)
require.NotNil(t, foldersIndex)
@@ -5,8 +5,10 @@
"resource": "dashboards",
"name": "aaa"
},
"kind": "dashboards",
"rv": 1234,
"title": "Test title",
"title_sort": "Test title",
"description": "test description",
"tags": [
"a",
@@ -5,8 +5,10 @@
"resource": "dashboards",
"name": "aaa"
},
"kind": "dashboards",
"rv": 1234,
"title": "aaa",
"title": "test-aaa",
"title_sort": "test-aaa",
"created": 1730490142000,
"createdBy": "user:1",
"repository": {
@@ -5,8 +5,10 @@
"resource": "dashboards",
"name": "bbb"
},
"kind": "dashboards",
"rv": 1234,
"title": "bbb",
"title": "test-bbb",
"title_sort": "test-bbb",
"created": 1730490142000,
"createdBy": "user:1",
"repository": {
@@ -5,8 +5,10 @@
"resource": "dashboards",
"name": "aaa"
},
"kind": "dashboards",
"rv": 1234,
"title": "aaa",
"title": "Test AAA",
"title_sort": "Test AAA",
"created": 1731336353000,
"createdBy": "user:t000000001",
"repository": {
@@ -5,8 +5,10 @@
"resource": "dashboards",
"name": "aaa"
},
"kind": "dashboards",
"rv": 1234,
"title": "aaa",
"title": "Test AAA",
"title_sort": "Test AAA",
"created": 1706690655000,
"createdBy": "user:abc",
"repository": {
@@ -0,0 +1,51 @@
{
"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": [
"yyy (folder)",
"ns/folder.grafana.app/folders/yyy"
],
"object": {
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "yyy",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"zzz (folder)",
"ns/folder.grafana.app/folders/zzz"
],
"object": {
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "zzz",
"namespace": "ns",
"creationTimestamp": null
}
}
}
]
}
+9 -9
View File
@@ -68,7 +68,7 @@
"rows": [
{
"cells": [
"ns/g/dash/ccc",
"ns/dashboard.grafana.app/dashboards/ccc",
"ccc (dash)",
[
"aa"
@@ -81,8 +81,8 @@
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "ccc",
"namespace": "ns",
@@ -92,7 +92,7 @@
},
{
"cells": [
"ns/g/dash/bbb",
"ns/dashboard.grafana.app/dashboards/bbb",
"bbb (dash)",
[
"aa"
@@ -105,8 +105,8 @@
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "bbb",
"namespace": "ns",
@@ -116,7 +116,7 @@
},
{
"cells": [
"ns/g/dash/aaa",
"ns/dashboard.grafana.app/dashboards/aaa",
"aaa (dash)",
[
"aa",
@@ -130,8 +130,8 @@
null
],
"object": {
"kind": "dash",
"apiVersion": "g",
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "aaa",
"namespace": "ns",
+28 -28
View File
@@ -20,26 +20,11 @@
{
"cells": [
"aaa (dash)",
"ns/g/dash/bbb"
"ns/dashboard.grafana.app/dashboards/aaa"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"metadata": {
"name": "bbb",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"bbb (dash)",
"ns/g/dash/aaa"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "aaa",
"namespace": "ns",
@@ -49,12 +34,27 @@
},
{
"cells": [
"ccc (dash)",
"ns/g/dash/ccc"
"bbb (dash)",
"ns/dashboard.grafana.app/dashboards/bbb"
],
"object": {
"kind": "dash",
"apiVersion": "g",
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "bbb",
"namespace": "ns",
"creationTimestamp": null
}
}
},
{
"cells": [
"ccc (dash)",
"ns/dashboard.grafana.app/dashboards/ccc"
],
"object": {
"kind": "dashboards",
"apiVersion": "dashboard.grafana.app",
"metadata": {
"name": "ccc",
"namespace": "ns",
@@ -65,11 +65,11 @@
{
"cells": [
"yyy (folder)",
"ns/g/folder/yyy"
"ns/folder.grafana.app/folders/yyy"
],
"object": {
"kind": "folder",
"apiVersion": "g",
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "yyy",
"namespace": "ns",
@@ -80,11 +80,11 @@
{
"cells": [
"zzz (folder)",
"ns/g/folder/zzz"
"ns/folder.grafana.app/folders/zzz"
],
"object": {
"kind": "folder",
"apiVersion": "g",
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "zzz",
"namespace": "ns",
+6 -6
View File
@@ -47,7 +47,7 @@
"rows": [
{
"cells": [
"ns/g/folder/yyy",
"ns/folder.grafana.app/folders/yyy",
"yyy (folder)",
null,
null,
@@ -55,8 +55,8 @@
0
],
"object": {
"kind": "folder",
"apiVersion": "g",
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "yyy",
"namespace": "ns",
@@ -66,7 +66,7 @@
},
{
"cells": [
"ns/g/folder/zzz",
"ns/folder.grafana.app/folders/zzz",
"zzz (folder)",
null,
null,
@@ -74,8 +74,8 @@
0
],
"object": {
"kind": "folder",
"apiVersion": "g",
"kind": "folders",
"apiVersion": "folder.grafana.app",
"metadata": {
"name": "zzz",
"namespace": "ns",