Alerting: Annotation CanUse for receiver resource (#110839)

* add origin to receiver
* populate origin of the receiver
* set CanUse to false if origin is not Grafana
* set provenance if origin is imported
* set Grafana origin by default in conversion API
* set canUse annotation
* reject update\delete operations on resources with origin other than Grafana
* fail to create with wrong origin
This commit is contained in:
Yuri Tseretyan
2025-09-16 09:32:04 -04:00
committed by GitHub
parent a15b6ec2a2
commit 356521c9b9
11 changed files with 78 additions and 21 deletions
@@ -3,3 +3,4 @@ package v0alpha1
const InternalPrefix = "grafana.com/"
const ProvenanceStatusAnnotationKey = InternalPrefix + "provenance"
const ProvenanceStatusNone = "none"
const CanUseAnnotationKey = InternalPrefix + "canUse"
@@ -35,6 +35,13 @@ func (o *Receiver) SetAccessControl(action string) {
o.Annotations[AccessControlAnnotation(action)] = "true"
}
func (o *Receiver) SetCanUse(canUse bool) {
if o.Annotations == nil {
o.Annotations = make(map[string]string, 1)
}
o.Annotations[CanUseAnnotationKey] = fmt.Sprintf("%v", canUse)
}
// AccessControlAnnotation returns the key for the access control annotation for the given action.
// Ex. grafana.com/access/canDelete.
func AccessControlAnnotation(action string) string {
@@ -101,6 +101,7 @@ func convertToK8sResource(
rules = append(rules, rule.UID)
}
r.SetInUse(metadata.InUseByRoutes, rules)
r.SetCanUse(metadata.CanUse)
}
r.UID = gapiutil.CalculateClusterWideUID(r)
return r, nil
@@ -120,6 +121,7 @@ func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[str
Integrations: make([]*ngmodels.Integration, 0, len(receiver.Spec.Integrations)),
Version: receiver.ResourceVersion,
Provenance: ngmodels.ProvenanceNone,
Origin: ngmodels.ResourceOriginGrafana, // Set to Grafana by default.
}
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
for _, integration := range receiver.Spec.Integrations {
+14
View File
@@ -37,8 +37,20 @@ type GetReceiversQuery struct {
type ReceiverMetadata struct {
InUseByRules []AlertRuleKey
InUseByRoutes int
// CanUse is true if the receiver can be used in routes and rules.
CanUse bool
}
// ResourceOrigin represents the origin or source of the resource.
type ResourceOrigin string
const (
// ResourceOriginGrafana indicates that the resource is in the Grafana configuration
ResourceOriginGrafana ResourceOrigin = "grafana"
// ResourceOriginImported indicates that the resource is from the imported configuration
ResourceOriginImported ResourceOrigin = "imported"
)
// Receiver is the domain model representation of a receiver / contact point.
type Receiver struct {
UID string
@@ -46,6 +58,7 @@ type Receiver struct {
Integrations []*Integration
Provenance Provenance
Version string
Origin ResourceOrigin
}
func (r *Receiver) Clone() Receiver {
@@ -54,6 +67,7 @@ func (r *Receiver) Clone() Receiver {
Name: r.Name,
Provenance: r.Provenance,
Version: r.Version,
Origin: r.Origin,
}
if r.Integrations != nil {
@@ -378,6 +378,7 @@ func TestReceiver_Fingerprint(t *testing.T) {
fingerprint := baseReceiver.Fingerprint()
excludedFields := map[string]struct{}{
"Version": {},
"Origin": {},
}
reflectVal := reflect.ValueOf(&completelyDifferentReceiver).Elem()
+7
View File
@@ -1180,6 +1180,7 @@ func ReceiverGen(mutators ...Mutator[Receiver]) func() Receiver {
Name: name,
Integrations: []*Integration{&integration},
Provenance: ProvenanceNone,
Origin: ResourceOriginGrafana,
}
for _, mutator := range mutators {
mutator(&c)
@@ -1246,6 +1247,12 @@ func (n ReceiverMutators) Decrypted(fn DecryptFn) Mutator[Receiver] {
}
}
func (n ReceiverMutators) WithOrigin(origin ResourceOrigin) Mutator[Receiver] {
return func(r *Receiver) {
r.Origin = origin
}
}
// Integrations
// CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it.
@@ -65,19 +65,7 @@ func ReceiverToPostableApiReceiver(r *models.Receiver) (*apimodels.PostableApiRe
}, nil
}
func PostableApiReceiversToReceivers(postables []*apimodels.PostableApiReceiver, storedProvenances map[string]models.Provenance) ([]*models.Receiver, error) {
receivers := make([]*models.Receiver, 0, len(postables))
for _, postable := range postables {
r, err := PostableApiReceiverToReceiver(postable, GetReceiverProvenance(storedProvenances, postable))
if err != nil {
return nil, err
}
receivers = append(receivers, r)
}
return receivers, nil
}
func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, provenance models.Provenance) (*models.Receiver, error) {
func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, provenance models.Provenance, origin models.ResourceOrigin) (*models.Receiver, error) {
integrations, err := PostableGrafanaReceiversToIntegrations(postable.GrafanaManagedReceivers)
if err != nil {
return nil, err
@@ -87,13 +75,18 @@ func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, prov
Name: postable.GetName(),
Integrations: integrations,
Provenance: provenance,
Origin: origin,
}
r.Version = r.Fingerprint()
return r, nil
}
// GetReceiverProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
func GetReceiverProvenance(storedProvenances map[string]models.Provenance, r *apimodels.PostableApiReceiver) models.Provenance {
func GetReceiverProvenance(storedProvenances map[string]models.Provenance, r *apimodels.PostableApiReceiver, origin models.ResourceOrigin) models.Provenance {
if origin == models.ResourceOriginImported {
return models.ProvenanceConvertedPrometheus
}
if len(r.GrafanaManagedReceivers) == 0 || len(storedProvenances) == 0 {
return models.ProvenanceNone
}
@@ -43,7 +43,7 @@ func (rev *ConfigRevision) CreateReceiver(receiver *models.Receiver) (*models.Re
return nil, err
}
return PostableApiReceiverToReceiver(postable, receiver.Provenance)
return PostableApiReceiverToReceiver(postable, receiver.Provenance, receiver.Origin)
}
func (rev *ConfigRevision) UpdateReceiver(receiver *models.Receiver) (*models.Receiver, error) {
@@ -69,7 +69,7 @@ func (rev *ConfigRevision) UpdateReceiver(receiver *models.Receiver) (*models.Re
return nil, err
}
return PostableApiReceiverToReceiver(newReceiver, receiver.Provenance)
return PostableApiReceiverToReceiver(newReceiver, receiver.Provenance, receiver.Origin)
}
// ReceiverNameUsedByRoutes checks if a receiver name is used in any routes.
@@ -89,7 +89,7 @@ func (rev *ConfigRevision) GetReceiver(uid string, prov Provenances) (*models.Re
if NameToUid(r.GetName()) != uid {
continue
}
recv, err := PostableApiReceiverToReceiver(r, GetReceiverProvenance(prov, r))
recv, err := PostableApiReceiverToReceiver(r, GetReceiverProvenance(prov, r, models.ResourceOriginGrafana), models.ResourceOriginGrafana)
if err != nil {
return nil, fmt.Errorf("failed to convert receiver %q: %w", r.Name, err)
}
@@ -109,7 +109,7 @@ func (rev *ConfigRevision) GetReceivers(uids []string, prov Provenances) ([]*mod
if len(uids) > 0 && !slices.Contains(uids, uid) {
continue
}
recv, err := PostableApiReceiverToReceiver(r, GetReceiverProvenance(prov, r))
recv, err := PostableApiReceiverToReceiver(r, GetReceiverProvenance(prov, r, models.ResourceOriginGrafana), models.ResourceOriginGrafana)
if err != nil {
return nil, fmt.Errorf("failed to convert receiver %q: %w", r.Name, err)
}
@@ -131,7 +131,7 @@ func DecryptedReceivers(receivers []*definitions.PostableApiReceiver, decryptFn
decrypted := make([]*definitions.PostableApiReceiver, len(receivers))
for i, r := range receivers {
// We don't care about the provenance here, so we pass ProvenanceNone.
rcv, err := PostableApiReceiverToReceiver(r, models.ProvenanceNone)
rcv, err := PostableApiReceiverToReceiver(r, models.ProvenanceNone, models.ResourceOriginGrafana)
if err != nil {
return nil, err
}
@@ -154,7 +154,7 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn
encrypted := make([]*definitions.PostableApiReceiver, len(receivers))
for i, r := range receivers {
// We don't care about the provenance here, so we pass ProvenanceNone.
rcv, err := PostableApiReceiverToReceiver(r, models.ProvenanceNone)
rcv, err := PostableApiReceiverToReceiver(r, models.ProvenanceNone, models.ResourceOriginGrafana)
if err != nil {
return nil, err
}
+21 -1
View File
@@ -36,6 +36,11 @@ var (
"Receiver cannot be renamed because it is used by provisioned {{ if .Public.UsedByRules }}alert rules{{ end }}{{ if .Public.UsedByRoutes }}{{ if .Public.UsedByRules }} and {{ end }}notification policies{{ end }}",
errutil.WithPublic(`Receiver cannot be renamed because it is used by provisioned {{ if .Public.UsedByRules }}alert rules{{ end }}{{ if .Public.UsedByRoutes }}{{ if .Public.UsedByRules }} and {{ end }}notification policies{{ end }}. You must update those resources first using the original provision method.`),
)
ErrReceiverOrigin = errutil.BadRequest("alerting.notifications.receivers.originInvalid").MustTemplate(
"Receiver '{{ .Public.Name }} cannot be {{ .Public.Action }}d because it belongs to an imported configuration.",
errutil.WithPublic("Receiver '{{ .Public.Name }} cannot be {{ .Public.Action }}d because it belongs to an imported configuration. Finish the import of the configuration first."),
)
)
// ReceiverService is the service for managing alertmanager receivers.
@@ -271,6 +276,10 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, calle
return err
}
if existing.Origin != models.ResourceOriginGrafana {
return makeErrReceiverOrigin(existing, "delete")
}
logger := rs.log.FromContext(ctx).New("receiver", existing.Name, "uid", uid, "version", version, "integrations", existing.GetIntegrationTypes())
// Check optimistic concurrency.
@@ -329,7 +338,9 @@ func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receive
if err := rs.authz.AuthorizeCreate(ctx, user); err != nil {
return nil, err
}
if r.Origin != models.ResourceOriginGrafana {
return nil, makeErrReceiverOrigin(r, "create")
}
revision, err := rs.cfgStore.Get(ctx, orgID)
if err != nil {
return nil, err
@@ -429,6 +440,10 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
return nil, err
}
if existing.Origin != models.ResourceOriginGrafana {
return nil, makeErrReceiverOrigin(existing, "update")
}
if err := rs.provenanceValidator(existing.Provenance, r.Provenance); err != nil {
return nil, err
}
@@ -538,6 +553,7 @@ func (rs *ReceiverService) InUseMetadata(ctx context.Context, orgID int64, recei
results[rcv.GetUID()] = models.ReceiverMetadata{
InUseByRoutes: receiverUses[rcv.Name],
InUseByRules: byReceiver[rcv.Name],
CanUse: rcv.Origin == models.ResourceOriginGrafana, // Only receivers from the Grafana configuration can be used.
}
}
@@ -684,6 +700,10 @@ func makeErrReceiverDependentResourcesProvenance(usedByRoutes bool, rules []mode
})
}
func makeErrReceiverOrigin(r *models.Receiver, action string) error {
return ErrReceiverOrigin.Build(errutil.TemplateData{Public: map[string]interface{}{"Action": action, "Name": r.Name}})
}
func (rs *ReceiverService) RenameReceiverInDependentResources(ctx context.Context, orgID int64, revision *legacy_storage.ConfigRevision, oldName, newName string, receiverProvenance models.Provenance) error {
ctx, span := rs.tracer.Start(ctx, "alerting.receivers.rename-dependent-resources", trace.WithAttributes(
attribute.String("oldName", oldName),
@@ -404,6 +404,12 @@ func TestReceiverService_Create(t *testing.T) {
), models.ReceiverMuts.Encrypted(models.Base64Enrypt)),
expectedProvenances: map[string]models.Provenance{generated(0): models.ProvenanceNone, generated(1): models.ProvenanceNone}, // Mark UIDs as generated so that test will insert generated UID.
},
{
name: "create receiver with non-Grafana origin fails",
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithOrigin(models.ResourceOriginImported)),
expectedErr: ErrReceiverOrigin,
},
{
name: "create integration with invalid UID fails",
user: writer,
@@ -1479,18 +1485,22 @@ func TestReceiverService_InUseMetadata(t *testing.T) {
legacy_storage.NameToUid("receiver1"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule1uid"}},
InUseByRoutes: 2,
CanUse: true,
},
legacy_storage.NameToUid("receiver2"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule1uid"}, {OrgID: 1, UID: "rule2uid"}},
InUseByRoutes: 1,
CanUse: true,
},
legacy_storage.NameToUid("receiver3"): {
InUseByRules: []models.AlertRuleKey{{OrgID: 1, UID: "rule2uid"}},
InUseByRoutes: 2,
CanUse: true,
},
legacy_storage.NameToUid("receiver4"): {
InUseByRules: []models.AlertRuleKey{},
InUseByRoutes: 1,
CanUse: true,
},
},
},
@@ -608,6 +608,7 @@ func TestIntegrationAccessControl(t *testing.T) {
// Set expected metadata.
expectedWithMetadata := expected.Copy().(*v0alpha1.Receiver)
expectedWithMetadata.SetInUse(0, nil)
expectedWithMetadata.SetCanUse(true)
if tc.canUpdate {
expectedWithMetadata.SetAccessControl("canWrite")
}
@@ -1294,6 +1295,7 @@ func TestIntegrationCRUD(t *testing.T) {
receiver.SetAccessControl("canReadSecrets")
receiver.SetAccessControl("canAdmin")
receiver.SetInUse(0, nil)
receiver.SetCanUse(true)
// Use export endpoint because it's the only way to get decrypted secrets fast.
cliCfg := helper.Org1.Admin.NewRestConfig()