Alerting: Include in-use metadata in k8s receiver LIST & GET (#93016)

* Include in-use metadata in k8s receiver List & Get
This commit is contained in:
Matthew Jacobson
2024-09-13 20:20:09 +03:00
committed by GitHub
parent bd9fc8127b
commit 3bf77d2e05
9 changed files with 379 additions and 13 deletions
@@ -1,6 +1,8 @@
package v0alpha1
import "fmt"
import (
"fmt"
)
const InternalPrefix = "grafana.com/"
const ProvenanceStatusAnnotationKey = InternalPrefix + "provenance"
@@ -60,3 +62,17 @@ func (o *Receiver) SetAccessControl(action string) {
func AccessControlAnnotation(action string) string {
return fmt.Sprintf("%s%s/%s", InternalPrefix, "access", action)
}
func (o *Receiver) SetInUse(routesCnt int, rules []string) {
if o.Annotations == nil {
o.Annotations = make(map[string]string, 2)
}
o.Annotations[InUseAnnotation("routes")] = fmt.Sprintf("%d", routesCnt)
o.Annotations[InUseAnnotation("rules")] = fmt.Sprintf("%d", len(rules))
}
// InUseAnnotation returns the key for the in-use annotation for the given resource.
// Ex. grafana.com/inUse/routes, grafana.com/inUse/rules.
func InUseAnnotation(resource string) string {
return fmt.Sprintf("%s%s/%s", InternalPrefix, "inUse", resource)
}
@@ -15,7 +15,14 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
)
func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, accesses map[string]ngmodels.ReceiverPermissionSet, namespacer request.NamespaceMapper, selector fields.Selector) (*model.ReceiverList, error) {
func convertToK8sResources(
orgID int64,
receivers []*ngmodels.Receiver,
accesses map[string]ngmodels.ReceiverPermissionSet,
metadatas map[string]ngmodels.ReceiverMetadata,
namespacer request.NamespaceMapper,
selector fields.Selector,
) (*model.ReceiverList, error) {
result := &model.ReceiverList{
Items: make([]model.Receiver, 0, len(receivers)),
}
@@ -26,7 +33,13 @@ func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, accesses
access = &a
}
}
k8sResource, err := convertToK8sResource(orgID, receiver, access, namespacer)
var metadata *ngmodels.ReceiverMetadata
if metadatas != nil {
if m, ok := metadatas[receiver.GetUID()]; ok {
metadata = &m
}
}
k8sResource, err := convertToK8sResource(orgID, receiver, access, metadata, namespacer)
if err != nil {
return nil, err
}
@@ -38,7 +51,13 @@ func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, accesses
return result, nil
}
func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, access *ngmodels.ReceiverPermissionSet, namespacer request.NamespaceMapper) (*model.Receiver, error) {
func convertToK8sResource(
orgID int64,
receiver *ngmodels.Receiver,
access *ngmodels.ReceiverPermissionSet,
metadata *ngmodels.ReceiverMetadata,
namespacer request.NamespaceMapper,
) (*model.Receiver, error) {
spec := model.ReceiverSpec{
Title: receiver.Name,
}
@@ -76,6 +95,14 @@ func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, access *ngmo
}
}
if metadata != nil {
rules := make([]string, 0, len(metadata.InUseByRules))
for _, rule := range metadata.InUseByRules {
rules = append(rules, rule.UID)
}
r.SetInUse(metadata.InUseByRoutes, rules)
}
return r, nil
}
@@ -34,7 +34,8 @@ type ReceiverService interface {
}
type MetadataService interface {
Access(ctx context.Context, user identity.Requester, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverPermissionSet, error)
AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverPermissionSet, error)
InUseMetadata(ctx context.Context, orgID int64, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverMetadata, error)
}
type legacyStorage struct {
@@ -90,12 +91,17 @@ func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOpti
return nil, err
}
accesses, err := s.metadata.Access(ctx, user, res...)
accesses, err := s.metadata.AccessControlMetadata(ctx, user, res...)
if err != nil {
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
return convertToK8sResources(orgId, res, accesses, s.namespacer, opts.FieldSelector)
inUses, err := s.metadata.InUseMetadata(ctx, orgId, res...)
if err != nil {
return nil, fmt.Errorf("failed to get in-use metadata: %w", err)
}
return convertToK8sResources(orgId, res, accesses, inUses, s.namespacer, opts.FieldSelector)
}
func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) {
@@ -125,7 +131,7 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
}
var access *ngmodels.ReceiverPermissionSet
accesses, err := s.metadata.Access(ctx, user, r)
accesses, err := s.metadata.AccessControlMetadata(ctx, user, r)
if err == nil {
if a, ok := accesses[r.GetUID()]; ok {
access = &a
@@ -134,7 +140,17 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
return convertToK8sResource(info.OrgID, r, access, s.namespacer)
var inUse *ngmodels.ReceiverMetadata
inUses, err := s.metadata.InUseMetadata(ctx, info.OrgID, r)
if err == nil {
if a, ok := inUses[r.GetUID()]; ok {
inUse = &a
}
} else {
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
return convertToK8sResource(info.OrgID, r, access, inUse, s.namespacer)
}
func (s *legacyStorage) Create(ctx context.Context,
@@ -172,7 +188,7 @@ func (s *legacyStorage) Create(ctx context.Context,
if err != nil {
return nil, err
}
return convertToK8sResource(info.OrgID, out, nil, s.namespacer)
return convertToK8sResource(info.OrgID, out, nil, nil, s.namespacer)
}
func (s *legacyStorage) Update(ctx context.Context,
@@ -224,7 +240,7 @@ func (s *legacyStorage) Update(ctx context.Context,
return nil, false, err
}
r, err := convertToK8sResource(info.OrgID, updated, nil, s.namespacer)
r, err := convertToK8sResource(info.OrgID, updated, nil, nil, s.namespacer)
return r, false, err
}
@@ -85,7 +85,7 @@ func (t *NotificationsAPIBuilder) GetAPIGroupInfo(
return nil, fmt.Errorf("failed to initialize time-interval storage: %w", err)
}
recvStorage, err := receiver.NewStorage(t.ng.Api.ReceiverService, t.namespacer, scheme, optsGetter, dualWriteBuilder, ac.NewReceiverAccess[*ngmodels.Receiver](t.ng.Api.AccessControl, false))
recvStorage, err := receiver.NewStorage(t.ng.Api.ReceiverService, t.namespacer, scheme, optsGetter, dualWriteBuilder, t.ng.Api.ReceiverService)
if err != nil {
return nil, fmt.Errorf("failed to initialize receiver storage: %w", err)
}
+6
View File
@@ -40,6 +40,12 @@ type ListReceiversQuery struct {
Offset int
}
// ReceiverMetadata contains metadata about a receiver's usage in routes and rules.
type ReceiverMetadata struct {
InUseByRules []AlertRuleKey
InUseByRoutes int
}
// Receiver is the domain model representation of a receiver / contact point.
type Receiver struct {
UID string
@@ -70,10 +70,18 @@ func (rev *ConfigRevision) UpdateReceiver(receiver *models.Receiver) error {
return nil
}
// ReceiverNameUsedByRoutes checks if a receiver name is used in any routes.
func (rev *ConfigRevision) ReceiverNameUsedByRoutes(name string) bool {
return isReceiverInUse(name, []*definitions.Route{rev.Config.AlertmanagerConfig.Route})
}
// ReceiverUseByName returns a map of receiver names to the number of times they are used in routes.
func (rev *ConfigRevision) ReceiverUseByName() map[string]int {
m := make(map[string]int)
receiverUseCounts([]*definitions.Route{rev.Config.AlertmanagerConfig.Route}, m)
return m
}
func (rev *ConfigRevision) GetReceiver(uid string) (*definitions.PostableApiReceiver, error) {
for _, r := range rev.Config.AlertmanagerConfig.Receivers {
if NameToUid(r.GetName()) == uid {
@@ -155,6 +163,17 @@ func isReceiverInUse(name string, routes []*definitions.Route) bool {
return false
}
// receiverUseCounts counts how many times receivers are used in a route or any of its sub-routes.
func receiverUseCounts(routes []*definitions.Route, m map[string]int) {
if len(routes) == 0 {
return
}
for _, route := range routes {
m[route.Receiver]++
receiverUseCounts(route.Routes, m)
}
}
// validateAndSetIntegrationUIDs validates existing integration UIDs and generates them if they are empty.
func validateAndSetIntegrationUIDs(receiver *models.Receiver) error {
for _, integration := range receiver.Integrations {
@@ -63,6 +63,8 @@ type receiverAccessControlService interface {
AuthorizeCreate(context.Context, identity.Requester) error
AuthorizeUpdate(context.Context, identity.Requester, *models.Receiver) error
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
Access(ctx context.Context, user identity.Requester, receivers ...*models.Receiver) (map[string]models.ReceiverPermissionSet, error)
}
type alertmanagerConfigStore interface {
@@ -444,6 +446,48 @@ func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name st
return maps.Keys(keys), nil
}
// AccessControlMetadata returns access control metadata for the given Receivers.
func (rs *ReceiverService) AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*models.Receiver) (map[string]models.ReceiverPermissionSet, error) {
return rs.authz.Access(ctx, user, receivers...)
}
// InUseMetadata returns metadata for the given Receivers about their usage in routes and rules.
func (rs *ReceiverService) InUseMetadata(ctx context.Context, orgID int64, receivers ...*models.Receiver) (map[string]models.ReceiverMetadata, error) {
revision, err := rs.cfgStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
receiverUses := revision.ReceiverUseByName()
q := models.ListNotificationSettingsQuery{OrgID: orgID}
if len(receivers) == 1 {
q.ReceiverName = receivers[0].Name
}
keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, q)
if err != nil {
return nil, err
}
byReceiver := map[string][]models.AlertRuleKey{}
for key, settings := range keys {
for _, s := range settings {
if s.Receiver != "" {
byReceiver[s.Receiver] = append(byReceiver[s.Receiver], key)
}
}
}
results := make(map[string]models.ReceiverMetadata, len(receivers))
for _, rcv := range receivers {
results[rcv.GetUID()] = models.ReceiverMetadata{
InUseByRoutes: receiverUses[rcv.Name],
InUseByRules: byReceiver[rcv.Name],
}
}
return results, nil
}
func removedIntegrations(old, new *models.Receiver) []*models.Integration {
updatedUIDs := make(map[string]struct{}, len(new.Integrations))
for _, integration := range new.Integrations {
@@ -1329,6 +1329,115 @@ func TestReceiverServiceAC_Delete(t *testing.T) {
}
}
func TestReceiverService_InUseMetadata(t *testing.T) {
secretsService := fake_secrets.NewFakeSecretsService()
admin := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{
1: {
accesscontrol.ActionAlertingNotificationsWrite: nil,
accesscontrol.ActionAlertingNotificationsRead: nil,
},
}}
for _, tc := range []struct {
name string
user identity.Requester
storeRoute definitions.Route
storeSettings map[models.AlertRuleKey][]models.NotificationSettings
existing []*models.Receiver
expectedMetadata map[string]models.ReceiverMetadata
}{
{
name: "mixed metadata",
user: admin,
existing: []*models.Receiver{
util.Pointer(models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"))()),
util.Pointer(models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"))()),
util.Pointer(models.ReceiverGen(models.ReceiverMuts.WithName("receiver3"))()),
util.Pointer(models.ReceiverGen(models.ReceiverMuts.WithName("receiver4"))()),
},
storeSettings: map[models.AlertRuleKey][]models.NotificationSettings{
{OrgID: 1, UID: "rule1uid"}: {
models.NotificationSettingsGen(models.NSMuts.WithReceiver("receiver1"))(),
models.NotificationSettingsGen(models.NSMuts.WithReceiver("receiver2"))(),
},
{OrgID: 1, UID: "rule2uid"}: {
models.NotificationSettingsGen(models.NSMuts.WithReceiver("receiver2"))(),
models.NotificationSettingsGen(models.NSMuts.WithReceiver("receiver3"))(),
},
},
storeRoute: definitions.Route{
Receiver: "receiver1",
Routes: []*definitions.Route{
{Receiver: "receiver2"},
{Receiver: "receiver3"},
{
Receiver: "receiver4",
Routes: []*definitions.Route{
{Receiver: "receiver1"},
{Receiver: "receiver3"},
},
},
},
},
expectedMetadata: map[string]models.ReceiverMetadata{
legacy_storage.NameToUid("receiver1"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule1uid"}},
InUseByRoutes: 2,
},
legacy_storage.NameToUid("receiver2"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule1uid"}, {OrgID: 1, UID: "rule2uid"}},
InUseByRoutes: 1,
},
legacy_storage.NameToUid("receiver3"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule2uid"}},
InUseByRoutes: 2,
},
legacy_storage.NameToUid("receiver4"): {
InUseByRules: []models.AlertRuleKey{},
InUseByRoutes: 1,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
sut := createReceiverServiceSut(t, &secretsService)
store := sut.ruleNotificationsStore.(*fakeConfigStore)
store.notificationSettings = map[int64]map[models.AlertRuleKey][]models.NotificationSettings{
1: make(map[models.AlertRuleKey][]models.NotificationSettings),
}
for key, settings := range tc.storeSettings {
store.notificationSettings[tc.user.GetOrgID()][key] = settings
}
for _, recv := range tc.existing {
_, err := sut.CreateReceiver(context.Background(), recv, tc.user.GetOrgID(), tc.user)
require.NoError(t, err)
}
// Create route after receivers as they will be referenced.
revision, err := sut.cfgStore.Get(context.Background(), tc.user.GetOrgID())
require.NoError(t, err)
revision.Config.AlertmanagerConfig.Route = &tc.storeRoute
err = sut.cfgStore.Save(context.Background(), revision, tc.user.GetOrgID())
require.NoError(t, err)
metadata, err := sut.InUseMetadata(context.Background(), tc.user.GetOrgID(), tc.existing...)
require.NoError(t, err)
assert.Lenf(t, metadata, len(tc.expectedMetadata), "unexpected metadata length")
for _, recv := range tc.existing {
expected, ok := tc.expectedMetadata[recv.UID]
assert.Truef(t, ok, "missing metadata for receiver uid: %q, name: %q", recv.UID, recv.Name)
assert.ElementsMatch(t, expected.InUseByRules, metadata[recv.UID].InUseByRules, "unexpected rules metadata for receiver uid: %q, name: %q", recv.UID, recv.Name)
assert.Equalf(t, expected.InUseByRoutes, metadata[recv.UID].InUseByRoutes, "unexpected routes metadata for receiver uid: %q, name: %q", recv.UID, recv.Name)
}
})
}
}
func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverService {
cfg := createEncryptedConfig(t, encryptSvc)
store := fakes.NewFakeAlertmanagerConfigStore(cfg)
@@ -300,7 +300,9 @@ func TestIntegrationAccessControl(t *testing.T) {
}
if tc.canRead {
// Set expected metadata.
expectedWithMetadata := expected.DeepCopy()
expectedWithMetadata.SetInUse(0, nil)
if tc.canUpdate {
expectedWithMetadata.SetAccessControl("canWrite")
}
@@ -413,6 +415,132 @@ func TestIntegrationAccessControl(t *testing.T) {
}
}
func TestIntegrationInUseMetadata(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
helper := getTestHelper(t)
cliCfg := helper.Org1.Admin.NewRestConfig()
legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password)
// Prepare environment and create notification policy and rule that use receiver
alertmanagerRaw, err := testData.ReadFile(path.Join("test-data", "notification-settings.json"))
require.NoError(t, err)
var amConfig definitions.PostableUserConfig
require.NoError(t, json.Unmarshal(alertmanagerRaw, &amConfig))
// Add more references to the receiver in other routes.
route1 := *amConfig.AlertmanagerConfig.Route.Routes[0]
route1.Routes = nil
route2 := route1
parentRoute := *amConfig.AlertmanagerConfig.Route.Routes[0]
parentRoute.Routes = []*definitions.Route{&route1, &route2}
amConfig.AlertmanagerConfig.Route.Routes = append(amConfig.AlertmanagerConfig.Route.Routes, &parentRoute)
success, err := legacyCli.PostConfiguration(t, amConfig)
require.Truef(t, success, "Failed to post Alertmanager configuration: %s", err)
postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1.json"))
require.NoError(t, err)
var ruleGroup definitions.PostableRuleGroupConfig
require.NoError(t, json.Unmarshal(postGroupRaw, &ruleGroup))
// Add more references to the receiver by creating adding same rule with a different title.
ruleGen := func() definitions.PostableGrafanaRule { return *ruleGroup.Rules[0].GrafanaManagedAlert }
rule2 := ruleGen()
rule2.Title = "Rule2"
rule2.NotificationSettings = &definitions.AlertRuleNotificationSettings{Receiver: "grafana-default-email"}
rule3 := ruleGen()
rule3.Title = "Rule3"
ruleGroup.Rules = append(ruleGroup.Rules,
definitions.PostableExtendedRuleNode{
ApiRuleNode: ruleGroup.Rules[0].ApiRuleNode,
GrafanaManagedAlert: &rule2,
},
definitions.PostableExtendedRuleNode{
ApiRuleNode: ruleGroup.Rules[0].ApiRuleNode,
GrafanaManagedAlert: &rule3,
},
)
folderUID := "test-folder"
legacyCli.CreateFolder(t, folderUID, "TEST")
_, status, data := legacyCli.PostRulesGroupWithStatus(t, folderUID, &ruleGroup)
require.Equalf(t, http.StatusAccepted, status, "Failed to post Rule: %s", data)
adminK8sClient, err := versioned.NewForConfig(cliCfg)
require.NoError(t, err)
adminClient := adminK8sClient.NotificationsV0alpha1().Receivers("default")
requestReceivers := func(t *testing.T, title string) (v0alpha1.Receiver, v0alpha1.Receiver) {
t.Helper()
receivers, err := adminClient.List(ctx, v1.ListOptions{})
require.NoError(t, err)
require.Len(t, receivers.Items, 2)
idx := slices.IndexFunc(receivers.Items, func(interval v0alpha1.Receiver) bool {
return interval.Spec.Title == title
})
receiverListed := receivers.Items[idx]
receiverGet, err := adminClient.Get(ctx, receiverListed.Name, v1.GetOptions{})
require.NoError(t, err)
return receiverListed, *receiverGet
}
checkInUse := func(t *testing.T, receiverList, receiverGet v0alpha1.Receiver, routes, rules int) {
t.Helper()
assert.Equalf(t, fmt.Sprintf("%d", routes), receiverList.Annotations[v0alpha1.InUseAnnotation("routes")], "LIST: Expected %s used by %d routes", receiverList.Spec.Title, routes)
assert.Equalf(t, fmt.Sprintf("%d", rules), receiverList.Annotations[v0alpha1.InUseAnnotation("rules")], "LIST: Expected %s used by %d rules", receiverList.Spec.Title, rules)
assert.Equalf(t, fmt.Sprintf("%d", routes), receiverGet.Annotations[v0alpha1.InUseAnnotation("routes")], "GET: Expected %s used by %d routes", receiverGet.Spec.Title, routes)
assert.Equalf(t, fmt.Sprintf("%d", rules), receiverGet.Annotations[v0alpha1.InUseAnnotation("rules")], "GET: Expected %s used by %d rules", receiverGet.Spec.Title, rules)
}
receiverListed, receiverGet := requestReceivers(t, "user-defined")
checkInUse(t, receiverListed, receiverGet, 4, 2)
// Verify the default.
receiverListed, receiverGet = requestReceivers(t, "grafana-default-email")
checkInUse(t, receiverListed, receiverGet, 1, 1)
// Removing the new extra route should leave only 1.
amConfig.AlertmanagerConfig.Route.Routes = amConfig.AlertmanagerConfig.Route.Routes[:1]
success, err = legacyCli.PostConfiguration(t, amConfig)
require.Truef(t, success, "Failed to post Alertmanager configuration: %s", err)
receiverListed, receiverGet = requestReceivers(t, "user-defined")
checkInUse(t, receiverListed, receiverGet, 1, 2)
// Remove the extra rules.
ruleGroup.Rules = ruleGroup.Rules[:1]
_, status, data = legacyCli.PostRulesGroupWithStatus(t, folderUID, &ruleGroup)
require.Equalf(t, http.StatusAccepted, status, "Failed to post Rule: %s", data)
receiverListed, receiverGet = requestReceivers(t, "user-defined")
checkInUse(t, receiverListed, receiverGet, 1, 1)
receiverListed, receiverGet = requestReceivers(t, "grafana-default-email")
checkInUse(t, receiverListed, receiverGet, 1, 0)
// Remove the rest.
amConfig.AlertmanagerConfig.Route.Routes = nil
success, err = legacyCli.PostConfiguration(t, amConfig)
require.Truef(t, success, "Failed to post Alertmanager configuration: %s", err)
ruleGroup.Rules = nil
_, status, data = legacyCli.PostRulesGroupWithStatus(t, folderUID, &ruleGroup)
require.Equalf(t, http.StatusAccepted, status, "Failed to post Rule: %s", data)
receiverListed, receiverGet = requestReceivers(t, "user-defined")
checkInUse(t, receiverListed, receiverGet, 0, 0)
receiverListed, receiverGet = requestReceivers(t, "grafana-default-email")
checkInUse(t, receiverListed, receiverGet, 1, 0)
}
func TestIntegrationProvisioning(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
@@ -883,9 +1011,10 @@ func TestIntegrationCRUD(t *testing.T) {
require.NoError(t, err)
require.Len(t, receiver.Spec.Integrations, len(integrations))
// Set access control metadata
// Set expected metadata
receiver.SetAccessControl("canWrite")
receiver.SetAccessControl("canDelete")
receiver.SetInUse(0, nil)
// Use export endpoint because it's the only way to get decrypted secrets fast.
cliCfg := helper.Org1.Admin.NewRestConfig()