Alerting: Receiver API Get+List+Delete (#90384)

This commit is contained in:
Matthew Jacobson 2024-07-16 10:02:16 -04:00 committed by GitHub
parent efdb08ed8c
commit b7f422b68d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 233 additions and 67 deletions

View File

@ -1,5 +1,7 @@
package v0alpha1
import "encoding/json"
// Integration defines model for Integration.
// +k8s:openapi-gen=true
type Integration struct {
@ -7,9 +9,9 @@ type Integration struct {
// +mapType=atomic
SecureFields map[string]bool `json:"SecureFields,omitempty"`
// +listType=atomic
Settings []byte `json:"settings"`
Type string `json:"type"`
Uid *string `json:"uid,omitempty"`
Settings json.RawMessage `json:"settings"`
Type string `json:"type"`
Uid *string `json:"uid,omitempty"`
}
// ReceiverSpec defines model for Spec.

View File

@ -8,6 +8,8 @@
package v0alpha1
import (
json "encoding/json"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@ -28,7 +30,7 @@ func (in *Integration) DeepCopyInto(out *Integration) {
}
if in.Settings != nil {
in, out := &in.Settings, &out.Settings
*out = make([]byte, len(*in))
*out = make(json.RawMessage, len(*in))
copy(*out, *in)
}
if in.Uid != nil {

View File

@ -4,14 +4,18 @@
package v0alpha1
import (
json "encoding/json"
)
// IntegrationApplyConfiguration represents an declarative configuration of the Integration type for use
// with apply.
type IntegrationApplyConfiguration struct {
DisableResolveMessage *bool `json:"disableResolveMessage,omitempty"`
SecureFields map[string]bool `json:"SecureFields,omitempty"`
Settings []byte `json:"settings,omitempty"`
Type *string `json:"type,omitempty"`
Uid *string `json:"uid,omitempty"`
DisableResolveMessage *bool `json:"disableResolveMessage,omitempty"`
SecureFields map[string]bool `json:"SecureFields,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"`
Type *string `json:"type,omitempty"`
Uid *string `json:"uid,omitempty"`
}
// IntegrationApplyConfiguration constructs an declarative configuration of the Integration type for use with
@ -42,13 +46,11 @@ func (b *IntegrationApplyConfiguration) WithSecureFields(entries map[string]bool
return b
}
// WithSettings adds the given value to the Settings field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Settings field.
func (b *IntegrationApplyConfiguration) WithSettings(values ...byte) *IntegrationApplyConfiguration {
for i := range values {
b.Settings = append(b.Settings, values[i])
}
// WithSettings sets the Settings field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Settings field is set to the value of the last call.
func (b *IntegrationApplyConfiguration) WithSettings(value json.RawMessage) *IntegrationApplyConfiguration {
b.Settings = &value
return b
}

View File

@ -1,6 +1,7 @@
package receiver
import (
"encoding/json"
"fmt"
"hash/fnv"
@ -48,7 +49,7 @@ func convertToK8sResource(orgID int64, receiver definitions.GettableApiReceiver,
Uid: &integration.UID,
Type: integration.Type,
DisableResolveMessage: &integration.DisableResolveMessage,
Settings: integration.Settings,
Settings: json.RawMessage(integration.Settings),
SecureFields: integration.SecureFields,
})
}
@ -83,7 +84,7 @@ func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiRece
grafanaIntegration := definitions.GettableGrafanaReceiver{
Name: receiver.Spec.Title,
Type: integration.Type,
Settings: integration.Settings,
Settings: definitions.RawMessage(integration.Settings),
SecureFields: integration.SecureFields,
//Provenance: "", //TODO: Convert provenance?
}

View File

@ -93,9 +93,8 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, err
}
q := models.GetReceiverQuery{
q := models.GetReceiversQuery{
OrgID: info.OrgID,
Name: uid, // TODO: Name/UID mapping or change signature of service.
//Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params.
}
@ -104,12 +103,18 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, err
}
res, err := s.service.GetReceiver(ctx, q, user)
res, err := s.service.GetReceivers(ctx, q, user)
if err != nil {
return nil, err
}
return convertToK8sResource(info.OrgID, res, s.namespacer)
for _, r := range res {
if getUID(r) == uid {
return convertToK8sResource(info.OrgID, r, s.namespacer)
}
}
return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
}
func (s *legacyStorage) Create(ctx context.Context,
@ -211,13 +216,9 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil {
version = *options.Preconditions.ResourceVersion
}
p, ok := old.(*notifications.Receiver)
if !ok {
return nil, false, fmt.Errorf("expected receiver but got %s", old.GetObjectKind().GroupVersionKind())
}
err = s.service.DeleteReceiver(ctx, p.Spec.Title, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
return old, false, err // false - will be deleted async
err = s.service.DeleteReceiver(ctx, uid, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
return old, false, err // false - will be deleted async
}
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {

View File

@ -80,7 +80,7 @@ func (t NotificationsAPIBuilder) GetAPIGroupInfo(
return nil, fmt.Errorf("failed to initialize time-interval storage: %w", err)
}
recvStorage, err := receiver.NewStorage(nil, t.namespacer, scheme, optsGetter, dualWriteBuilder) // TODO: add receiver service
recvStorage, err := receiver.NewStorage(t.ng.Api.ReceiverService, t.namespacer, scheme, optsGetter, dualWriteBuilder)
if err != nil {
return nil, fmt.Errorf("failed to initialize receiver storage: %w", err)
}

View File

@ -5,13 +5,17 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"slices"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning/validation"
"github.com/grafana/grafana/pkg/services/secrets"
)
@ -22,6 +26,11 @@ var (
ErrNotFound = errors.New("not found") // TODO: convert to errutil
)
var (
ErrReceiverInUse = errutil.Conflict("alerting.notifications.receiver.used", errutil.WithPublicMessage("Receiver is used by one or many notification policies"))
ErrVersionConflict = errutil.Conflict("alerting.notifications.receiver.conflict")
)
// ReceiverService is the service for managing alertmanager receivers.
type ReceiverService struct {
ac accesscontrol.AccessControl
@ -30,6 +39,7 @@ type ReceiverService struct {
encryptionService secrets.Service
xact transactionManager
log log.Logger
validator validation.ProvenanceStatusTransitionValidator
}
type configStore interface {
@ -39,6 +49,7 @@ type configStore interface {
type provisoningStore interface {
GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error)
DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error
}
type transactionManager interface {
@ -60,6 +71,7 @@ func NewReceiverService(
encryptionService: encryptionService,
xact: xact,
log: log,
validator: validation.ValidateProvenanceRelaxed,
}
}
@ -119,7 +131,7 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver
return definitions.GettableApiReceiver{}, err
}
provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint")
provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return definitions.GettableApiReceiver{}, err
}
@ -158,7 +170,7 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
return nil, err
}
provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint")
provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return nil, err
}
@ -213,6 +225,83 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
return output, nil
}
// DeleteReceiver deletes a receiver by uid.
// UID field currently does not exist, we assume the uid is a particular hashed value of the receiver name.
func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID int64, callerProvenance definitions.Provenance, version string) error {
//TODO: Check delete permissions.
baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, orgID)
if err != nil {
return err
}
cfg := definitions.PostableUserConfig{}
err = json.Unmarshal([]byte(baseCfg.AlertmanagerConfiguration), &cfg)
if err != nil {
return err
}
idx, recv := getReceiverByUID(cfg, uid)
if recv == nil {
return ErrNotFound // TODO: nil?
}
// TODO: Implement + check optimistic concurrency.
storedProvenance, err := rs.getContactPointProvenance(ctx, recv, orgID)
if err != nil {
return err
}
if err := rs.validator(storedProvenance, models.Provenance(callerProvenance)); err != nil {
return err
}
if isReceiverInUse(recv.Name, []*definitions.Route{cfg.AlertmanagerConfig.Route}) {
return ErrReceiverInUse.Errorf("")
}
// Remove the receiver from the configuration.
cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers[:idx], cfg.AlertmanagerConfig.Receivers[idx+1:]...)
return rs.xact.InTransaction(ctx, func(ctx context.Context) error {
serialized, err := json.Marshal(cfg)
if err != nil {
return err
}
cmd := models.SaveAlertmanagerConfigurationCmd{
AlertmanagerConfiguration: string(serialized),
ConfigurationVersion: baseCfg.ConfigurationVersion,
FetchedConfigurationHash: baseCfg.ConfigurationHash,
Default: false,
OrgID: orgID,
}
err = rs.cfgStore.UpdateAlertmanagerConfiguration(ctx, &cmd)
if err != nil {
return err
}
// Remove provenance for all integrations in the receiver.
for _, integration := range recv.GrafanaManagedReceivers {
target := definitions.EmbeddedContactPoint{UID: integration.UID}
if err := rs.provisioningStore.DeleteProvenance(ctx, &target, orgID); err != nil {
return err
}
}
return nil
})
}
func (rs *ReceiverService) CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
// TODO: Stub
panic("not implemented")
}
func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
// TODO: Stub
panic("not implemented")
}
func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string {
return func(value string) string {
if !decrypt {
@ -232,3 +321,61 @@ func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, na
return string(decrypted)
}
}
// getContactPointProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
func (rs *ReceiverService) getContactPointProvenance(ctx context.Context, r *definitions.PostableApiReceiver, orgID int64) (models.Provenance, error) {
if len(r.GrafanaManagedReceivers) == 0 {
return models.ProvenanceNone, nil
}
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return "", err
}
// Current provisioning works on the integration level, so we need some way to determine the provenance of the
// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
// non-zero provenance we find.
for _, contactPoint := range r.GrafanaManagedReceivers {
if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone {
return p, nil
}
}
return models.ProvenanceNone, nil
}
// getReceiverByUID returns the index and receiver with the given UID.
func getReceiverByUID(cfg definitions.PostableUserConfig, uid string) (int, *definitions.PostableApiReceiver) {
for i, r := range cfg.AlertmanagerConfig.Receivers {
if getUID(r) == uid {
return i, r
}
}
return 0, nil
}
// getUID returns the UID of a PostableApiReceiver.
// Currently, the UID is a hash of the receiver name.
func getUID(t *definitions.PostableApiReceiver) string { // TODO replace to stable UID when we switch to normal storage
sum := fnv.New64()
_, _ = sum.Write([]byte(t.Name))
return fmt.Sprintf("%016x", sum.Sum64())
}
// TODO: Check if the contact point is used directly in an alert rule.
// isReceiverInUse checks if a receiver is used in a route or any of its sub-routes.
func isReceiverInUse(name string, routes []*definitions.Route) bool {
if len(routes) == 0 {
return false
}
for _, route := range routes {
if route.Receiver == name {
return true
}
if isReceiverInUse(name, route.Routes) {
return true
}
}
return false
}

