From e19e6f6fe319bf0d8a9a1e2885960313279e92de Mon Sep 17 00:00:00 2001 From: Cairon Date: Fri, 10 Apr 2026 15:20:44 +0000 Subject: [PATCH 1/2] Add Opsgenie alias field for alert deduplication Set the alias field on Opsgenie alerts using a SHA-256 hash of the involved object's Kind, Namespace, Name, and event Reason. This allows Opsgenie to deduplicate repeated alerts for the same source instead of creating new pages for each firing. Fixes fluxcd/notification-controller#460 Signed-off-by: Cairon --- internal/notifier/opsgenie.go | 26 ++++++++ internal/notifier/opsgenie_test.go | 100 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/internal/notifier/opsgenie.go b/internal/notifier/opsgenie.go index eae923960..9002d4efe 100644 --- a/internal/notifier/opsgenie.go +++ b/internal/notifier/opsgenie.go @@ -18,6 +18,7 @@ package notifier import ( "context" + "crypto/sha256" "crypto/tls" "errors" "fmt" @@ -36,6 +37,7 @@ type Opsgenie struct { type OpsgenieAlert struct { Message string `json:"message"` + Alias string `json:"alias,omitempty"` Description string `json:"description"` Details map[string]string `json:"details"` } @@ -67,8 +69,15 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error { } details["severity"] = event.Severity + // Construct a stable alias for deduplication in Opsgenie. + // The alias is derived from the involved object's kind, namespace, + // name, and the event reason so that repeated alerts for the same + // source are deduplicated while different reasons create separate alerts. + alias := generateOpsgenieAlias(event) + payload := OpsgenieAlert{ Message: event.InvolvedObject.Kind + "/" + event.InvolvedObject.Name, + Alias: alias, Description: event.Message, Details: details, } @@ -91,3 +100,20 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error { return nil } + +// generateOpsgenieAlias creates a stable, deterministic alias string from +// the event's involved object and reason. Opsgenie uses the alias field to +// deduplicate alerts — alerts with the same alias are treated as the same +// incident instead of creating new pages. The alias is a SHA-256 hash +// (truncated to 64 chars) to stay within Opsgenie's 512-char alias limit +// while remaining collision-resistant. +func generateOpsgenieAlias(event eventv1.Event) string { + key := fmt.Sprintf("%s/%s/%s/%s", + event.InvolvedObject.Kind, + event.InvolvedObject.Namespace, + event.InvolvedObject.Name, + event.Reason, + ) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) + return hash[:64] +} diff --git a/internal/notifier/opsgenie_test.go b/internal/notifier/opsgenie_test.go index 5772db7e7..6215c8430 100644 --- a/internal/notifier/opsgenie_test.go +++ b/internal/notifier/opsgenie_test.go @@ -18,7 +18,9 @@ package notifier import ( "context" + "crypto/sha256" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -69,3 +71,101 @@ func TestOpsgenie_Post(t *testing.T) { }) } } + +func TestOpsgenie_PostAlias(t *testing.T) { + var receivedPayload OpsgenieAlert + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + json.Unmarshal(b, &receivedPayload) + })) + defer ts.Close() + + tests := []struct { + name string + event func() v1beta1.Event + expectedAlias string + }{ + { + name: "alias is set from involved object and reason", + event: testEvent, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + }, + { + name: "alias is stable for same event", + event: func() v1beta1.Event { + e := testEvent() + e.Message = "different message should not change alias" + return e + }, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + }, + { + name: "alias differs for different reason", + event: func() v1beta1.Event { + e := testEvent() + e.Reason = "HealthCheckFailed" + return e + }, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("GitRepository/gitops-system/webapp/HealthCheckFailed")))[:64], + }, + { + name: "alias differs for different namespace", + event: func() v1beta1.Event { + e := testEvent() + e.InvolvedObject.Namespace = "production" + return e + }, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("GitRepository/production/webapp/reason")))[:64], + }, + { + name: "alias with empty metadata", + event: func() v1beta1.Event { + e := testEvent() + e.Metadata = nil + return e + }, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token") + g.Expect(err).ToNot(HaveOccurred()) + + err = opsgenie.Post(context.TODO(), tt.event()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(receivedPayload.Alias).To(Equal(tt.expectedAlias)) + g.Expect(receivedPayload.Alias).ToNot(BeEmpty()) + }) + } +} + +func TestGenerateOpsgenieAlias(t *testing.T) { + g := NewWithT(t) + event := testEvent() + + // Alias should be deterministic + alias1 := generateOpsgenieAlias(event) + alias2 := generateOpsgenieAlias(event) + g.Expect(alias1).To(Equal(alias2)) + + // Alias should be 64 chars (hex-encoded SHA-256 truncated) + g.Expect(alias1).To(HaveLen(64)) + + // Different reason should produce different alias + event2 := testEvent() + event2.Reason = "DifferentReason" + alias3 := generateOpsgenieAlias(event2) + g.Expect(alias1).ToNot(Equal(alias3)) +} From f322c46bae1f204b35b49fbf0f9d1c32648f8dcb Mon Sep 17 00:00:00 2001 From: Cairon Date: Thu, 23 Apr 2026 13:52:46 +0000 Subject: [PATCH 2/2] opsgenie: include provider UID in alias for multi-cluster uniqueness Without the provider UID in the alias hash, alerts from different clusters sharing the same involved object (e.g. Kustomization/flux-system/flux-system) would produce identical aliases and Opsgenie would aggregate them into a single incident. Thread the ProviderUID from notifierOptions into the Opsgenie struct and prepend it to the alias hash input so each cluster's Provider resource produces a unique alias. Signed-off-by: cairon-ab Signed-off-by: Cairon --- internal/notifier/factory.go | 2 +- internal/notifier/opsgenie.go | 40 +++++++++++++++++------------- internal/notifier/opsgenie_test.go | 29 ++++++++++++++-------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 6b2dfabc0..0966f2ddc 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -304,7 +304,7 @@ func matrixNotifierFunc(opts notifierOptions) (Interface, error) { } func opsgenieNotifierFunc(opts notifierOptions) (Interface, error) { - return NewOpsgenie(opts.URL, opts.ProxyURL, opts.TLSConfig, opts.Token) + return NewOpsgenie(opts.URL, opts.ProxyURL, opts.TLSConfig, opts.Token, opts.ProviderUID) } func alertmanagerNotifierFunc(opts notifierOptions) (Interface, error) { diff --git a/internal/notifier/opsgenie.go b/internal/notifier/opsgenie.go index 9002d4efe..b6cb1a44b 100644 --- a/internal/notifier/opsgenie.go +++ b/internal/notifier/opsgenie.go @@ -29,10 +29,11 @@ import ( ) type Opsgenie struct { - URL string - ProxyURL string - TLSConfig *tls.Config - ApiKey string + URL string + ProxyURL string + TLSConfig *tls.Config + ApiKey string + ProviderUID string } type OpsgenieAlert struct { @@ -42,7 +43,7 @@ type OpsgenieAlert struct { Details map[string]string `json:"details"` } -func NewOpsgenie(hookURL string, proxyURL string, tlsConfig *tls.Config, token string) (*Opsgenie, error) { +func NewOpsgenie(hookURL string, proxyURL string, tlsConfig *tls.Config, token string, providerUID string) (*Opsgenie, error) { _, err := url.ParseRequestURI(hookURL) if err != nil { return nil, fmt.Errorf("invalid Opsgenie hook URL %s: '%w'", hookURL, err) @@ -53,10 +54,11 @@ func NewOpsgenie(hookURL string, proxyURL string, tlsConfig *tls.Config, token s } return &Opsgenie{ - URL: hookURL, - ProxyURL: proxyURL, - ApiKey: token, - TLSConfig: tlsConfig, + URL: hookURL, + ProxyURL: proxyURL, + ApiKey: token, + TLSConfig: tlsConfig, + ProviderUID: providerUID, }, nil } @@ -73,7 +75,7 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error { // The alias is derived from the involved object's kind, namespace, // name, and the event reason so that repeated alerts for the same // source are deduplicated while different reasons create separate alerts. - alias := generateOpsgenieAlias(event) + alias := generateOpsgenieAlias(s.ProviderUID, event) payload := OpsgenieAlert{ Message: event.InvolvedObject.Kind + "/" + event.InvolvedObject.Name, @@ -102,13 +104,17 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error { } // generateOpsgenieAlias creates a stable, deterministic alias string from -// the event's involved object and reason. Opsgenie uses the alias field to -// deduplicate alerts — alerts with the same alias are treated as the same -// incident instead of creating new pages. The alias is a SHA-256 hash -// (truncated to 64 chars) to stay within Opsgenie's 512-char alias limit -// while remaining collision-resistant. -func generateOpsgenieAlias(event eventv1.Event) string { - key := fmt.Sprintf("%s/%s/%s/%s", +// the provider UID and the event's involved object and reason. Opsgenie uses +// the alias field to deduplicate alerts — alerts with the same alias are +// treated as the same incident instead of creating new pages. The provider UID +// is included so that alerts from different clusters (each with their own +// Provider resource) produce distinct aliases even when the involved objects +// share the same kind/namespace/name. The alias is a SHA-256 hash (truncated +// to 64 chars) to stay within Opsgenie's 512-char alias limit while remaining +// collision-resistant. +func generateOpsgenieAlias(providerUID string, event eventv1.Event) string { + key := fmt.Sprintf("%s/%s/%s/%s/%s", + providerUID, event.InvolvedObject.Kind, event.InvolvedObject.Namespace, event.InvolvedObject.Name, diff --git a/internal/notifier/opsgenie_test.go b/internal/notifier/opsgenie_test.go index 6215c8430..be3f9bd3a 100644 --- a/internal/notifier/opsgenie_test.go +++ b/internal/notifier/opsgenie_test.go @@ -63,7 +63,7 @@ func TestOpsgenie_Post(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) - opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token") + opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token", "") g.Expect(err).ToNot(HaveOccurred()) err = opsgenie.Post(context.TODO(), tt.event()) @@ -84,16 +84,18 @@ func TestOpsgenie_PostAlias(t *testing.T) { })) defer ts.Close() + providerUID := "test-provider-uid-123" + tests := []struct { name string event func() v1beta1.Event expectedAlias string }{ { - name: "alias is set from involved object and reason", + name: "alias includes provider UID for cluster uniqueness", event: testEvent, expectedAlias: fmt.Sprintf("%x", - sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + sha256.Sum256([]byte("test-provider-uid-123/GitRepository/gitops-system/webapp/reason")))[:64], }, { name: "alias is stable for same event", @@ -103,7 +105,7 @@ func TestOpsgenie_PostAlias(t *testing.T) { return e }, expectedAlias: fmt.Sprintf("%x", - sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + sha256.Sum256([]byte("test-provider-uid-123/GitRepository/gitops-system/webapp/reason")))[:64], }, { name: "alias differs for different reason", @@ -113,7 +115,7 @@ func TestOpsgenie_PostAlias(t *testing.T) { return e }, expectedAlias: fmt.Sprintf("%x", - sha256.Sum256([]byte("GitRepository/gitops-system/webapp/HealthCheckFailed")))[:64], + sha256.Sum256([]byte("test-provider-uid-123/GitRepository/gitops-system/webapp/HealthCheckFailed")))[:64], }, { name: "alias differs for different namespace", @@ -123,7 +125,7 @@ func TestOpsgenie_PostAlias(t *testing.T) { return e }, expectedAlias: fmt.Sprintf("%x", - sha256.Sum256([]byte("GitRepository/production/webapp/reason")))[:64], + sha256.Sum256([]byte("test-provider-uid-123/GitRepository/production/webapp/reason")))[:64], }, { name: "alias with empty metadata", @@ -133,14 +135,14 @@ func TestOpsgenie_PostAlias(t *testing.T) { return e }, expectedAlias: fmt.Sprintf("%x", - sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64], + sha256.Sum256([]byte("test-provider-uid-123/GitRepository/gitops-system/webapp/reason")))[:64], }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) - opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token") + opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token", providerUID) g.Expect(err).ToNot(HaveOccurred()) err = opsgenie.Post(context.TODO(), tt.event()) @@ -154,10 +156,11 @@ func TestOpsgenie_PostAlias(t *testing.T) { func TestGenerateOpsgenieAlias(t *testing.T) { g := NewWithT(t) event := testEvent() + providerUID := "test-uid" // Alias should be deterministic - alias1 := generateOpsgenieAlias(event) - alias2 := generateOpsgenieAlias(event) + alias1 := generateOpsgenieAlias(providerUID, event) + alias2 := generateOpsgenieAlias(providerUID, event) g.Expect(alias1).To(Equal(alias2)) // Alias should be 64 chars (hex-encoded SHA-256 truncated) @@ -166,6 +169,10 @@ func TestGenerateOpsgenieAlias(t *testing.T) { // Different reason should produce different alias event2 := testEvent() event2.Reason = "DifferentReason" - alias3 := generateOpsgenieAlias(event2) + alias3 := generateOpsgenieAlias(providerUID, event2) g.Expect(alias1).ToNot(Equal(alias3)) + + // Different provider UID should produce different alias + alias4 := generateOpsgenieAlias("different-uid", event) + g.Expect(alias1).ToNot(Equal(alias4)) }