Alerting: Support field selectors in time interval API (#90022)

* fix kind of TimeInterval
* register custom fields for selectors
* support field selectors in legacy storage
* support selectors in storage

===== Misc
* refactor conversions to build in one place
* hide implementation of provenance status behind accessors to use the key in selectors
* fix provenance error
This commit is contained in:
Yuri Tseretyan
2024-07-08 15:45:30 -04:00
committed by GitHub
parent 63e715f6a9
commit 5ae5fa3a7a
8 changed files with 221 additions and 43 deletions

View File

@@ -1,11 +1,16 @@
package v0alpha1 package v0alpha1
import ( import (
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/generic"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1"
) )
func init() { func init() {
@@ -20,7 +25,7 @@ const (
var ( var (
TimeIntervalResourceInfo = common.NewResourceInfo(GROUP, VERSION, TimeIntervalResourceInfo = common.NewResourceInfo(GROUP, VERSION,
"timeintervals", "timeinterval", "TimeIntervals", "timeintervals", "timeinterval", "TimeInterval",
func() runtime.Object { return &TimeInterval{} }, func() runtime.Object { return &TimeInterval{} },
func() runtime.Object { return &TimeIntervalList{} }, func() runtime.Object { return &TimeIntervalList{} },
) )
@@ -51,9 +56,36 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error {
&ReceiverList{}, &ReceiverList{},
) )
metav1.AddToGroupVersion(scheme, g) metav1.AddToGroupVersion(scheme, g)
err := scheme.AddFieldLabelConversionFunc(
TimeIntervalResourceInfo.GroupVersionKind(),
func(label, value string) (string, string, error) {
fieldSet := SelectableTimeIntervalsFields(&TimeInterval{})
for key := range fieldSet {
if label == key {
return label, value, nil
}
}
return "", "", fmt.Errorf("field label not supported for %s: %s", scope.ScopeNodeResourceInfo.GroupVersionKind(), label)
},
)
if err != nil {
return err
}
return nil return nil
} }
func SelectableTimeIntervalsFields(obj *TimeInterval) fields.Set {
if obj == nil {
return nil
}
return generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false), fields.Set{
"metadata.provenance": obj.GetProvenanceStatus(),
"spec.name": obj.Spec.Name,
})
}
// Resource takes an unqualified resource and returns a Group qualified GroupResource // Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource { func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource() return SchemeGroupVersion.WithResource(resource).GroupResource()

View File

@@ -0,0 +1,46 @@
package v0alpha1
const ProvenanceStatusAnnotationKey = "grafana.com/provenance"
const ProvenanceStatusNone = "none"
func (o *TimeInterval) GetProvenanceStatus() string {
if o == nil || o.Annotations == nil {
return ""
}
s, ok := o.Annotations[ProvenanceStatusAnnotationKey]
if !ok || s == "" {
return ProvenanceStatusNone
}
return s
}
func (o *TimeInterval) SetProvenanceStatus(status string) {
if o.Annotations == nil {
o.Annotations = make(map[string]string, 1)
}
if status == "" {
status = ProvenanceStatusNone
}
o.Annotations[ProvenanceStatusAnnotationKey] = status
}
func (o *Receiver) GetProvenanceStatus() string {
if o == nil || o.Annotations == nil {
return ""
}
s, ok := o.Annotations[ProvenanceStatusAnnotationKey]
if !ok || s == "" {
return ProvenanceStatusNone
}
return s
}
func (o *Receiver) SetProvenanceStatus(status string) {
if o.Annotations == nil {
o.Annotations = make(map[string]string, 1)
}
if status == "" {
status = ProvenanceStatusNone
}
o.Annotations[ProvenanceStatusAnnotationKey] = status
}

View File

@@ -54,19 +54,18 @@ func convertToK8sResource(orgID int64, receiver definitions.GettableApiReceiver,
} }
uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage
return &model.Receiver{ r := &model.Receiver{
TypeMeta: resourceInfo.TypeMeta(), TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
UID: types.UID(uid), // This is needed to make PATCH work UID: types.UID(uid), // This is needed to make PATCH work
Name: uid, // TODO replace to stable UID when we switch to normal storage Name: uid, // TODO replace to stable UID when we switch to normal storage
Namespace: namespacer(orgID), Namespace: namespacer(orgID),
Annotations: map[string]string{ // TODO find a better place for provenance?
"grafana.com/provenance": string(provenance),
},
ResourceVersion: "", // TODO: Implement optimistic concurrency. ResourceVersion: "", // TODO: Implement optimistic concurrency.
}, },
Spec: spec, Spec: spec,
}, nil }
r.SetProvenanceStatus(string(provenance))
return r, nil
} }
func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) { func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) {

View File

@@ -6,6 +6,7 @@ import (
"hash/fnv" "hash/fnv"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
@@ -19,7 +20,7 @@ func getIntervalUID(t definitions.MuteTimeInterval) string {
return fmt.Sprintf("%016x", sum.Sum64()) return fmt.Sprintf("%016x", sum.Sum64())
} }
func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper) (*model.TimeIntervalList, error) { func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TimeIntervalList, error) {
data, err := json.Marshal(intervals) data, err := json.Marshal(intervals)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -30,23 +31,15 @@ func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval
return nil, err return nil, err
} }
result := &model.TimeIntervalList{} result := &model.TimeIntervalList{}
for idx := range specs { for idx := range specs {
interval := intervals[idx] interval := intervals[idx]
spec := specs[idx] spec := specs[idx]
uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage item := buildTimeInterval(orgID, interval, spec, namespacer)
result.Items = append(result.Items, model.TimeInterval{ if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableTimeIntervalsFields(&item)) {
TypeMeta: resourceInfo.TypeMeta(), continue
ObjectMeta: metav1.ObjectMeta{ }
UID: types.UID(uid), // TODO This is needed to make PATCH work result.Items = append(result.Items, item)
Name: uid, // TODO replace to stable UID when we switch to normal storage
Namespace: namespacer(orgID),
Annotations: map[string]string{ // TODO find a better place for provenance?
"grafana.com/provenance": string(interval.Provenance),
},
ResourceVersion: interval.Version,
},
Spec: spec,
})
} }
return result, nil return result, nil
} }
@@ -61,21 +54,24 @@ func convertToK8sResource(orgID int64, interval definitions.MuteTimeInterval, na
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := buildTimeInterval(orgID, interval, spec, namespacer)
return &result, nil
}
func buildTimeInterval(orgID int64, interval definitions.MuteTimeInterval, spec model.TimeIntervalSpec, namespacer request.NamespaceMapper) model.TimeInterval {
uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage
return &model.TimeInterval{ i := model.TimeInterval{
TypeMeta: resourceInfo.TypeMeta(), TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
UID: types.UID(uid), // TODO This is needed to make PATCH work UID: types.UID(uid), // TODO This is needed to make PATCH work
Name: uid, // TODO replace to stable UID when we switch to normal storage Name: uid, // TODO replace to stable UID when we switch to normal storage
Namespace: namespacer(orgID), Namespace: namespacer(orgID),
Annotations: map[string]string{ // TODO find a better place for provenance?
"grafana.com/provenance": string(interval.Provenance),
},
ResourceVersion: interval.Version, ResourceVersion: interval.Version,
}, },
Spec: spec, Spec: spec,
}, nil }
i.SetProvenanceStatus(string(interval.Provenance))
return i
} }
func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInterval, error) { func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInterval, error) {

View File

@@ -59,7 +59,7 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
return s.tableConverter.ConvertToTable(ctx, object, tableOptions) return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
} }
func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions) (runtime.Object, error) { func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) {
orgId, err := request.OrgIDForList(ctx) orgId, err := request.OrgIDForList(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -70,7 +70,7 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions
return nil, err return nil, err
} }
return convertToK8sResources(orgId, res, s.namespacer) return convertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector)
} }
func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) { func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) {

View File

@@ -4,10 +4,13 @@ import (
"fmt" "fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/registry/rest"
apistore "k8s.io/apiserver/pkg/storage"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@@ -63,7 +66,7 @@ func NewStorage(
NewListFunc: resourceInfo.NewListFunc, NewListFunc: resourceInfo.NewListFunc,
KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()),
KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()),
PredicateFunc: grafanaregistry.Matcher, PredicateFunc: Matcher,
DefaultQualifiedResource: resourceInfo.GroupResource(), DefaultQualifiedResource: resourceInfo.GroupResource(),
SingularQualifiedResource: resourceInfo.SingularGroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(),
TableConvertor: legacyStore.tableConverter, TableConvertor: legacyStore.tableConverter,
@@ -71,7 +74,7 @@ func NewStorage(
UpdateStrategy: strategy, UpdateStrategy: strategy,
DeleteStrategy: strategy, DeleteStrategy: strategy,
} }
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
if err := s.CompleteWithOptions(options); err != nil { if err := s.CompleteWithOptions(options); err != nil {
return nil, err return nil, err
} }
@@ -79,3 +82,19 @@ func NewStorage(
} }
return legacyStore, nil return legacyStore, nil
} }
func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) {
if s, ok := obj.(*model.TimeInterval); ok {
return s.Labels, model.SelectableTimeIntervalsFields(s), nil
}
return nil, nil, fmt.Errorf("object of type %T is not supported", obj)
}
// Matcher returns a generic.SelectionPredicate that matches on label and field selectors.
func Matcher(label labels.Selector, field fields.Selector) apistore.SelectionPredicate {
return apistore.SelectionPredicate{
Label: label,
Field: field,
GetAttrs: GetAttrs,
}
}

