diff --git a/apps/annotation/kinds/manifest.cue b/apps/annotation/kinds/manifest.cue index b41931fc0c2..3404f8853c6 100644 --- a/apps/annotation/kinds/manifest.cue +++ b/apps/annotation/kinds/manifest.cue @@ -10,6 +10,20 @@ manifest: { v0alpha1: { kinds: [annotationv0alpha1] + routes: { + namespaced: { + "/tags": { + "GET": { + response: { + tags: [...{ + tag: string + count: number + }] + } + } + } + } + } codegen: { ts: { enabled: true diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_body_types_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_body_types_gen.go new file mode 100644 index 00000000000..99067df65e3 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_body_types_gen.go @@ -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{} +} diff --git a/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_object_types_gen.go b/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_object_types_gen.go new file mode 100644 index 00000000000..6d567ef68d3 --- /dev/null +++ b/apps/annotation/pkg/apis/annotation/v0alpha1/gettags_response_object_types_gen.go @@ -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() diff --git a/apps/annotation/pkg/apis/annotation_manifest.go b/apps/annotation/pkg/apis/annotation_manifest.go index 5e7d6ae8b07..004529ee84d 100644 --- a/apps/annotation/pkg/apis/annotation_manifest.go +++ b/apps/annotation/pkg/apis/annotation_manifest.go @@ -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||/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. diff --git a/apps/annotation/pkg/app/app.go b/apps/annotation/pkg/app/app.go index d1665c0e776..743d4020047 100644 --- a/apps/annotation/pkg/app/app.go +++ b/apps/annotation/pkg/app/app.go @@ -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 diff --git a/apps/annotation/pkg/app/config.go b/apps/annotation/pkg/app/config.go new file mode 100644 index 00000000000..564139d5b38 --- /dev/null +++ b/apps/annotation/pkg/app/config.go @@ -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 +} diff --git a/go.work.sum b/go.work.sum index 3bc86d10781..7ae0eae64f2 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/pkg/registry/apps/annotation/memory_store.go b/pkg/registry/apps/annotation/memory_store.go new file mode 100644 index 00000000000..acf3cc6cde2 --- /dev/null +++ b/pkg/registry/apps/annotation/memory_store.go @@ -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 +} diff --git a/pkg/registry/apps/annotation/register.go b/pkg/registry/apps/annotation/register.go index 40d79df30ce..9d733eb3dcb 100644 --- a/pkg/registry/apps/annotation/register.go +++ b/pkg/registry/apps/annotation/register.go @@ -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") } diff --git a/pkg/registry/apps/annotation/sql_adapter.go b/pkg/registry/apps/annotation/sql_adapter.go new file mode 100644 index 00000000000..82722c809eb --- /dev/null +++ b/pkg/registry/apps/annotation/sql_adapter.go @@ -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 +} diff --git a/pkg/registry/apps/annotation/storage.go b/pkg/registry/apps/annotation/storage.go new file mode 100644 index 00000000000..445fab34ac2 --- /dev/null +++ b/pkg/registry/apps/annotation/storage.go @@ -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 +} diff --git a/pkg/registry/apps/annotation/tags_handler.go b/pkg/registry/apps/annotation/tags_handler.go new file mode 100644 index 00000000000..7ac7d60387d --- /dev/null +++ b/pkg/registry/apps/annotation/tags_handler.go @@ -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) + } +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 3304a905fc0..d57273ccd35 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -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 }