diff --git a/pkg/apis/alerting_notifications/v0alpha1/register.go b/pkg/apis/alerting_notifications/v0alpha1/register.go index cbf36e4aab4..c773957155c 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/register.go +++ b/pkg/apis/alerting_notifications/v0alpha1/register.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() diff --git a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go new file mode 100644 index 00000000000..df9079bf675 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go @@ -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 +} diff --git a/pkg/registry/apis/alerting/notifications/receiver/conversions.go b/pkg/registry/apis/alerting/notifications/receiver/conversions.go index cd38ad5e805..b4a19200381 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/conversions.go +++ b/pkg/registry/apis/alerting/notifications/receiver/conversions.go @@ -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) { diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go index cacb6f4369a..ae8ed546c98 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go @@ -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) { diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go index 64d95e17a12..ac2137a2cb0 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go @@ -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) { diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go index efbddd08f14..d51c75d7732 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go @@ -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, + } +} diff --git a/pkg/services/ngalert/provisioning/errors.go b/pkg/services/ngalert/provisioning/errors.go index 0f95920f880..4c164381816 100644 --- a/pkg/services/ngalert/provisioning/errors.go +++ b/pkg/services/ngalert/provisioning/errors.go @@ -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") diff --git a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go index a7312545c2d..ae3cf8bc6b1 100644 --- a/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go +++ b/pkg/tests/apis/alerting/notifications/timeinterval/timeinterval_test.go @@ -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) + }) +}