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
pull/90210/head
Yuri Tseretyan 1 year ago committed by GitHub
parent 63e715f6a9
commit 5ae5fa3a7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 34
      pkg/apis/alerting_notifications/v0alpha1/register.go
  2. 46
      pkg/apis/alerting_notifications/v0alpha1/types_ext.go
  3. 15
      pkg/registry/apis/alerting/notifications/receiver/conversions.go
  4. 42
      pkg/registry/apis/alerting/notifications/timeinterval/conversions.go
  5. 4
      pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go
  6. 23
      pkg/registry/apis/alerting/notifications/timeinterval/storage.go
  7. 4
      pkg/services/ngalert/provisioning/errors.go
  8. 96
      pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go

@ -1,11 +1,16 @@
package v0alpha1
import (
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/generic"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
scope "github.com/grafana/grafana/pkg/apis/scope/v0alpha1"
)
func init() {
@ -20,7 +25,7 @@ const (
var (
TimeIntervalResourceInfo = common.NewResourceInfo(GROUP, VERSION,
"timeintervals", "timeinterval", "TimeIntervals",
"timeintervals", "timeinterval", "TimeInterval",
func() runtime.Object { return &TimeInterval{} },
func() runtime.Object { return &TimeIntervalList{} },
)
@ -51,9 +56,36 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error {
&ReceiverList{},
)
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
}
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
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()

@ -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
}

@ -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
return &model.Receiver{
r := &model.Receiver{
TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
UID: types.UID(uid), // This is needed to make PATCH work
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(provenance),
},
UID: types.UID(uid), // This is needed to make PATCH work
Name: uid, // TODO replace to stable UID when we switch to normal storage
Namespace: namespacer(orgID),
ResourceVersion: "", // TODO: Implement optimistic concurrency.
},
Spec: spec,
}, nil
}
r.SetProvenanceStatus(string(provenance))
return r, nil
}
func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) {

@ -6,6 +6,7 @@ import (
"hash/fnv"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
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())
}
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)
if err != nil {
return nil, err
@ -30,23 +31,15 @@ func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval
return nil, err
}
result := &model.TimeIntervalList{}
for idx := range specs {
interval := intervals[idx]
spec := specs[idx]
uid := getIntervalUID(interval) // TODO replace to stable UID when we switch to normal storage
result.Items = append(result.Items, model.TimeInterval{
TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
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
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,
})
item := buildTimeInterval(orgID, interval, spec, namespacer)
if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableTimeIntervalsFields(&item)) {
continue
}
result.Items = append(result.Items, item)
}
return result, nil
}
@ -61,21 +54,24 @@ func convertToK8sResource(orgID int64, interval definitions.MuteTimeInterval, na
if err != nil {
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
return &model.TimeInterval{
i := model.TimeInterval{
TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
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
Namespace: namespacer(orgID),
Annotations: map[string]string{ // TODO find a better place for provenance?
"grafana.com/provenance": string(interval.Provenance),
},
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
Namespace: namespacer(orgID),
ResourceVersion: interval.Version,
},
Spec: spec,
}, nil
}
i.SetProvenanceStatus(string(interval.Provenance))
return i
}
func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInterval, error) {

@ -59,7 +59,7 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
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)
if err != nil {
return nil, err
@ -70,7 +70,7 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions
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) {

@ -4,10 +4,13 @@ import (
"fmt"
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/apiserver/pkg/registry/generic"
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest"
apistore "k8s.io/apiserver/pkg/storage"
"github.com/prometheus/client_golang/prometheus"
@ -63,7 +66,7 @@ func NewStorage(
NewListFunc: resourceInfo.NewListFunc,
KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()),
KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()),
PredicateFunc: grafanaregistry.Matcher,
PredicateFunc: Matcher,
DefaultQualifiedResource: resourceInfo.GroupResource(),
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
TableConvertor: legacyStore.tableConverter,
@ -71,7 +74,7 @@ func NewStorage(
UpdateStrategy: 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 {
return nil, err
}
@ -79,3 +82,19 @@ func NewStorage(
}
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,
}
}

@ -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."))
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 }}'",
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"),
"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.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")

@ -192,15 +192,13 @@ func TestIntegrationTimeIntervalAccessControl(t *testing.T) {
var expected = &v0alpha1.TimeInterval{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{
"grafana.com/provenance": "",
},
},
Spec: v0alpha1.TimeIntervalSpec{
Name: fmt.Sprintf("time-interval-1-%s", tc.user.Identity.GetLogin()),
TimeIntervals: v0alpha1.IntervalGenerator{}.GenerateMany(2),
},
}
expected.SetProvenanceStatus("")
d, err := json.Marshal(expected)
require.NoError(t, err)
@ -363,7 +361,7 @@ func TestIntegrationTimeIntervalProvisioning(t *testing.T) {
},
}, v1.CreateOptions{})
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) {
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{})
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) {
updated := created.DeepCopy()
@ -540,3 +538,91 @@ func TestIntegrationTimeIntervalPatch(t *testing.T) {
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)
})
}

Loading…
Cancel
Save