View File

@@ -17,8 +17,8 @@ var (
ErrBadAlertmanagerConfiguration = errutil.Internal("alerting.notification.configCorrupted").MustTemplate("Failed to unmarshal the Alertmanager configuration", errutil.WithPublic("Current Alertmanager configuration in the storage is corrupted. Reset the configuration or rollback to a recent valid one.")) ErrBadAlertmanagerConfiguration = errutil.Internal("alerting.notification.configCorrupted").MustTemplate("Failed to unmarshal the Alertmanager configuration", errutil.WithPublic("Current Alertmanager configuration in the storage is corrupted. Reset the configuration or rollback to a recent valid one."))
ErrProvenanceChangeNotAllowed = errutil.Forbidden("alerting.notifications.invalidProvenance").MustTemplate( ErrProvenanceChangeNotAllowed = errutil.Forbidden("alerting.notifications.invalidProvenance").MustTemplate(
"Resource with provenance status '{{ .Public.CurrentProvenance }}' cannot be managed via API that handles resources with provenance status '{{ .Public.TargetProvenance }}'", "Resource with provenance status '{{ .Public.SourceProvenance }}' cannot be managed via API that handles resources with provenance status '{{ .Public.TargetProvenance }}'",
errutil.WithPublic("Resource with provenance status '{{ .Public.CurrentProvenance }}' cannot be managed via API that handles resources with provenance status '{{ .Public.TargetProvenance }}'. You must use appropriate API to manage this resource"), errutil.WithPublic("Resource with provenance status '{{ .Public.SourceProvenance }}' cannot be managed via API that handles resources with provenance status '{{ .Public.TargetProvenance }}'. You must use appropriate API to manage this resource"),
) )
ErrVersionConflict = errutil.Conflict("alerting.notifications.conflict") ErrVersionConflict = errutil.Conflict("alerting.notifications.conflict")

View File

@@ -192,15 +192,13 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) {
var expected = &v0alpha1.TimeInterval{ var expected = &v0alpha1.TimeInterval{
ObjectMeta: v1.ObjectMeta{ ObjectMeta: v1.ObjectMeta{
Namespace: "default", Namespace: "default",
Annotations: map[string]string{
"grafana.com/provenance": "",
},
}, },
Spec: v0alpha1.TimeIntervalSpec{ Spec: v0alpha1.TimeIntervalSpec{
Name: fmt.Sprintf("time-interval-1-%s", tc.user.Identity.GetLogin()), Name: fmt.Sprintf("time-interval-1-%s", tc.user.Identity.GetLogin()),
TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2), TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2),
}, },
} }
expected.SetProvenanceStatus("")
d, err := json.Marshal(expected) d, err := json.Marshal(expected)
require.NoError(t, err) require.NoError(t, err)
@@ -363,7 +361,7 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) {
}, },
}, v1.CreateOptions{}) }, v1.CreateOptions{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "", created.Annotations["grafana.com/provenance"]) require.Equal(t, "none", created.GetProvenanceStatus())
t.Run("should provide provenance status", func(t *testing.T) { t.Run("should provide provenance status", func(t *testing.T) {
require.NoError(t, db.SetProvenance(ctx, &definitions.MuteTimeInterval{ require.NoError(t, db.SetProvenance(ctx, &definitions.MuteTimeInterval{
@@ -374,7 +372,7 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) {
got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "API", got.Annotations["grafana.com/provenance"]) require.Equal(t, "API", got.GetProvenanceStatus())
}) })
t.Run("should not let update if provisioned", func(t *testing.T) { t.Run("should not let update if provisioned", func(t *testing.T) {
updated := created.DeepCopy() updated := created.DeepCopy()
@@ -540,3 +538,91 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) {
current = result current = result
}) })
} }
func TestIntegrationTimeIntervalListSelector(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
helper := getTestHelper(t)
adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig())
require.NoError(t, err)
adminClient := adminK8sClient.NotificationsV0alpha1().TimeIntervals("default")
interval1 := &v0alpha1.TimeInterval{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
},
Spec: v0alpha1.TimeIntervalSpec{
Name: "test1",
TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2),
},
}
interval1, err = adminClient.Create(ctx, interval1, v1.CreateOptions{})
require.NoError(t, err)
interval2 := &v0alpha1.TimeInterval{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
},
Spec: v0alpha1.TimeIntervalSpec{
Name: "test2",
TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2),
},
}
interval2, err = adminClient.Create(ctx, interval2, v1.CreateOptions{})
require.NoError(t, err)
env := helper.GetEnv()
ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient())
db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac)
require.NoError(t, err)
require.NoError(t, db.SetProvenance(ctx, &definitions.MuteTimeInterval{
MuteTimeInterval: config.MuteTimeInterval{
Name: interval2.Spec.Name,
},
}, helper.Org1.Admin.Identity.GetOrgID(), "API"))
interval2, err = adminClient.Get(ctx, interval2.Name, v1.GetOptions{})
require.NoError(t, err)
intervals, err := adminClient.List(ctx, v1.ListOptions{})
require.NoError(t, err)
require.Len(t, intervals.Items, 2)
t.Run("should filter by interval name", func(t *testing.T) {
list, err := adminClient.List(ctx, v1.ListOptions{
FieldSelector: "spec.name=" + interval1.Spec.Name,
})
require.NoError(t, err)
require.Len(t, list.Items, 1)
require.Equal(t, interval1.Name, list.Items[0].Name)
})
t.Run("should filter by interval metadata name", func(t *testing.T) {
list, err := adminClient.List(ctx, v1.ListOptions{
FieldSelector: "metadata.name=" + interval2.Name,
})
require.NoError(t, err)
require.Len(t, list.Items, 1)
require.Equal(t, interval2.Name, list.Items[0].Name)
})
t.Run("should filter by multiple filters", func(t *testing.T) {
list, err := adminClient.List(ctx, v1.ListOptions{
FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", interval2.Name, "API"),
})
require.NoError(t, err)
require.Len(t, list.Items, 1)
require.Equal(t, interval2.Name, list.Items[0].Name)
})
t.Run("should be empty when filter does not match", func(t *testing.T) {
list, err := adminClient.List(ctx, v1.ListOptions{
FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", interval2.Name, "unknown"),
})
require.NoError(t, err)
require.Empty(t, list.Items)
})
}