Compare commits

...

3 Commits

Author SHA1 Message Date
Serge Zaitsev
9a64951f9a fix field selectors, implement update 2025-12-23 16:20:23 +01:00
Serge Zaitsev
3071482bf4 trying to make it work 2025-12-23 15:55:39 +01:00
Serge Zaitsev
b3589300b3 add authorizer 2025-12-23 15:55:39 +01:00
10 changed files with 372 additions and 38 deletions

View File

@@ -6,6 +6,8 @@ require (
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7
k8s.io/apimachinery v0.34.3
k8s.io/apiserver v0.34.2
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e
)

View File

@@ -248,6 +248,8 @@ k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE=
k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=

View File

@@ -1,17 +1,22 @@
package kinds
annotationv0alpha1: {
kind: "Annotation"
kind: "Annotation"
pluralName: "Annotations"
schema: {
spec: {
text: string
schema: {
spec: {
text: string
time: int64
timeEnd?: int64
dashboardUID?: string
panelID?: int64
tags?: [...string]
}
}
}
}
}
selectableFields: [
"spec.time",
"spec.timeEnd",
"spec.dashboardUID",
"spec.panelID",
]
}

View File

@@ -25,6 +25,13 @@ type Annotation struct {
Status AnnotationStatus `json:"status" yaml:"status"`
}
func NewAnnotation() *Annotation {
return &Annotation{
Spec: *NewAnnotationSpec(),
Status: *NewAnnotationStatus(),
}
}
func (o *Annotation) GetSpec() any {
return o.Spec
}

View File

@@ -5,13 +5,69 @@
package v0alpha1
import (
"errors"
"fmt"
"github.com/grafana/grafana-app-sdk/resource"
)
// schema is unexported to prevent accidental overwrites
var (
schemaAnnotation = resource.NewSimpleSchema("annotation.grafana.app", "v0alpha1", &Annotation{}, &AnnotationList{}, resource.WithKind("Annotation"),
resource.WithPlural("annotations"), resource.WithScope(resource.NamespacedScope))
schemaAnnotation = resource.NewSimpleSchema("annotation.grafana.app", "v0alpha1", NewAnnotation(), &AnnotationList{}, resource.WithKind("Annotation"),
resource.WithPlural("annotations"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{
FieldSelector: "spec.time",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Annotation)
if !ok {
return "", errors.New("provided object must be of type *Annotation")
}
return fmt.Sprintf("%d", cast.Spec.Time), nil
},
},
resource.SelectableField{
FieldSelector: "spec.timeEnd",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Annotation)
if !ok {
return "", errors.New("provided object must be of type *Annotation")
}
if cast.Spec.TimeEnd == nil {
return "", nil
}
return fmt.Sprintf("%d", *cast.Spec.TimeEnd), nil
},
},
resource.SelectableField{
FieldSelector: "spec.dashboardUID",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Annotation)
if !ok {
return "", errors.New("provided object must be of type *Annotation")
}
if cast.Spec.DashboardUID == nil {
return "", nil
}
return *cast.Spec.DashboardUID, nil
},
},
resource.SelectableField{
FieldSelector: "spec.panelID",
FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Annotation)
if !ok {
return "", errors.New("provided object must be of type *Annotation")
}
if cast.Spec.PanelID == nil {
return "", nil
}
return fmt.Sprintf("%d", *cast.Spec.PanelID), nil
},
},
}))
kindAnnotation = resource.Kind{
Schema: schemaAnnotation,
Codecs: map[resource.KindEncoding]resource.Codec{

View File

@@ -40,6 +40,12 @@ var appManifestData = app.ManifestData{
Scope: "Namespaced",
Conversion: false,
Schema: &versionSchemaAnnotationv0alpha1,
SelectableFields: []string{
"spec.time",
"spec.timeEnd",
"spec.dashboardUID",
"spec.panelID",
},
},
},
Routes: app.ManifestVersionRoutes{
@@ -77,6 +83,28 @@ var appManifestData = app.ManifestData{
"tags": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"count": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
},
},
"tag": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"tag",
"count",
},
}},
},
},
},
},

View File

@@ -0,0 +1,20 @@
package app
import (
"context"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
func GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(func(
ctx context.Context, attr authorizer.Attributes,
) (authorized authorizer.Decision, reason string, err error) {
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
// Any authenticated user can access the API
return authorizer.DecisionAllow, "", nil
})
}

View File

