Chore: Annotation store interface (#114100)

* annotation legacy store with api server, read only

* annotations are not addressable by ID for read operations

* add ownership for an app

* typo, of course

* fix go workspace

* update workspace

* copy annotation app in dockerfile

* experimenting with store interface

* finalising interfaces

* add tags as custom handler

* implement tags handler

* add missing config file

* mute linter

* update generated files

* update workspace
This commit is contained in:
Serge Zaitsev
2025-12-01 12:28:46 +01:00
committed by GitHub
parent 32b9bebc75
commit c15b1b6f10
13 changed files with 734 additions and 128 deletions
+14
View File
@@ -10,6 +10,20 @@ manifest: {
v0alpha1: {
kinds: [annotationv0alpha1]
routes: {
namespaced: {
"/tags": {
"GET": {
response: {
tags: [...{
tag: string
count: number
}]
}
}
}
}
}
codegen: {
ts: {
enabled: true
@@ -0,0 +1,26 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
// +k8s:openapi-gen=true
type GetTagsBody struct {
Tags []V0alpha1GetTagsBodyTags `json:"tags"`
}
// NewGetTagsBody creates a new GetTagsBody object.
func NewGetTagsBody() *GetTagsBody {
return &GetTagsBody{
Tags: []V0alpha1GetTagsBodyTags{},
}
}
// +k8s:openapi-gen=true
type V0alpha1GetTagsBodyTags struct {
Tag string `json:"tag"`
Count float64 `json:"count"`
}
// NewV0alpha1GetTagsBodyTags creates a new V0alpha1GetTagsBodyTags object.
func NewV0alpha1GetTagsBodyTags() *V0alpha1GetTagsBodyTags {
return &V0alpha1GetTagsBodyTags{}
}
@@ -0,0 +1,37 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// +k8s:openapi-gen=true
type GetTags struct {
metav1.TypeMeta `json:",inline"`
GetTagsBody `json:",inline"`
}
func NewGetTags() *GetTags {
return &GetTags{}
}
func (t *GetTagsBody) DeepCopyInto(dst *GetTagsBody) {
_ = resource.CopyObjectInto(dst, t)
}
func (o *GetTags) DeepCopyObject() runtime.Object {
dst := NewGetTags()
o.DeepCopyInto(dst)
return dst
}
func (o *GetTags) DeepCopyInto(dst *GetTags) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.GetTagsBody.DeepCopyInto(&dst.GetTagsBody)
}
var _ runtime.Object = NewGetTags()
+57 -4
View File
@@ -43,9 +43,60 @@ var appManifestData = app.ManifestData{
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Namespaced: map[string]spec3.PathProps{
"/tags": {
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getTags",
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"apiVersion": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
},
},
"kind": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
},
},
"tags": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
},
Required: []string{
"tags",
"apiVersion",
"kind",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
},
},
},
@@ -70,7 +121,9 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
return goType, exists
}
var customRouteToGoResponseType = map[string]any{}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/tags|GET": v0alpha1.GetTags{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths.
+17
View File
@@ -30,6 +30,23 @@ func New(cfg app.Config) (app.App, error) {
},
}
// Add custom route handlers if a TagHandler is provided in SpecificConfig.
// The handler is created/owned by the registry layer and passed in via
// SpecificConfig to avoid the apps package depending on the registry.
if cfg.SpecificConfig != nil {
if annotationConfig, ok := cfg.SpecificConfig.(*AnnotationConfig); ok && annotationConfig.TagHandler != nil {
simpleConfig.VersionedCustomRoutes = map[string]simple.AppVersionRouteHandlers{
"v0alpha1": {
{
Namespaced: true,
Path: "tags",
Method: "GET",
}: annotationConfig.TagHandler,
},
}
}
}
a, err := simple.NewApp(simpleConfig)
if err != nil {
return nil, err
+16
View File
@@ -0,0 +1,16 @@
package app
import (
"context"
"github.com/grafana/grafana-app-sdk/app"
)
// AnnotationConfig is the app-specific config for the annotation app. The
// registry can pass a TagHandler implementation here to wire the /tags
// resource route into the app without importing registry types.
type AnnotationConfig struct {
// TagHandler is the handler function for the GET /tags custom route.
// The function signature matches app.CustomRouteHandler from the app-sdk.
TagHandler func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error
}
+11
View File
@@ -870,6 +870,17 @@ github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVn
github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/grafana-app-sdk/logging v0.40.2/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM=
github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU=
github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY=
github.com/grafana/grafana-app-sdk v0.46.0/go.mod h1:LCTrqR1SwBS13XGVYveBmM7giJDDjzuXK+M9VzPuPWc=
github.com/grafana/grafana-app-sdk v0.47.0/go.mod h1:kywXmkppq0oReUMzkjTW8Fq2EBzyN7v914jttTWnWxA=
github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4=
github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/grafana-app-sdk/logging v0.43.0/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M=
github.com/grafana/grafana-app-sdk/logging v0.43.1/go.mod h1:0xrjKSGY5z+NLGuGsXQpxiCHR4Smu79i/CbAfdkaB1M=
github.com/grafana/grafana-app-sdk/logging v0.43.2/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
@@ -0,0 +1,158 @@
package annotation
import (
"context"
"fmt"
"strings"
"sync"
"github.com/google/uuid"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
)
type memoryStore struct {
mu sync.RWMutex
data map[string]*annotationV0.Annotation
}
func NewMemoryStore() Store {
return &memoryStore{
data: make(map[string]*annotationV0.Annotation),
}
}
func (m *memoryStore) Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error) {
m.mu.RLock()
defer m.mu.RUnlock()
key := namespace + "/" + name
anno, ok := m.data[key]
if !ok {
return nil, fmt.Errorf("annotation not found")
}
return anno.DeepCopy(), nil
}
func (m *memoryStore) List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error) {
m.mu.RLock()
defer m.mu.RUnlock()
//nolint:prealloc
var result []annotationV0.Annotation // no, we can't pre-alloc it, we don't know the size yet
for _, anno := range m.data {
if anno.Namespace != namespace {
continue
}
if opts.DashboardUID != "" && (anno.Spec.DashboardUID == nil || *anno.Spec.DashboardUID != opts.DashboardUID) {
continue
}
if opts.PanelID != 0 && (anno.Spec.PanelID == nil || *anno.Spec.PanelID != opts.PanelID) {
continue
}
if opts.From > 0 && anno.Spec.Time < opts.From {
continue
}
if opts.To > 0 && anno.Spec.Time > opts.To {
continue
}
result = append(result, *anno.DeepCopy())
if opts.Limit > 0 && int64(len(result)) >= opts.Limit {
break
}
}
return &AnnotationList{Items: result}, nil
}
func (m *memoryStore) Create(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
m.mu.Lock()
defer m.mu.Unlock()
if anno.Name == "" {
anno.Name = uuid.New().String()
}
key := anno.Namespace + "/" + anno.Name
if _, exists := m.data[key]; exists {
return nil, fmt.Errorf("annotation already exists")
}
created := anno.DeepCopy()
if created.CreationTimestamp.IsZero() {
created.CreationTimestamp = metav1.Now()
}
m.data[key] = created
return created, nil
}
func (m *memoryStore) Update(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
m.mu.Lock()
defer m.mu.Unlock()
key := anno.Namespace + "/" + anno.Name
if _, exists := m.data[key]; !exists {
return nil, fmt.Errorf("annotation not found")
}
updated := anno.DeepCopy()
m.data[key] = updated
return updated, nil
}
func (m *memoryStore) Delete(ctx context.Context, namespace, name string) error {
m.mu.Lock()
defer m.mu.Unlock()
key := namespace + "/" + name
if _, exists := m.data[key]; !exists {
return fmt.Errorf("annotation not found")
}
delete(m.data, key)
return nil
}
func (m *memoryStore) ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error) {
m.mu.RLock()
defer m.mu.RUnlock()
tagCounts := make(map[string]int64)
for _, anno := range m.data {
if anno.Namespace != namespace {
continue
}
for _, tag := range anno.Spec.Tags {
if opts.Prefix == "" || strings.HasPrefix(tag, opts.Prefix) {
tagCounts[tag]++
}
}
}
tags := make([]Tag, 0, len(tagCounts))
for name, count := range tagCounts {
tags = append(tags, Tag{Name: name, Count: count})
}
if opts.Limit > 0 && len(tags) > opts.Limit {
tags = tags[:opts.Limit]
}
return tags, nil
}
+49 -122
View File
@@ -12,6 +12,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
restclient "k8s.io/client-go/rest"
@@ -21,12 +22,11 @@ import (
"github.com/grafana/grafana/apps/annotation/pkg/apis"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
annotationapp "github.com/grafana/grafana/apps/annotation/pkg/app"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
apiserverrest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
grafrequest "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
@@ -46,15 +46,32 @@ func RegisterAppInstaller(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
service annotations.Repository,
cleaner annotations.Cleaner,
) (*AnnotationAppInstaller, error) {
installer := &AnnotationAppInstaller{
cfg: cfg,
}
var tagHandler func(context.Context, app.CustomRouteResponseWriter, *app.CustomRouteRequest) error
if service != nil {
mapper := grafrequest.GetNamespaceMapper(cfg)
sqlAdapter := NewSQLAdapter(service, cleaner, mapper, cfg)
installer.legacy = &legacyStorage{
store: sqlAdapter,
mapper: mapper,
}
// Create the tags handler using the sqlAdapter as TagProvider
tagHandler = newTagsHandler(sqlAdapter)
}
provider := simple.NewAppProvider(apis.LocalManifest(), nil, annotationapp.New)
appConfig := app.Config{
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
KubeConfig: restclient.Config{},
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: &annotationapp.AnnotationConfig{
TagHandler: tagHandler,
},
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, apis.NewGoTypeAssociator())
if err != nil {
@@ -62,13 +79,6 @@ func RegisterAppInstaller(
}
installer.AppInstaller = i
if service != nil {
installer.legacy = &legacyStorage{
service: service,
namespacer: request.GetNamespaceMapper(cfg),
}
}
return installer, nil
}
@@ -79,9 +89,11 @@ func (a *AnnotationAppInstaller) GetLegacyStorage(requested schema.GroupVersionR
Version: kind.Version(),
Resource: kind.Plural(),
}
if requested.String() != gvr.String() {
return nil
}
a.legacy.tableConverter = utils.NewTableConverter(
gvr.GroupResource(),
utils.TableColumns{
@@ -114,8 +126,8 @@ var (
)
type legacyStorage struct {
service annotations.Repository
namespacer request.NamespaceMapper
store Store
mapper grafrequest.NamespaceMapper
tableConverter rest.TableConvertor
}
@@ -142,21 +154,15 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
}
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
orgID, err := request.OrgIDForList(ctx)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{OrgID: orgID, SignedInUser: user, AlertID: -1}
namespace := request.NamespaceValue(ctx)
opts := ListOptions{}
if options.FieldSelector != nil {
for _, r := range options.FieldSelector.Requirements() {
switch r.Field {
case "spec.dashboardUID":
if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals {
query.DashboardUID = r.Value
opts.DashboardUID = r.Value
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.dashboardUID (only = supported)", r.Operator)
}
@@ -167,7 +173,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid panelID value %q: %w", r.Value, err)
}
query.PanelID = panelID
opts.PanelID = panelID
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.panelID (only = supported)", r.Operator)
}
@@ -178,13 +184,13 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err)
}
query.From = from
opts.From = from
case selection.LessThan:
to, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err)
}
query.To = to
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.time (only >, < supported for ranges)", r.Operator)
}
@@ -196,13 +202,13 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
}
query.From = from
opts.From = from
case selection.LessThan:
to, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
}
query.To = to
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.timeEnd (only >, < supported for ranges)", r.Operator)
}
@@ -213,31 +219,22 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
}
}
query.Limit = 100
opts.Limit = 100
if options.Limit > 0 {
query.Limit = options.Limit
opts.Limit = options.Limit
}
items, err := s.service.Find(ctx, query)
result, err := s.store.List(ctx, namespace, opts)
if err != nil {
return nil, err
}
list := &annotationV0.AnnotationList{
Items: make([]annotationV0.Annotation, len(items)),
}
for i, item := range items {
c, err := toK8sResource(orgID, item, s.namespacer)
if err != nil {
return nil, err
}
list.Items[i] = *c
}
// TODO: pagination?
return list, nil
return &annotationV0.AnnotationList{Items: result.Items}, nil
}
func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
return nil, errors.New("fetching single annotations not supported by legacy storage")
namespace := request.NamespaceValue(ctx)
return s.store.Get(ctx, namespace, name)
}
func (s *legacyStorage) Create(ctx context.Context,
@@ -245,22 +242,11 @@ func (s *legacyStorage) Create(ctx context.Context,
createValidation rest.ValidateObjectFunc,
options *metav1.CreateOptions,
) (runtime.Object, error) {
return nil, errors.New("not implemented")
// resource, ok := obj.(*correlationsV0.Correlation)
// if !ok {
// return nil, fmt.Errorf("expected correlation")
// }
//
// cmd, err := correlations.ToCreateCorrelationCommand(resource)
// if err != nil {
// return nil, err
// }
//
// out, err := s.service.CreateCorrelation(ctx, *cmd)
// if err != nil {
// return nil, err
// }
// return s.Get(ctx, out.UID, &metav1.GetOptions{})
resource, ok := obj.(*annotationV0.Annotation)
if !ok {
return nil, fmt.Errorf("expected annotation")
}
return s.store.Create(ctx, resource)
}
func (s *legacyStorage) Update(ctx context.Context,
@@ -272,73 +258,14 @@ func (s *legacyStorage) Update(ctx context.Context,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
return nil, false, errors.New("not implemented")
// before, err := s.Get(ctx, name, &metav1.GetOptions{})
// if err != nil {
// return nil, false, err
// }
// obj, err := objInfo.UpdatedObject(ctx, before)
// if err != nil {
// return nil, false, err
// }
//
// resource, ok := obj.(*correlationsV0.Correlation)
// if !ok {
// return nil, false, fmt.Errorf("expected correlation")
// }
//
// cmd, err := correlations.ToUpdateCorrelationCommand(resource)
// if err != nil {
// return nil, false, err
// }
//
// out, err := s.service.UpdateCorrelation(ctx, *cmd)
// if err != nil {
// return nil, false, err
// }
// obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{})
// return obj, false, err
}
// GracefulDeleter
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, errors.New("not implemented")
// orgID, err := request.OrgIDForList(ctx)
// if err != nil {
// return nil, false, err
// }
// err = s.service.DeleteCorrelation(ctx, correlations.DeleteCorrelationCommand{
// OrgId: orgID,
// UID: name,
// })
// return nil, (err == nil), err
namespace := request.NamespaceValue(ctx)
err := s.store.Delete(ctx, namespace, name)
return nil, false, err
}
// CollectionDeleter
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, fmt.Errorf("DeleteCollection for annotation not implemented")
}
func toK8sResource(orgID int64, item *annotations.ItemDTO, namespacer request.NamespaceMapper) (*annotationV0.Annotation, error) {
annotation := &annotationV0.Annotation{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("a-%d", item.ID), // FIXME
Namespace: namespacer(orgID),
},
Spec: annotationV0.AnnotationSpec{
Text: item.Text,
Time: item.Time,
Tags: item.Tags,
},
}
if item.DashboardUID != nil && *item.DashboardUID != "" {
annotation.Spec.DashboardUID = item.DashboardUID
}
if item.PanelID != 0 {
annotation.Spec.PanelID = &item.PanelID
}
if item.TimeEnd != 0 {
annotation.Spec.TimeEnd = &item.TimeEnd
}
return annotation, nil
return nil, fmt.Errorf("DeleteCollection for annotation is not available")
}
+255
View File
@@ -0,0 +1,255 @@
package annotation
import (
"context"
"fmt"
"strconv"
claims "github.com/grafana/authlib/types"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/setting"
)
type sqlAdapter struct {
repo annotations.Repository
cleaner annotations.Cleaner
nsMapper request.NamespaceMapper
cfg *setting.Cfg
}
func NewSQLAdapter(repo annotations.Repository, cleaner annotations.Cleaner, nsMapper request.NamespaceMapper, cfg *setting.Cfg) *sqlAdapter {
return &sqlAdapter{
repo: repo,
cleaner: cleaner,
nsMapper: nsMapper,
cfg: cfg,
}
}
func (a *sqlAdapter) Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error) {
id, err := parseAnnotationID(name)
if err != nil {
return nil, err
}
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{
SignedInUser: user,
OrgID: orgID,
Limit: 1000,
AlertID: -1,
}
items, err := a.repo.Find(ctx, query)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ID == id {
return a.toK8sResource(item, namespace), nil
}
}
return nil, fmt.Errorf("annotation not found")
}
func (a *sqlAdapter) List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error) {
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query := &annotations.ItemQuery{
SignedInUser: user,
OrgID: orgID,
DashboardUID: opts.DashboardUID,
PanelID: opts.PanelID,
From: opts.From,
To: opts.To,
Limit: opts.Limit,
AlertID: -1,
}
items, err := a.repo.Find(ctx, query)
if err != nil {
return nil, err
}
result := make([]annotationV0.Annotation, 0, len(items))
for _, item := range items {
result = append(result, *a.toK8sResource(item, namespace))
}
return &AnnotationList{
Items: result,
Continue: "",
}, nil
}
func (a *sqlAdapter) Create(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
orgID, err := namespaceToOrgID(ctx, anno.Namespace)
if err != nil {
return nil, err
}
item := a.fromK8sResource(anno)
item.OrgID = orgID
if err := a.repo.Save(ctx, item); err != nil {
return nil, err
}
created := anno.DeepCopy()
created.Name = fmt.Sprintf("a-%d", item.ID)
return created, nil
}
func (a *sqlAdapter) Update(ctx context.Context, anno *annotationV0.Annotation) (*annotationV0.Annotation, error) {
orgID, err := namespaceToOrgID(ctx, anno.Namespace)
if err != nil {
return nil, err
}
item := a.fromK8sResource(anno)
item.OrgID = orgID
if err := a.repo.Update(ctx, item); err != nil {
return nil, err
}
return anno, nil
}
func (a *sqlAdapter) Delete(ctx context.Context, namespace, name string) error {
id, err := parseAnnotationID(name)
if err != nil {
return err
}
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return err
}
return a.repo.Delete(ctx, &annotations.DeleteParams{
ID: id,
OrgID: orgID,
})
}
func (a *sqlAdapter) Cleanup(ctx context.Context) (int64, error) {
if a.cleaner == nil {
return 0, nil
}
deleted, _, err := a.cleaner.Run(ctx, a.cfg)
return deleted, err
}
func (a *sqlAdapter) ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error) {
orgID, err := namespaceToOrgID(ctx, namespace)
if err != nil {
return nil, err
}
query := &annotations.TagsQuery{
OrgID: orgID,
Limit: int64(opts.Limit),
Tag: opts.Prefix,
}
result, err := a.repo.FindTags(ctx, query)
if err != nil {
return nil, err
}
tags := make([]Tag, len(result.Tags))
for i, t := range result.Tags {
tags[i] = Tag{Name: t.Tag, Count: t.Count}
}
return tags, nil
}
func (a *sqlAdapter) toK8sResource(item *annotations.ItemDTO, namespace string) *annotationV0.Annotation {
anno := &annotationV0.Annotation{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("a-%d", item.ID),
Namespace: namespace,
},
Spec: annotationV0.AnnotationSpec{
Text: item.Text,
Time: item.Time,
Tags: item.Tags,
},
}
if item.DashboardUID != nil && *item.DashboardUID != "" {
anno.Spec.DashboardUID = item.DashboardUID
}
if item.PanelID != 0 {
anno.Spec.PanelID = &item.PanelID
}
if item.TimeEnd != 0 {
anno.Spec.TimeEnd = &item.TimeEnd
}
return anno
}
func (a *sqlAdapter) fromK8sResource(anno *annotationV0.Annotation) *annotations.Item {
item := &annotations.Item{
Text: anno.Spec.Text,
Epoch: anno.Spec.Time,
Tags: anno.Spec.Tags,
}
if anno.Name != "" {
if id, err := parseAnnotationID(anno.Name); err == nil {
item.ID = id
}
}
if anno.Spec.DashboardUID != nil {
item.DashboardUID = *anno.Spec.DashboardUID
}
if anno.Spec.PanelID != nil {
item.PanelID = *anno.Spec.PanelID
}
if anno.Spec.TimeEnd != nil {
item.EpochEnd = *anno.Spec.TimeEnd
}
return item
}
func parseAnnotationID(name string) (int64, error) {
if len(name) < 3 || name[:2] != "a-" {
return 0, fmt.Errorf("invalid annotation name format: %s", name)
}
return strconv.ParseInt(name[2:], 10, 64)
}
func namespaceToOrgID(ctx context.Context, namespace string) (int64, error) {
info, err := claims.ParseNamespace(namespace)
return info.OrgID, err
}
+47
View File
@@ -0,0 +1,47 @@
package annotation
import (
"context"
annotationV0 "github.com/grafana/grafana/apps/annotation/pkg/apis/annotation/v0alpha1"
)
type Store interface {
Get(ctx context.Context, namespace, name string) (*annotationV0.Annotation, error)
List(ctx context.Context, namespace string, opts ListOptions) (*AnnotationList, error)
Create(ctx context.Context, annotation *annotationV0.Annotation) (*annotationV0.Annotation, error)
Update(ctx context.Context, annotation *annotationV0.Annotation) (*annotationV0.Annotation, error)
Delete(ctx context.Context, namespace, name string) error
}
type ListOptions struct {
DashboardUID string
PanelID int64
From int64
To int64
Limit int64
Continue string
}
type AnnotationList struct {
Items []annotationV0.Annotation
Continue string
}
type LifecycleManager interface {
Cleanup(ctx context.Context) (int64, error)
}
type TagProvider interface {
ListTags(ctx context.Context, namespace string, opts TagListOptions) ([]Tag, error)
}
type TagListOptions struct {
Prefix string
Limit int
}
type Tag struct {
Name string
Count int64
}
@@ -0,0 +1,45 @@
package annotation
import (
"context"
"encoding/json"
"fmt"
"github.com/grafana/grafana-app-sdk/app"
)
type tagResponse struct {
Tags []tagItem `json:"tags"`
}
type tagItem struct {
Tag string `json:"tag"`
Count int64 `json:"count"`
}
func newTagsHandler(tagProvider TagProvider) func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
return func(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
fmt.Println("Handling /tags request")
namespace := request.ResourceIdentifier.Namespace
if namespace == "" {
namespace = "default"
}
tags, err := tagProvider.ListTags(ctx, namespace, TagListOptions{})
if err != nil {
return err
}
items := make([]tagItem, len(tags))
for i, tag := range tags {
items[i] = tagItem{
Tag: tag.Name,
Count: tag.Count,
}
}
response := tagResponse{
Tags: items,
}
return json.NewEncoder(writer).Encode(response)
}
}
+2 -2
View File
@@ -813,7 +813,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl)
annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl, cleanupServiceImpl)
if err != nil {
return nil, err
}
@@ -1467,7 +1467,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl)
annotationAppInstaller, err := annotation.RegisterAppInstaller(cfg, featureToggles, repositoryImpl, cleanupServiceImpl)
if err != nil {
return nil, err
}