View File

@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning/validation"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
@ -183,12 +184,13 @@ func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *Receive
provisioningStore := fakes.NewFakeProvisioningStore()
return &ReceiverService{
acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
provisioningStore,
store,
encryptSvc,
xact,
log.NewNopLogger(),
ac: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
provisioningStore: provisioningStore,
cfgStore: store,
encryptionService: encryptSvc,
xact: xact,
log: log.NewNopLogger(),
validator: validation.ValidateProvenanceRelaxed,
}
}

View File

@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning/validation"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/util"
@ -485,7 +486,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
if canUpdate := validation.CanUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
}
@ -502,7 +503,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
if canUpdate := validation.CanUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
updates = append(updates, models.UpdateRule{

View File

@ -5,7 +5,6 @@ import (
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
var ErrValidation = fmt.Errorf("invalid object specification")
@ -16,11 +15,6 @@ var (
ErrNoAlertmanagerConfiguration = errutil.Internal("alerting.notification.configMissing", errutil.WithPublicMessage("No alertmanager configuration present in this organization"))
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.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")
ErrTimeIntervalNotFound = errutil.NotFound("alerting.notifications.time-intervals.notFound")
@ -53,19 +47,3 @@ func MakeErrTimeIntervalInvalid(err error) error {
return ErrTimeIntervalInvalid.Build(data)
}
func MakeErrProvenanceChangeNotAllowed(from, to models.Provenance) error {
if to == "" {
to = "none"
}
if from == "" {
from = "none"
}
data := errutil.TemplateData{
Public: map[string]interface{}{
"TargetProvenance": to,
"SourceProvenance": from,
},
}
return ErrProvenanceChangeNotAllowed.Build(data)
}

View File

@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning/validation"
)
type MuteTimingService struct {
@ -20,7 +21,7 @@ type MuteTimingService struct {
provenanceStore ProvisioningStore
xact TransactionManager
log log.Logger
validator ProvenanceStatusTransitionValidator
validator validation.ProvenanceStatusTransitionValidator
}
func NewMuteTimingService(config AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *MuteTimingService {
@ -29,7 +30,7 @@ func NewMuteTimingService(config AMConfigStore, prov ProvisioningStore, xact Tra
provenanceStore: prov,
xact: xact,
log: log,
validator: ValidateProvenanceRelaxed,
validator: validation.ValidateProvenanceRelaxed,
}
}

View File

@ -0,0 +1,29 @@
package validation
import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
var (
ErrProvenanceChangeNotAllowed = errutil.Forbidden("alerting.notifications.invalidProvenance").MustTemplate(
"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"),
)
)
func MakeErrProvenanceChangeNotAllowed(from, to models.Provenance) error {
if to == "" {
to = "none"
}
if from == "" {
from = "none"
}
data := errutil.TemplateData{
Public: map[string]interface{}{
"TargetProvenance": to,
"SourceProvenance": from,
},
}
return ErrProvenanceChangeNotAllowed.Build(data)
}

View File

@ -1,12 +1,12 @@
package provisioning
package validation
import (
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
// canUpdateProvenanceInRuleGroup checks if a provenance can be updated for a rule group and its alerts.
// CanUpdateProvenanceInRuleGroup checks if a provenance can be updated for a rule group and its alerts.
// ReplaceRuleGroup function intends to replace an entire rule group: inserting, updating, and removing rules.
func canUpdateProvenanceInRuleGroup(storedProvenance, provenance models.Provenance) bool {
func CanUpdateProvenanceInRuleGroup(storedProvenance, provenance models.Provenance) bool {
return storedProvenance == provenance ||
storedProvenance == models.ProvenanceNone ||
(storedProvenance == models.ProvenanceAPI && provenance == models.ProvenanceNone)

View File

@ -1,4 +1,4 @@
package provisioning
package validation
import (
"fmt"