@@ -2,7 +2,6 @@ package annotation
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
@@ -12,6 +11,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
restclient "k8s.io/client-go/rest"
@@ -82,6 +82,10 @@ func RegisterAppInstaller(
return installer, nil
}
func (a *AnnotationAppInstaller) GetAuthorizer() authorizer.Authorizer {
return annotationapp.GetAuthorizer()
}
func (a *AnnotationAppInstaller) GetLegacyStorage(requested schema.GroupVersionResource) apiserverrest.Storage {
kind := annotationV0.AnnotationKind()
gvr := schema.GroupVersionResource{
@@ -178,39 +182,25 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO
return nil, fmt.Errorf("unsupported operator %s for spec.panelID (only = supported)", r.Operator)
}
case "spec.time":
switch r.Operator {
case selection.GreaterThan:
if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals {
from, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid time value %q: %w", r.Value, err)
return nil, fmt.Errorf("invalid from value %q: %w", r.Value, err)
}
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)
}
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.time (only >, < supported for ranges)", r.Operator)
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.from (only = supported)", r.Operator)
}
case "spec.timeEnd":
switch r.Operator {
case selection.GreaterThan:
from, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
}
opts.From = from
case selection.LessThan:
if r.Operator == selection.Equals || r.Operator == selection.DoubleEquals {
to, err := strconv.ParseInt(r.Value, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid timeEnd value %q: %w", r.Value, err)
return nil, fmt.Errorf("invalid to value %q: %w", r.Value, err)
}
opts.To = to
default:
return nil, fmt.Errorf("unsupported operator %s for spec.timeEnd (only >, < supported for ranges)", r.Operator)
} else {
return nil, fmt.Errorf("unsupported operator %s for spec.to (only = supported)", r.Operator)
}
default:
@@ -257,7 +247,32 @@ func (s *legacyStorage) Update(ctx context.Context,
forceAllowCreate bool,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
return nil, false, errors.New("not implemented")
namespace := request.NamespaceValue(ctx)
obj, err := objInfo.UpdatedObject(ctx, nil)
if err != nil {
return nil, false, err
}
resource, ok := obj.(*annotationV0.Annotation)
if !ok {
return nil, false, fmt.Errorf("expected annotation")
}
if resource.Name != name {
return nil, false, fmt.Errorf("name in URL does not match name in body")
}
if resource.Namespace != namespace {
return nil, false, fmt.Errorf("namespace in URL does not match namespace in body")
}
updated, err := s.store.Update(ctx, resource)
if err != nil {
return nil, false, err
}
return updated, false, nil
}
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {

View File

@@ -1,7 +1,11 @@
import { AnnotationEvent, DataFrame, toDataFrame } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { config, getBackendSrv } from '@grafana/runtime';
import { StateHistoryItem } from 'app/types/unified-alerting';
import { getAPINamespace } from '../../api/utils';
import { ScopedResourceClient } from '../apiserver/client';
import type { Resource, ResourceForCreate, ListOptions } from '../apiserver/types';
import { AnnotationTagsResponse } from './types';
export interface AnnotationServer {
@@ -47,11 +51,190 @@ class LegacyAnnotationServer implements AnnotationServer {
}
}
// K8s-style annotation spec based on the CUE definition
interface K8sAnnotationSpec {
text: string;
time: number;
timeEnd?: number;
dashboardUID?: string;
panelID?: number;
tags?: string[];
}
interface K8sAnnotationTagsResponse {
tags: Array<{ tag: string; count: number }>;
}
const K8S_ANNOTATION_API_CONFIG = {
group: 'annotation.grafana.app',
version: 'v0alpha1',
resource: 'annotations',
};
class K8sAnnotationServer implements AnnotationServer {
private client: ScopedResourceClient<K8sAnnotationSpec>;
constructor() {
this.client = new ScopedResourceClient<K8sAnnotationSpec>(K8S_ANNOTATION_API_CONFIG);
}
async query(params: Record<string, unknown>, requestId: string): Promise<DataFrame> {
const listOpts: ListOptions = {};
if (params.limit) {
listOpts.limit = Number(params.limit);
}
const fieldSelectors: string[] = [];
if (params.dashboardUID) {
fieldSelectors.push(`spec.dashboardUID=${params.dashboardUID}`);
}
if (params.panelId) {
fieldSelectors.push(`spec.panelID=${params.panelId}`);
}
if (params.from) {
fieldSelectors.push(`spec.time=${params.from}`);
}
if (params.to) {
fieldSelectors.push(`spec.timeEnd=${params.to}`);
}
if (fieldSelectors.length > 0) {
listOpts.fieldSelector = fieldSelectors.join(',');
}
const result = await this.client.list(listOpts);
let annotations = result.items.map((item: Resource<K8sAnnotationSpec>) => ({
id: item.metadata.name,
...item.spec,
panelId: item.spec.panelID,
}));
// Client-side tag filtering (tags are not in SelectableFields since they're in an array)
if (params.tags && Array.isArray(params.tags) && params.tags.length > 0) {
const tags = params.tags;
annotations = annotations.filter((anno) => {
if (!anno.tags || anno.tags.length === 0) {
return false;
}
return tags.every((tag) => anno.tags!.includes(tag));
});
}
return toDataFrame(annotations);
}
async forAlert(alertUID: string): Promise<StateHistoryItem[]> {
// For now, we filter client-side since label selector support for alertUID may not be implemented
const result = await this.client.list({
limit: 1000,
});
// Filter by tags that contain the alertUID
// Alert annotations typically have the alert UID in their tags
return result.items
.filter((item: Resource<K8sAnnotationSpec>) => {
// Check if any tag contains the alertUID
return item.spec.tags?.some((tag) => tag.includes(alertUID));
})
.map((item: Resource<K8sAnnotationSpec>) => ({
id: item.metadata.name,
...item.spec,
panelId: item.spec.panelID,
})) as any;
}
async save(annotation: AnnotationEvent): Promise<AnnotationEvent> {
const resource: ResourceForCreate<K8sAnnotationSpec> = {
metadata: {
name: '', // Will be auto-generated by the server
},
spec: {
text: annotation.text || '',
time: annotation.time || Date.now(),
timeEnd: annotation.timeEnd,
dashboardUID: annotation.dashboardUID ?? undefined,
panelID: annotation.panelId,
tags: annotation.tags,
},
};
const result = await this.client.create(resource);
return {
...annotation,
id: result.metadata.name,
...result.spec,
panelId: result.spec.panelID,
};
}
async update(annotation: AnnotationEvent): Promise<unknown> {
if (!annotation.id) {
throw new Error('Annotation ID is required for update');
}
// Get the existing resource to preserve metadata (especially resourceVersion)
const existing = await this.client.get(String(annotation.id));
// Update only the spec fields, preserve all metadata
const updated: Resource<K8sAnnotationSpec> = {
apiVersion: existing.apiVersion,
kind: existing.kind,
metadata: {
...existing.metadata,
// Preserve critical metadata fields for update
},
spec: {
text: annotation.text !== undefined ? annotation.text : existing.spec.text,
time: annotation.time !== undefined ? annotation.time : existing.spec.time,
timeEnd: annotation.timeEnd !== undefined ? annotation.timeEnd : existing.spec.timeEnd,
dashboardUID:
annotation.dashboardUID !== undefined && annotation.dashboardUID !== null
? annotation.dashboardUID
: existing.spec.dashboardUID,
panelID: annotation.panelId !== undefined ? annotation.panelId : existing.spec.panelID,
tags: annotation.tags !== undefined ? annotation.tags : existing.spec.tags,
},
};
return this.client.update(updated);
}
async delete(annotation: AnnotationEvent): Promise<unknown> {
if (!annotation.id) {
throw new Error('Annotation ID is required for delete');
}
return this.client.delete(String(annotation.id), false);
}
async tags(): Promise<Array<{ term: string; count: number }>> {
// Use the custom /tags route defined in the CUE manifest
const namespace = getAPINamespace();
const url = `/apis/${K8S_ANNOTATION_API_CONFIG.group}/${K8S_ANNOTATION_API_CONFIG.version}/namespaces/${namespace}/tags`;
const response = await getBackendSrv().get<K8sAnnotationTagsResponse>(url, { limit: 1000 });
return response.tags.map(({ tag, count }) => ({
term: tag,
count,
}));
}
}
let instance: AnnotationServer | null = null;
export function annotationServer(): AnnotationServer {
if (!instance) {
instance = new LegacyAnnotationServer();
if (config.featureToggles.kubernetesAnnotations) {
instance = new K8sAnnotationServer();
} else {
instance = new LegacyAnnotationServer();
}
}
return instance;
}

View File

@@ -14,6 +14,7 @@ import {
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, getBackendSrv, locationService } from '@grafana/runtime';
import { annotationServer } from 'app/features/annotations/api';
import { Button, ScrollContainer, stylesFactory, TagList } from '@grafana/ui';
import { AbstractList } from '@grafana/ui/internal';
import { appEvents } from 'app/core/app_events';
@@ -28,7 +29,7 @@ interface UserInfo {
email?: string;
}
export interface Props extends PanelProps<Options> {}
export interface Props extends PanelProps<Options> { }
interface State {
annotations: AnnotationEvent[];
timeInfo: string;
@@ -128,7 +129,22 @@ export class AnnoListPanel extends PureComponent<Props, State> {
params.tags = params.tags ? [...params.tags, ...queryTags] : queryTags;
}
const annotations = await getBackendSrv().get('/api/annotations', params, this.state.requestId);
// Use annotationServer() to support both legacy and k8s APIs
const df = await annotationServer().query(params, this.state.requestId);
// Convert DataFrame to array of annotations
// The DataFrame will have fields that correspond to annotation properties
const annotations: AnnotationEvent[] = [];
if (df.length > 0) {
const length = df.fields[0]?.values.length || 0;
for (let i = 0; i < length; i++) {
const annotation: any = {};
df.fields.forEach((field) => {
annotation[field.name] = field.values[i];
});
annotations.push(annotation as AnnotationEvent);
}
}
this.setState({
annotations,