mirror of https://github.com/grafana/grafana
K8s: Move GrafanaMetaAccessor into grafana-apiserver and remove usage of kinds metadata (#79602)
* move GrafanaMetaAccessor into pkg/apis, add support for Spec.Title & Spec.Name * K8s: Move GrafanaMetaAccessor (PR into another) (#79728) * access titles * remove title * remove title * remove kinds metadata accessor * remove kinds metadata accessor * fixes * error handling * fix tests --------- Co-authored-by: Ryan McKinley <ryantxu@gmail.com>pull/80495/head
parent
da894994d4
commit
d76defe517
@ -1,35 +0,0 @@ |
||||
package kinds |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
) |
||||
|
||||
func TestMetaAccessor(t *testing.T) { |
||||
originInfo := &ResourceOriginInfo{ |
||||
Name: "test", |
||||
Path: "a/b/c", |
||||
Key: "kkk", |
||||
} |
||||
|
||||
// Verify that you can set annotations when they do not exist
|
||||
dummy := &GrafanaResourceMetadata{} |
||||
dummy.SetOriginInfo(originInfo) |
||||
dummy.SetFolder("folderUID") |
||||
|
||||
// with any k8s object
|
||||
obj := &unstructured.Unstructured{} |
||||
meta := MetaAccessor(obj) |
||||
meta.SetOriginInfo(originInfo) |
||||
meta.SetFolder("folderUID") |
||||
|
||||
require.Equal(t, map[string]string{ |
||||
"grafana.app/originName": "test", |
||||
"grafana.app/originPath": "a/b/c", |
||||
"grafana.app/originKey": "kkk", |
||||
"grafana.app/folder": "folderUID", |
||||
}, dummy.Annotations) |
||||
require.Equal(t, dummy.Annotations, obj.GetAnnotations()) |
||||
} |
@ -0,0 +1,266 @@ |
||||
package utils |
||||
|
||||
import ( |
||||
"fmt" |
||||
"reflect" |
||||
"time" |
||||
|
||||
"k8s.io/apimachinery/pkg/api/meta" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
) |
||||
|
||||
// Annotation keys
|
||||
|
||||
const AnnoKeyCreatedBy = "grafana.app/createdBy" |
||||
const AnnoKeyUpdatedTimestamp = "grafana.app/updatedTimestamp" |
||||
const AnnoKeyUpdatedBy = "grafana.app/updatedBy" |
||||
const AnnoKeyFolder = "grafana.app/folder" |
||||
const AnnoKeySlug = "grafana.app/slug" |
||||
|
||||
// Identify where values came from
|
||||
|
||||
const AnnoKeyOriginName = "grafana.app/originName" |
||||
const AnnoKeyOriginPath = "grafana.app/originPath" |
||||
const AnnoKeyOriginKey = "grafana.app/originKey" |
||||
const AnnoKeyOriginTimestamp = "grafana.app/originTimestamp" |
||||
|
||||
// ResourceOriginInfo is saved in annotations. This is used to identify where the resource came from
|
||||
// This object can model the same data as our existing provisioning table or a more general git sync
|
||||
type ResourceOriginInfo struct { |
||||
// Name of the origin/provisioning source
|
||||
Name string `json:"name,omitempty"` |
||||
|
||||
// The path within the named origin above (external_id in the existing dashboard provisioing)
|
||||
Path string `json:"path,omitempty"` |
||||
|
||||
// Verification/identification key (check_sum in existing dashboard provisioning)
|
||||
Key string `json:"key,omitempty"` |
||||
|
||||
// Origin modification timestamp when the resource was saved
|
||||
// This will be before the resource updated time
|
||||
Timestamp *time.Time `json:"time,omitempty"` |
||||
|
||||
// Avoid extending
|
||||
_ any `json:"-"` |
||||
} |
||||
|
||||
// Accessor functions for k8s objects
|
||||
type GrafanaResourceMetaAccessor interface { |
||||
GetUpdatedTimestamp() (*time.Time, error) |
||||
SetUpdatedTimestamp(v *time.Time) |
||||
SetUpdatedTimestampMillis(unix int64) |
||||
GetCreatedBy() string |
||||
SetCreatedBy(user string) |
||||
GetUpdatedBy() string |
||||
SetUpdatedBy(user string) |
||||
GetFolder() string |
||||
SetFolder(uid string) |
||||
GetSlug() string |
||||
SetSlug(v string) |
||||
GetOriginInfo() (*ResourceOriginInfo, error) |
||||
SetOriginInfo(info *ResourceOriginInfo) |
||||
GetOriginName() string |
||||
GetOriginPath() string |
||||
GetOriginKey() string |
||||
GetOriginTimestamp() (*time.Time, error) |
||||
|
||||
// Find a title in the object
|
||||
// This will reflect the object and try to get:
|
||||
// * spec.title
|
||||
// * spec.name
|
||||
// * title
|
||||
// and return an empty string if nothing was found
|
||||
FindTitle(defaultTitle string) string |
||||
} |
||||
|
||||
var _ GrafanaResourceMetaAccessor = (*grafanaResourceMetaAccessor)(nil) |
||||
|
||||
type grafanaResourceMetaAccessor struct { |
||||
raw interface{} // the original object (it implements metav1.Object)
|
||||
obj metav1.Object |
||||
} |
||||
|
||||
// Accessor takes an arbitrary object pointer and returns meta.Interface.
|
||||
// obj must be a pointer to an API type. An error is returned if the minimum
|
||||
// required fields are missing. Fields that are not required return the default
|
||||
// value and are a no-op if set.
|
||||
func MetaAccessor(raw interface{}) (GrafanaResourceMetaAccessor, error) { |
||||
obj, err := meta.Accessor(raw) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &grafanaResourceMetaAccessor{raw, obj}, nil |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) set(key string, val string) { |
||||
anno := m.obj.GetAnnotations() |
||||
if val == "" { |
||||
if anno != nil { |
||||
delete(anno, key) |
||||
} |
||||
} else { |
||||
if anno == nil { |
||||
anno = make(map[string]string) |
||||
} |
||||
anno[key] = val |
||||
} |
||||
m.obj.SetAnnotations(anno) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) get(key string) string { |
||||
return m.obj.GetAnnotations()[key] |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) { |
||||
v, ok := m.obj.GetAnnotations()[AnnoKeyUpdatedTimestamp] |
||||
if !ok || v == "" { |
||||
return nil, nil |
||||
} |
||||
t, err := time.Parse(time.RFC3339, v) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid updated timestamp: %s", err.Error()) |
||||
} |
||||
return &t, nil |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetUpdatedTimestampMillis(v int64) { |
||||
if v > 0 { |
||||
t := time.UnixMilli(v) |
||||
m.SetUpdatedTimestamp(&t) |
||||
} else { |
||||
m.set(AnnoKeyUpdatedTimestamp, "") // will clear the annotation
|
||||
} |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetUpdatedTimestamp(v *time.Time) { |
||||
txt := "" |
||||
if v != nil && v.Unix() != 0 { |
||||
txt = v.UTC().Format(time.RFC3339) |
||||
} |
||||
m.set(AnnoKeyUpdatedTimestamp, txt) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetCreatedBy() string { |
||||
return m.get(AnnoKeyCreatedBy) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetCreatedBy(user string) { |
||||
m.set(AnnoKeyCreatedBy, user) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetUpdatedBy() string { |
||||
return m.get(AnnoKeyUpdatedBy) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetUpdatedBy(user string) { |
||||
m.set(AnnoKeyUpdatedBy, user) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetFolder() string { |
||||
return m.get(AnnoKeyFolder) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetFolder(uid string) { |
||||
m.set(AnnoKeyFolder, uid) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetSlug() string { |
||||
return m.get(AnnoKeySlug) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetSlug(v string) { |
||||
m.set(AnnoKeySlug, v) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) SetOriginInfo(info *ResourceOriginInfo) { |
||||
anno := m.obj.GetAnnotations() |
||||
if anno == nil { |
||||
if info == nil { |
||||
return |
||||
} |
||||
anno = make(map[string]string, 0) |
||||
} |
||||
|
||||
delete(anno, AnnoKeyOriginName) |
||||
delete(anno, AnnoKeyOriginPath) |
||||
delete(anno, AnnoKeyOriginKey) |
||||
delete(anno, AnnoKeyOriginTimestamp) |
||||
if info != nil && info.Name != "" { |
||||
anno[AnnoKeyOriginName] = info.Name |
||||
if info.Path != "" { |
||||
anno[AnnoKeyOriginPath] = info.Path |
||||
} |
||||
if info.Key != "" { |
||||
anno[AnnoKeyOriginKey] = info.Key |
||||
} |
||||
if info.Timestamp != nil { |
||||
anno[AnnoKeyOriginTimestamp] = info.Timestamp.Format(time.RFC3339) |
||||
} |
||||
} |
||||
m.obj.SetAnnotations(anno) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetOriginInfo() (*ResourceOriginInfo, error) { |
||||
v, ok := m.obj.GetAnnotations()[AnnoKeyOriginName] |
||||
if !ok { |
||||
return nil, nil |
||||
} |
||||
t, err := m.GetOriginTimestamp() |
||||
return &ResourceOriginInfo{ |
||||
Name: v, |
||||
Path: m.GetOriginPath(), |
||||
Key: m.GetOriginKey(), |
||||
Timestamp: t, |
||||
}, err |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetOriginName() string { |
||||
return m.get(AnnoKeyOriginName) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetOriginPath() string { |
||||
return m.get(AnnoKeyOriginPath) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetOriginKey() string { |
||||
return m.get(AnnoKeyOriginKey) |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) GetOriginTimestamp() (*time.Time, error) { |
||||
v, ok := m.obj.GetAnnotations()[AnnoKeyOriginTimestamp] |
||||
if !ok || v == "" { |
||||
return nil, nil |
||||
} |
||||
t, err := time.Parse(time.RFC3339, v) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid origin timestamp: %s", err.Error()) |
||||
} |
||||
return &t, nil |
||||
} |
||||
|
||||
func (m *grafanaResourceMetaAccessor) FindTitle(defaultTitle string) string { |
||||
// look for Spec.Title or Spec.Name
|
||||
r := reflect.ValueOf(m.raw) |
||||
if r.Kind() == reflect.Ptr || r.Kind() == reflect.Interface { |
||||
r = r.Elem() |
||||
} |
||||
if r.Kind() == reflect.Struct { |
||||
spec := r.FieldByName("Spec") |
||||
if spec.Kind() == reflect.Struct { |
||||
title := spec.FieldByName("Title") |
||||
if title.IsValid() && title.Kind() == reflect.String { |
||||
return title.String() |
||||
} |
||||
name := spec.FieldByName("Name") |
||||
if name.IsValid() && name.Kind() == reflect.String { |
||||
return name.String() |
||||
} |
||||
} |
||||
|
||||
title := r.FieldByName("Title") |
||||
if title.IsValid() && title.Kind() == reflect.String { |
||||
return title.String() |
||||
} |
||||
} |
||||
return defaultTitle |
||||
} |
@ -0,0 +1,208 @@ |
||||
package utils_test |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/grafana-apiserver/utils" |
||||
) |
||||
|
||||
type TestResource struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
// Standard object's metadata
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
|
||||
// +optional
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
Spec Spec `json:"spec,omitempty"` |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TestResource) DeepCopyInto(out *TestResource) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
in.Spec.DeepCopyInto(&out.Spec) |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Playlist.
|
||||
func (in *TestResource) DeepCopy() *TestResource { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(TestResource) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *TestResource) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// Spec defines model for Spec.
|
||||
type Spec struct { |
||||
// Name of the object.
|
||||
Title string `json:"title"` |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Spec) DeepCopyInto(out *Spec) { |
||||
*out = *in |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Spec.
|
||||
func (in *Spec) DeepCopy() *Spec { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(Spec) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
type TestResource2 struct { |
||||
metav1.TypeMeta `json:",inline"` |
||||
// Standard object's metadata
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
|
||||
// +optional
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"` |
||||
|
||||
Spec Spec2 `json:"spec,omitempty"` |
||||
} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TestResource2) DeepCopyInto(out *TestResource2) { |
||||
*out = *in |
||||
out.TypeMeta = in.TypeMeta |
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) |
||||
in.Spec.DeepCopyInto(&out.Spec) |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Playlist.
|
||||
func (in *TestResource2) DeepCopy() *TestResource2 { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(TestResource2) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *TestResource2) DeepCopyObject() runtime.Object { |
||||
if c := in.DeepCopy(); c != nil { |
||||
return c |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// Spec defines model for Spec.
|
||||
type Spec2 struct{} |
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Spec2) DeepCopyInto(out *Spec2) { |
||||
*out = *in |
||||
} |
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Spec.
|
||||
func (in *Spec2) DeepCopy() *Spec2 { |
||||
if in == nil { |
||||
return nil |
||||
} |
||||
out := new(Spec2) |
||||
in.DeepCopyInto(out) |
||||
return out |
||||
} |
||||
|
||||
func TestMetaAccessor(t *testing.T) { |
||||
originInfo := &utils.ResourceOriginInfo{ |
||||
Name: "test", |
||||
Path: "a/b/c", |
||||
Key: "kkk", |
||||
} |
||||
|
||||
t.Run("fails for non resource objects", func(t *testing.T) { |
||||
_, err := utils.MetaAccessor("hello") |
||||
require.Error(t, err) |
||||
|
||||
_, err = utils.MetaAccessor(unstructured.Unstructured{}) |
||||
require.Error(t, err) // Not a pointer!
|
||||
|
||||
_, err = utils.MetaAccessor(&unstructured.Unstructured{}) |
||||
require.NoError(t, err) // Must be a pointer
|
||||
|
||||
_, err = utils.MetaAccessor(&TestResource{ |
||||
Spec: Spec{ |
||||
Title: "HELLO", |
||||
}, |
||||
}) |
||||
require.NoError(t, err) // Must be a pointer
|
||||
}) |
||||
|
||||
t.Run("get and set grafana metadata", func(t *testing.T) { |
||||
res := &unstructured.Unstructured{} |
||||
meta, err := utils.MetaAccessor(res) |
||||
require.NoError(t, err) |
||||
|
||||
meta.SetOriginInfo(originInfo) |
||||
meta.SetFolder("folderUID") |
||||
|
||||
require.Equal(t, map[string]string{ |
||||
"grafana.app/originName": "test", |
||||
"grafana.app/originPath": "a/b/c", |
||||
"grafana.app/originKey": "kkk", |
||||
"grafana.app/folder": "folderUID", |
||||
}, res.GetAnnotations()) |
||||
}) |
||||
|
||||
t.Run("find titles", func(t *testing.T) { |
||||
// with a k8s object that has Spec.Title
|
||||
obj := &TestResource{ |
||||
Spec: Spec{ |
||||
Title: "HELLO", |
||||
}, |
||||
} |
||||
|
||||
meta, err := utils.MetaAccessor(obj) |
||||
require.NoError(t, err) |
||||
meta.SetOriginInfo(originInfo) |
||||
meta.SetFolder("folderUID") |
||||
|
||||
require.Equal(t, map[string]string{ |
||||
"grafana.app/originName": "test", |
||||
"grafana.app/originPath": "a/b/c", |
||||
"grafana.app/originKey": "kkk", |
||||
"grafana.app/folder": "folderUID", |
||||
}, obj.GetAnnotations()) |
||||
|
||||
require.Equal(t, "HELLO", obj.Spec.Title) |
||||
require.Equal(t, "HELLO", meta.FindTitle("")) |
||||
obj.Spec.Title = "" |
||||
require.Equal(t, "", meta.FindTitle("xxx")) |
||||
|
||||
// with a k8s object without Spec.Title
|
||||
obj2 := &TestResource2{} |
||||
|
||||
meta, err = utils.MetaAccessor(obj2) |
||||
require.NoError(t, err) |
||||
meta.SetOriginInfo(originInfo) |
||||
meta.SetFolder("folderUID") |
||||
|
||||
require.Equal(t, map[string]string{ |
||||
"grafana.app/originName": "test", |
||||
"grafana.app/originPath": "a/b/c", |
||||
"grafana.app/originKey": "kkk", |
||||
"grafana.app/folder": "folderUID", |
||||
}, obj2.GetAnnotations()) |
||||
|
||||
require.Equal(t, "xxx", meta.FindTitle("xxx")) |
||||
}) |
||||
} |
@ -1,57 +0,0 @@ |
||||
package model |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/grafana/grafana/pkg/kinds/librarypanel" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestLibaryPanelConversion(t *testing.T) { |
||||
body := `{}` |
||||
|
||||
src := LibraryElementDTO{ |
||||
Kind: 0, // always library panel
|
||||
FolderUID: "TheFolderUID", |
||||
UID: "TheUID", |
||||
Version: 10, |
||||
Model: json.RawMessage(body), |
||||
Meta: LibraryElementDTOMeta{ |
||||
Created: time.UnixMilli(946713600000).UTC(), // 2000-01-01
|
||||
Updated: time.UnixMilli(1262332800000).UTC(), // 2010-01-01,
|
||||
CreatedBy: librarypanel.LibraryElementDTOMetaUser{ |
||||
Id: 11, |
||||
}, |
||||
UpdatedBy: librarypanel.LibraryElementDTOMetaUser{ |
||||
Id: 12, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
dst := src.ToResource() |
||||
|
||||
require.Equal(t, src.UID, dst.Metadata.Name) |
||||
|
||||
out, err := json.MarshalIndent(dst, "", " ") |
||||
require.NoError(t, err) |
||||
fmt.Printf("%s", string(out)) |
||||
require.JSONEq(t, `{ |
||||
"apiVersion": "v0-0-alpha", |
||||
"kind": "LibraryPanel", |
||||
"metadata": { |
||||
"name": "TheUID", |
||||
"resourceVersion": "10", |
||||
"creationTimestamp": "2000-01-01T08:00:00Z", |
||||
"annotations": { |
||||
"grafana.app/createdBy": "user:11", |
||||
"grafana.app/folder": "TheFolderUID", |
||||
"grafana.app/updatedBy": "user:12", |
||||
"grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z" |
||||
} |
||||
}, |
||||
"spec": {} |
||||
}`, string(out)) |
||||
} |
@ -1,45 +0,0 @@ |
||||
package team |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestTeamConversion(t *testing.T) { |
||||
src := Team{ |
||||
ID: 123, |
||||
UID: "abc", |
||||
Name: "TeamA", |
||||
Email: "team@a.org", |
||||
OrgID: 11, |
||||
Created: time.UnixMilli(946713600000).UTC(), // 2000-01-01
|
||||
Updated: time.UnixMilli(1262332800000).UTC(), // 2010-01-01
|
||||
} |
||||
|
||||
dst := src.ToResource() |
||||
|
||||
require.Equal(t, src.Name, dst.Spec.Name) |
||||
|
||||
out, err := json.MarshalIndent(dst, "", " ") |
||||
require.NoError(t, err) |
||||
fmt.Printf("%s", string(out)) |
||||
require.JSONEq(t, `{ |
||||
"apiVersion": "v0-0-alpha", |
||||
"kind": "Team", |
||||
"metadata": { |
||||
"name": "abc", |
||||
"creationTimestamp": "2000-01-01T08:00:00Z", |
||||
"annotations": { |
||||
"grafana.app/updatedTimestamp": "2010-01-01T08:00:00Z" |
||||
} |
||||
}, |
||||
"spec": { |
||||
"email": "team@a.org", |
||||
"name": "TeamA" |
||||
} |
||||
}`, string(out)) |
||||
} |
Loading…
Reference in new issue