diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 11bf73e9b..da00ec976 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -306,7 +306,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 eae923960..b6cb1a44b 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" @@ -28,19 +29,21 @@ 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 { Message string `json:"message"` + Alias string `json:"alias,omitempty"` Description string `json:"description"` 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) @@ -51,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 } @@ -67,8 +71,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(s.ProviderUID, event) + payload := OpsgenieAlert{ Message: event.InvolvedObject.Kind + "/" + event.InvolvedObject.Name, + Alias: alias, Description: event.Message, Details: details, } @@ -91,3 +102,24 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error { return nil } + +// generateOpsgenieAlias creates a stable, deterministic alias string from +// 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, + event.Reason, + ) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) + return hash[:64] +} diff --git a/internal/notifier/opsgenie_fuzz_test.go b/internal/notifier/opsgenie_fuzz_test.go index 1f26dcae2..367cfff58 100644 --- a/internal/notifier/opsgenie_fuzz_test.go +++ b/internal/notifier/opsgenie_fuzz_test.go @@ -45,7 +45,7 @@ func Fuzz_OpsGenie(f *testing.F) { var tlsConfig tls.Config _ = fuzz.NewConsumer(seed).GenerateStruct(&tlsConfig) - opsgenie, err := NewOpsgenie(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), "", &tlsConfig, token) + opsgenie, err := NewOpsgenie(fmt.Sprintf("%s/%s", ts.URL, urlSuffix), "", &tlsConfig, token, "") if err != nil { return } diff --git a/internal/notifier/opsgenie_test.go b/internal/notifier/opsgenie_test.go index 5772db7e7..be3f9bd3a 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" @@ -61,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()) @@ -69,3 +71,108 @@ 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() + + providerUID := "test-provider-uid-123" + + tests := []struct { + name string + event func() v1beta1.Event + expectedAlias string + }{ + { + name: "alias includes provider UID for cluster uniqueness", + event: testEvent, + expectedAlias: fmt.Sprintf("%x", + sha256.Sum256([]byte("test-provider-uid-123/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("test-provider-uid-123/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("test-provider-uid-123/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("test-provider-uid-123/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("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", providerUID) + 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() + providerUID := "test-uid" + + // Alias should be deterministic + alias1 := generateOpsgenieAlias(providerUID, event) + alias2 := generateOpsgenieAlias(providerUID, 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(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)) +}