From eb6e4ec603bb7249a09573069f3e0bd8fb1e4d07 Mon Sep 17 00:00:00 2001 From: Alexandre Lamarre Date: Thu, 24 Nov 2022 11:25:07 -0500 Subject: [PATCH 1/2] pager duty equality + simple unit tests --- pkg/alerting/routing/data.go | 55 +++- pkg/alerting/routing/equality_test.go | 399 ++++++++++++++++++++++++++ 2 files changed, 442 insertions(+), 12 deletions(-) create mode 100644 pkg/alerting/routing/equality_test.go diff --git a/pkg/alerting/routing/data.go b/pkg/alerting/routing/data.go index f71ecd5799..8ba9fafebc 100644 --- a/pkg/alerting/routing/data.go +++ b/pkg/alerting/routing/data.go @@ -79,45 +79,71 @@ func slackConfigsAreEqual(s1, s2 *SlackConfig) (equal bool, reason string) { } func emailConfigsAreEqual(e1, e2 *EmailConfig) (equal bool, reason string) { - if e1.To == e2.To { + if e1.To != e2.To { return false, fmt.Sprintf("to mismatch %s <-> %s", e1.To, e2.To) } - if e1.From == e2.From { + if e1.From != e2.From { return false, fmt.Sprintf("from mismatch %s <-> %s ", e1.From, e2.From) } - if e1.Smarthost == e2.Smarthost { + if e1.Smarthost != e2.Smarthost { return false, fmt.Sprintf("smarthost mismatch %s <-> %s ", e1.Smarthost, e2.Smarthost) } - if e1.AuthUsername == e2.AuthUsername { + if e1.AuthUsername != e2.AuthUsername { return false, fmt.Sprintf("auth username mismatch %s <-> %s ", e1.AuthUsername, e2.AuthUsername) } - if e1.AuthPassword == e2.AuthPassword { + if e1.AuthPassword != e2.AuthPassword { return false, fmt.Sprintf("auth password mismatch %s <-> %s ", e1.AuthPassword, e2.AuthPassword) } - if e1.AuthSecret == e2.AuthSecret { + if e1.AuthSecret != e2.AuthSecret { return false, fmt.Sprintf("auth secret mismatch %s <-> %s ", e1.AuthSecret, e2.AuthSecret) } - if e1.RequireTLS == e2.RequireTLS { + if e1.RequireTLS != e2.RequireTLS { return false, fmt.Sprintf("require tls mismatch %v <-> %v ", e1.RequireTLS, e2.RequireTLS) } - if e1.HTML == e2.HTML { + if e1.HTML != e2.HTML { return false, fmt.Sprintf("html mismatch %s <-> %s ", e1.HTML, e2.HTML) } - if e1.Text == e2.Text { + if e1.Text != e2.Text { return false, fmt.Sprintf("text mismatch %s <-> %s ", e1.Text, e2.Text) } return true, "" } +func pagerDutyConfigsAreEqual(p1, p2 *PagerdutyConfig) (equal bool, reason string) { + if p1.RoutingKey != p2.RoutingKey { + return false, fmt.Sprintf("routing key mismatch %s <-> %s ", p1.RoutingKey, p2.RoutingKey) + } + if p1.ServiceKey != p2.ServiceKey { + return false, fmt.Sprintf("service key mismatch %s <-> %s ", p1.ServiceKey, p2.ServiceKey) + } + if p1.URL != p2.URL { + return false, fmt.Sprintf("url mismatch %s <-> %s ", p1.URL, p2.URL) + } + if p1.Client != p2.Client { + return false, fmt.Sprintf("client mismatch %s <-> %s ", p1.Client, p2.Client) + } + if p1.ClientURL != p2.ClientURL { + return false, fmt.Sprintf("client url mismatch %s <-> %s ", p1.ClientURL, p2.ClientURL) + } + if p1.Description != p2.Description { + return false, fmt.Sprintf("description mismatch %s <-> %s ", p1.Description, p2.Description) + } + return true, "" +} + func receiversAreEqual(r1 *Receiver, r2 *Receiver) (equal bool, reason string) { - if r1.Name != r2.Name { + if r1.Name != r2.Name { // opni specific indexing return false, fmt.Sprintf("receiver name mismatch %s <-> %s ", r1.Name, r2.Name) } if len(r1.EmailConfigs) != len(r2.EmailConfigs) { - return false, fmt.Sprintf("email config length mismatch %d <-> %d ", len(r1.EmailConfigs), len(r2.EmailConfigs)) + return false, fmt.Sprintf("email configs are not yet synced: found num old %d <-> num new %d ", len(r1.EmailConfigs), len(r2.EmailConfigs)) } + if len(r1.SlackConfigs) != len(r2.SlackConfigs) { - return false, "slack config length mismatch" + return false, fmt.Sprintf("slack configs are not yet synced: found num old %d <-> num new %d ", len(r1.SlackConfigs), len(r2.SlackConfigs)) + } + if len(r1.PagerdutyConfigs) != len(r2.PagerdutyConfigs) { + return false, fmt.Sprintf("pager duty configs are not yet synced: found num old %d <-> num new %d ", len(r1.PagerdutyConfigs), len(r2.PagerdutyConfigs)) } for idx, emailConfig := range r1.EmailConfigs { if equal, reason := emailConfigsAreEqual(emailConfig, r2.EmailConfigs[idx]); !equal { @@ -129,6 +155,11 @@ func receiversAreEqual(r1 *Receiver, r2 *Receiver) (equal bool, reason string) { return false, fmt.Sprintf("slack config mismatch %s", reason) } } + for idx, pagerDutyConfig := range r1.PagerdutyConfigs { + if equal, reason := pagerDutyConfigsAreEqual(pagerDutyConfig, r2.PagerdutyConfigs[idx]); !equal { + return false, fmt.Sprintf("pager duty config mismatch %s", reason) + } + } return true, "" } diff --git a/pkg/alerting/routing/equality_test.go b/pkg/alerting/routing/equality_test.go new file mode 100644 index 0000000000..cf2ee2a1fd --- /dev/null +++ b/pkg/alerting/routing/equality_test.go @@ -0,0 +1,399 @@ +package routing_test + +import ( + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/rancher/opni/pkg/alerting/routing" + alertingv1 "github.com/rancher/opni/pkg/apis/alerting/v1" + "github.com/rancher/opni/pkg/test" + "github.com/samber/lo" +) + +const innerHookServer = "http://localhost:3000" + +func Create2RoutingTrees() (*routing.RoutingTree, *routing.RoutingTree, + *routing.OpniInternalRouting, *routing.OpniInternalRouting) { + return routing.NewDefaultRoutingTree(innerHookServer), + routing.NewDefaultRoutingTree(innerHookServer), + routing.NewDefaultOpniInternalRouting(), + routing.NewDefaultOpniInternalRouting() +} + +var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), func() { + When("We maintain opni's internal routing information", func() { + It("should compare equality between slack receivers", func() { + testConditionId1 := uuid.New().String() + testConditionId2 := uuid.New().String() + testConditionId3 := testConditionId1 + testConditionId4 := testConditionId2 + testEndpointId1 := uuid.New().String() + testEndpointId2 := uuid.New().String() + testEndpointId3 := testEndpointId1 + testEndpointId4 := testEndpointId2 + slackConfig1 := alertingv1.SlackEndpoint{ + Channel: "#channel1", + WebhookUrl: "http://testWebhook1", + } + slackConfig2 := alertingv1.SlackEndpoint{ + Channel: "#channel2", + WebhookUrl: "http://testWebhook2", + } + r1, r2, ir1, ir2 := Create2RoutingTrees() + r3, r4, ir3, ir4 := Create2RoutingTrees() + Expect(r1).NotTo(BeNil()) + Expect(r2).NotTo(BeNil()) + err1 := r1.CreateRoutingNodeForCondition(testConditionId1, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_Slack{ + Slack: &slackConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir1) + Expect(err1).To(Succeed()) + err2 := r2.CreateRoutingNodeForCondition(testConditionId2, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_Slack{ + Slack: &slackConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir2) + Expect(err2).To(Succeed()) + err3 := r3.CreateRoutingNodeForCondition(testConditionId3, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId3, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_Slack{ + Slack: &slackConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir3) + Expect(err3).To(Succeed()) + err4 := r4.CreateRoutingNodeForCondition(testConditionId4, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId4, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_Slack{ + Slack: &slackConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir4) + Expect(err4).To(Succeed()) + equal1, _ := r1.IsEqual(r2) + Expect(equal1).To(BeFalse()) + equal2, _ := r3.IsEqual(r4) + Expect(equal2).To(BeFalse()) + equal3, _ := r1.IsEqual(r3) + Expect(equal3).To(BeTrue()) + equal4, _ := r2.IsEqual(r4) + Expect(equal4).To(BeTrue()) + + }) + + It("should compare equality between email receivers", func() { + testConditionId1 := uuid.New().String() + testConditionId2 := uuid.New().String() + testConditionId3 := testConditionId1 + testConditionId4 := testConditionId2 + testEndpointId1 := uuid.New().String() + testEndpointId2 := uuid.New().String() + testEndpointId3 := testEndpointId1 + testEndpointId4 := testEndpointId2 + emailConfig1 := alertingv1.EmailEndpoint{ + To: "", + SmtpFrom: lo.ToPtr("bot@google.com"), + SmtpSmartHost: lo.ToPtr("smtp.google.com:587"), + SmtpAuthUsername: lo.ToPtr("alex"), + SmtpAuthPassword: lo.ToPtr("password"), + } + emailConfig2 := alertingv1.EmailEndpoint{ + To: "", + SmtpFrom: lo.ToPtr("bot@google.com"), + SmtpSmartHost: lo.ToPtr("smtp.google.com:587"), + SmtpAuthUsername: lo.ToPtr("alex"), + SmtpAuthPassword: lo.ToPtr("password2"), + } + r1, r2, ir1, ir2 := Create2RoutingTrees() + r3, r4, ir3, ir4 := Create2RoutingTrees() + Expect(r1).NotTo(BeNil()) + Expect(r2).NotTo(BeNil()) + err1 := r1.CreateRoutingNodeForCondition(testConditionId1, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_Email{ + Email: &emailConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir1) + Expect(err1).To(Succeed()) + err2 := r2.CreateRoutingNodeForCondition(testConditionId2, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_Email{ + Email: &emailConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir2) + Expect(err2).To(Succeed()) + err3 := r3.CreateRoutingNodeForCondition(testConditionId3, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId3, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_Email{ + Email: &emailConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir3) + Expect(err3).To(Succeed()) + err4 := r4.CreateRoutingNodeForCondition(testConditionId4, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId4, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_Email{ + Email: &emailConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir4) + Expect(err4).To(Succeed()) + equal1, _ := r1.IsEqual(r2) + Expect(equal1).To(BeFalse()) + equal2, _ := r3.IsEqual(r4) + Expect(equal2).To(BeFalse()) + equal3, _ := r1.IsEqual(r3) + Expect(equal3).To(BeTrue()) + equal4, _ := r2.IsEqual(r4) + Expect(equal4).To(BeTrue()) + }) + + It("should compare equality between pager duty receivers", func() { + testConditionId1 := uuid.New().String() + testConditionId2 := uuid.New().String() + testConditionId3 := testConditionId1 + testConditionId4 := testConditionId2 + testEndpointId1 := uuid.New().String() + testEndpointId2 := uuid.New().String() + testEndpointId3 := testEndpointId1 + testEndpointId4 := testEndpointId2 + pagerDutyConfig1 := alertingv1.PagerDutyEndpoint{ + IntegrationKey: "testIntegrationKey1", + } + pagerDutyConfig2 := alertingv1.PagerDutyEndpoint{ + IntegrationKey: "testIntegrationKey2", + } + r1, r2, ir1, ir2 := Create2RoutingTrees() + r3, r4, ir3, ir4 := Create2RoutingTrees() + Expect(r1).NotTo(BeNil()) + Expect(r2).NotTo(BeNil()) + err1 := r1.CreateRoutingNodeForCondition(testConditionId1, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_PagerDuty{ + PagerDuty: &pagerDutyConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir1) + Expect(err1).To(Succeed()) + err2 := r2.CreateRoutingNodeForCondition(testConditionId2, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId1, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_PagerDuty{ + PagerDuty: &pagerDutyConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir2) + Expect(err2).To(Succeed()) + err3 := r3.CreateRoutingNodeForCondition(testConditionId3, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId3, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint1", + Description: "testEndpoint1", + Endpoint: &alertingv1.AlertEndpoint_PagerDuty{ + PagerDuty: &pagerDutyConfig1, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title1", + Body: "body1", + }, + }, ir3) + Expect(err3).To(Succeed()) + err4 := r4.CreateRoutingNodeForCondition(testConditionId4, &alertingv1.FullAttachedEndpoints{ + Items: []*alertingv1.FullAttachedEndpoint{ + { + EndpointId: testEndpointId4, + AlertEndpoint: &alertingv1.AlertEndpoint{ + Name: "testEndpoint2", + Description: "testEndpoint2", + Endpoint: &alertingv1.AlertEndpoint_PagerDuty{ + PagerDuty: &pagerDutyConfig2, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, + }, + Details: &alertingv1.EndpointImplementation{ + Title: "title2", + Body: "body2", + }, + }, ir4) + Expect(err4).To(Succeed()) + equal1, _ := r1.IsEqual(r2) + Expect(equal1).To(BeFalse()) + equal2, _ := r3.IsEqual(r4) + Expect(equal2).To(BeFalse()) + equal3, _ := r1.IsEqual(r3) + Expect(equal3).To(BeTrue()) + equal4, _ := r2.IsEqual(r4) + Expect(equal4).To(BeTrue()) + }) + }) +}) From 45fb5d150cc1037b1b637260f76d2e516207d947 Mon Sep 17 00:00:00 2001 From: Alexandre Lamarre Date: Tue, 29 Nov 2022 13:34:52 -0500 Subject: [PATCH 2/2] Equality interfaces --- internal/alertmanager/alertmanager_main.go | 2 +- pkg/alerting/backend/backend.go | 2 +- pkg/alerting/routing/api_types.go | 181 +----------------- pkg/alerting/routing/data.go | 79 ++------ pkg/alerting/routing/email.go | 112 +++++++++++ pkg/alerting/routing/equality_test.go | 24 +-- pkg/alerting/routing/interfaces.go | 48 +++++ pkg/alerting/routing/internal_routing_test.go | 28 +-- pkg/alerting/routing/ops.go | 24 +-- pkg/alerting/routing/pagerduty.go | 119 ++++++++++++ pkg/alerting/routing/receivers.go | 16 +- pkg/alerting/routing/routing_test.go | 4 +- pkg/alerting/routing/slack.go | 104 ++++++++++ 13 files changed, 448 insertions(+), 295 deletions(-) create mode 100644 pkg/alerting/routing/email.go create mode 100644 pkg/alerting/routing/interfaces.go create mode 100644 pkg/alerting/routing/pagerduty.go create mode 100644 pkg/alerting/routing/slack.go diff --git a/internal/alertmanager/alertmanager_main.go b/internal/alertmanager/alertmanager_main.go index c2964706b4..6f0a9b7bd5 100644 --- a/internal/alertmanager/alertmanager_main.go +++ b/internal/alertmanager/alertmanager_main.go @@ -226,7 +226,7 @@ func startOpniServer(configFile string) { http.Error(wr, err.Error(), http.StatusInternalServerError) return } - if equal, reason := r1.IsEqual(r2); !equal { + if equal, reason := r1.Equal(r2); !equal { lg.Errorf("config is not equal to persisted config: %s", reason) http.Error(wr, fmt.Sprintf("config not yet equal : %s", reason), http.StatusConflict) return diff --git a/pkg/alerting/backend/backend.go b/pkg/alerting/backend/backend.go index c5c202bc1b..2b9805e795 100644 --- a/pkg/alerting/backend/backend.go +++ b/pkg/alerting/backend/backend.go @@ -445,7 +445,7 @@ func NewExpectConfigEqual(expectedConfig string) func(*http.Response) error { if err != nil { return err } - if isEqual, reason := r1.IsEqual(r2); !isEqual { + if isEqual, reason := r1.Equal(r2); !isEqual { lg.Debug(fmt.Sprintf("config not equal : %s", reason)) return fmt.Errorf("%s", reason) } diff --git a/pkg/alerting/routing/api_types.go b/pkg/alerting/routing/api_types.go index 6d9369f1a2..40224d7c31 100644 --- a/pkg/alerting/routing/api_types.go +++ b/pkg/alerting/routing/api_types.go @@ -5,7 +5,6 @@ import ( "net/url" "time" - "github.com/containerd/containerd/pkg/cri/config" cfg "github.com/prometheus/alertmanager/config" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" @@ -13,49 +12,10 @@ import ( "golang.org/x/text/language" ) +var _ OpniReceiver = (*Receiver)(nil) + var ( - DefaultSlackConfig = SlackConfig{ - NotifierConfig: cfg.NotifierConfig{ - VSendResolved: false, - }, - Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`, - Username: `{{ template "slack.default.username" . }}`, - Title: `{{ template "slack.default.title" . }}`, - TitleLink: `{{ template "slack.default.titlelink" . }}`, - IconEmoji: `{{ template "slack.default.iconemoji" . }}`, - IconURL: `{{ template "slack.default.iconurl" . }}`, - Pretext: `{{ template "slack.default.pretext" . }}`, - Text: `{{ template "slack.default.text" . }}`, - Fallback: `{{ template "slack.default.fallback" . }}`, - CallbackID: `{{ template "slack.default.callbackid" . }}`, - Footer: `{{ template "slack.default.footer" . }}`, - } - // DefaultEmailConfig defines default values for Email configurations. - DefaultEmailConfig = EmailConfig{ - NotifierConfig: cfg.NotifierConfig{ - VSendResolved: false, - }, - HTML: `{{ template "email.default.html" . }}`, - Text: ``, - } normalizeTitle = cases.Title(language.AmericanEnglish) - - // DefaultPagerdutyConfig defines default values for PagerDuty configurations. - DefaultPagerdutyConfig = PagerdutyConfig{ - NotifierConfig: &cfg.NotifierConfig{ - VSendResolved: true, - }, - Description: `{{ template "pagerduty.default.description" .}}`, - Client: `{{ template "pagerduty.default.client" . }}`, - ClientURL: `{{ template "pagerduty.default.clientURL" . }}`, - } - // DefaultPagerdutyDetails defines the default values for PagerDuty details. - DefaultPagerdutyDetails = map[string]string{ - "firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`, - "resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`, - "num_firing": `{{ .Alerts.Firing | len }}`, - "num_resolved": `{{ .Alerts.Resolved | len }}`, - } ) // Receiver configuration provides configuration on how to contact a receiver. @@ -76,6 +36,10 @@ type Receiver struct { TelegramConfigs []*cfg.TelegramConfig `yaml:"telegram_configs,omitempty" json:"telegram_configs,omitempty"` } +func (r *Receiver) Equal(other *Receiver) (bool, string) { + return receiversAreEqual(r, other) +} + func (c *Receiver) UnmarshalYAML(unmarshal func(interface{}) error) error { type plain Receiver if err := unmarshal((*plain)(c)); err != nil { @@ -87,139 +51,6 @@ func (c *Receiver) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } -type SlackConfig struct { - cfg.NotifierConfig `yaml:",inline" json:",inline"` - - HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` - - // string since the string is stored in a kube secret anyways - APIURL string `yaml:"api_url,omitempty" json:"api_url,omitempty"` - APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"` - - // Slack channel override, (like #other-channel or @username). - Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Color string `yaml:"color,omitempty" json:"color,omitempty"` - - Title string `yaml:"title,omitempty" json:"title,omitempty"` - TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` - Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` - Text string `yaml:"text,omitempty" json:"text,omitempty"` - Fields []*cfg.SlackField `yaml:"fields,omitempty" json:"fields,omitempty"` - ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` - Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` - Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` - CallbackID string `yaml:"callback_id,omitempty" json:"callback_id,omitempty"` - IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` - IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` - ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` - ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` - LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` - MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"` - Actions []*cfg.SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"` -} - -type EmailConfig struct { - cfg.NotifierConfig `yaml:",inline" json:",inline"` - To string `yaml:"to,omitempty" json:"to,omitempty"` - From string `yaml:"from,omitempty" json:"from,omitempty"` - Hello string `yaml:"hello,omitempty" json:"hello,omitempty"` - Smarthost cfg.HostPort `yaml:"smarthost,omitempty" json:"smarthost,omitempty"` - AuthUsername string `yaml:"auth_username,omitempty" json:"auth_username,omitempty"` - // Change from secret to string since the string is stored in a kube secret anyways - AuthPassword string `yaml:"auth_password,omitempty" json:"auth_password,omitempty"` - // Change from secret to string since the string is stored in a kube secret anyways - AuthSecret string `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"` - AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - HTML string `yaml:"html,omitempty" json:"html,omitempty"` - Text string `yaml:"text,omitempty" json:"text,omitempty"` - RequireTLS *bool `yaml:"require_tls,omitempty" json:"require_tls,omitempty"` - TLSConfig config.TLSConfig `yaml:"tls_config,omitempty" json:"tls_config,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *EmailConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - *c = DefaultEmailConfig - type plain EmailConfig - if err := unmarshal((*plain)(c)); err != nil { - return err - } - if c.To == "" { - return fmt.Errorf("missing to address in email config") - } - // Header names are case-insensitive, check for collisions. - normalizedHeaders := map[string]string{} - for h, v := range c.Headers { - normalized := normalizeTitle.String(h) - if _, ok := normalizedHeaders[normalized]; ok { - return fmt.Errorf("duplicate header %q in email config", normalized) - } - normalizedHeaders[normalized] = v - } - c.Headers = normalizedHeaders - - return nil -} - -func (c *SlackConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - *c = DefaultSlackConfig - type plain SlackConfig - if err := unmarshal((*plain)(c)); err != nil { - return err - } - - if c.APIURL != "" && len(c.APIURLFile) > 0 { - return fmt.Errorf("at most one of api_url & api_url_file must be configured") - } - - return nil -} - -// PagerdutyConfig configures notifications via PagerDuty. -type PagerdutyConfig struct { - *cfg.NotifierConfig `yaml:",inline" json:",inline"` - - HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` - - // Change from secret to string since the string is stored in a kube secret anyways - ServiceKey string `yaml:"service_key,omitempty" json:"service_key,omitempty"` - // Change from secret to string since the string is stored in a kube secret anyways - RoutingKey string `yaml:"routing_key,omitempty" json:"routing_key,omitempty"` - URL *cfg.URL `yaml:"url,omitempty" json:"url,omitempty"` - Client string `yaml:"client,omitempty" json:"client,omitempty"` - ClientURL string `yaml:"client_url,omitempty" json:"client_url,omitempty"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Details map[string]string `yaml:"details,omitempty" json:"details,omitempty"` - Images []*cfg.PagerdutyImage `yaml:"images,omitempty" json:"images,omitempty"` - Links []*cfg.PagerdutyLink `yaml:"links,omitempty" json:"links,omitempty"` - Severity string `yaml:"severity,omitempty" json:"severity,omitempty"` - Class string `yaml:"class,omitempty" json:"class,omitempty"` - Component string `yaml:"component,omitempty" json:"component,omitempty"` - Group string `yaml:"group,omitempty" json:"group,omitempty"` -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *PagerdutyConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - *c = DefaultPagerdutyConfig - type plain PagerdutyConfig - if err := unmarshal((*plain)(c)); err != nil { - return err - } - if c.RoutingKey == "" && c.ServiceKey == "" { - return fmt.Errorf("missing service or routing key in PagerDuty config") - } - if c.Details == nil { - c.Details = make(map[string]string) - } - for k, v := range DefaultPagerdutyDetails { - if _, ok := c.Details[k]; !ok { - c.Details[k] = v - } - } - return nil -} - // required due to https://github.com/rancher/opni/issues/542 type GlobalConfig struct { // ResolveTimeout is the time after which an alert is declared resolved diff --git a/pkg/alerting/routing/data.go b/pkg/alerting/routing/data.go index 8ba9fafebc..b66e0f3e19 100644 --- a/pkg/alerting/routing/data.go +++ b/pkg/alerting/routing/data.go @@ -68,69 +68,6 @@ func areDurationsEqual(m1, m2 *model.Duration) (equal bool, reason string) { } -func slackConfigsAreEqual(s1, s2 *SlackConfig) (equal bool, reason string) { - if s1.Channel != s2.Channel { - return false, fmt.Sprintf("channel mismatch %s <-> %s ", s1.Channel, s2.Channel) - } - if s1.APIURL != s2.APIURL { - return false, fmt.Sprintf("api url mismatch %s <-> %s ", s1.APIURL, s2.APIURL) - } - return true, "" -} - -func emailConfigsAreEqual(e1, e2 *EmailConfig) (equal bool, reason string) { - if e1.To != e2.To { - return false, fmt.Sprintf("to mismatch %s <-> %s", e1.To, e2.To) - } - if e1.From != e2.From { - return false, fmt.Sprintf("from mismatch %s <-> %s ", e1.From, e2.From) - } - if e1.Smarthost != e2.Smarthost { - return false, fmt.Sprintf("smarthost mismatch %s <-> %s ", e1.Smarthost, e2.Smarthost) - } - if e1.AuthUsername != e2.AuthUsername { - return false, fmt.Sprintf("auth username mismatch %s <-> %s ", e1.AuthUsername, e2.AuthUsername) - } - if e1.AuthPassword != e2.AuthPassword { - return false, fmt.Sprintf("auth password mismatch %s <-> %s ", e1.AuthPassword, e2.AuthPassword) - } - if e1.AuthSecret != e2.AuthSecret { - return false, fmt.Sprintf("auth secret mismatch %s <-> %s ", e1.AuthSecret, e2.AuthSecret) - } - if e1.RequireTLS != e2.RequireTLS { - return false, fmt.Sprintf("require tls mismatch %v <-> %v ", e1.RequireTLS, e2.RequireTLS) - } - if e1.HTML != e2.HTML { - return false, fmt.Sprintf("html mismatch %s <-> %s ", e1.HTML, e2.HTML) - } - if e1.Text != e2.Text { - return false, fmt.Sprintf("text mismatch %s <-> %s ", e1.Text, e2.Text) - } - return true, "" -} - -func pagerDutyConfigsAreEqual(p1, p2 *PagerdutyConfig) (equal bool, reason string) { - if p1.RoutingKey != p2.RoutingKey { - return false, fmt.Sprintf("routing key mismatch %s <-> %s ", p1.RoutingKey, p2.RoutingKey) - } - if p1.ServiceKey != p2.ServiceKey { - return false, fmt.Sprintf("service key mismatch %s <-> %s ", p1.ServiceKey, p2.ServiceKey) - } - if p1.URL != p2.URL { - return false, fmt.Sprintf("url mismatch %s <-> %s ", p1.URL, p2.URL) - } - if p1.Client != p2.Client { - return false, fmt.Sprintf("client mismatch %s <-> %s ", p1.Client, p2.Client) - } - if p1.ClientURL != p2.ClientURL { - return false, fmt.Sprintf("client url mismatch %s <-> %s ", p1.ClientURL, p2.ClientURL) - } - if p1.Description != p2.Description { - return false, fmt.Sprintf("description mismatch %s <-> %s ", p1.Description, p2.Description) - } - return true, "" -} - func receiversAreEqual(r1 *Receiver, r2 *Receiver) (equal bool, reason string) { if r1.Name != r2.Name { // opni specific indexing return false, fmt.Sprintf("receiver name mismatch %s <-> %s ", r1.Name, r2.Name) @@ -146,17 +83,17 @@ func receiversAreEqual(r1 *Receiver, r2 *Receiver) (equal bool, reason string) { return false, fmt.Sprintf("pager duty configs are not yet synced: found num old %d <-> num new %d ", len(r1.PagerdutyConfigs), len(r2.PagerdutyConfigs)) } for idx, emailConfig := range r1.EmailConfigs { - if equal, reason := emailConfigsAreEqual(emailConfig, r2.EmailConfigs[idx]); !equal { + if equal, reason := emailConfig.Equal(r2.EmailConfigs[idx]); !equal { return false, fmt.Sprintf("email config mismatch : %s", reason) } } for idx, slackConfig := range r1.SlackConfigs { - if equal, reason := slackConfigsAreEqual(slackConfig, r2.SlackConfigs[idx]); !equal { + if equal, reason := slackConfig.Equal(r2.SlackConfigs[idx]); !equal { return false, fmt.Sprintf("slack config mismatch %s", reason) } } for idx, pagerDutyConfig := range r1.PagerdutyConfigs { - if equal, reason := pagerDutyConfigsAreEqual(pagerDutyConfig, r2.PagerdutyConfigs[idx]); !equal { + if equal, reason := pagerDutyConfig.Equal(r2.PagerdutyConfigs[idx]); !equal { return false, fmt.Sprintf("pager duty config mismatch %s", reason) } } @@ -236,15 +173,21 @@ func areMatchersEqual(m1, m2 cfg.Matchers) bool { return true } +var _ EqualityComparer[any] = (*RoutingTree)(nil) + // for our purposes we will only treat receivers and routes as opni config equality -func (r *RoutingTree) IsEqual(other *RoutingTree) (equal bool, reason string) { +func (r *RoutingTree) Equal(input any) (equal bool, reason string) { + if _, ok := input.(*RoutingTree); !ok { + return false, "input is not a routing tree" + } + other := input.(*RoutingTree) selfReceiverIndex := r.indexOpniReceivers() otherReceiverIndex := other.indexOpniReceivers() for id, r1 := range selfReceiverIndex { if r2, ok := otherReceiverIndex[id]; !ok { return false, fmt.Sprintf("configurations do not have matching receiver : %s", id) } else { - if equal, reason := receiversAreEqual(r1, r2); !equal { + if equal, reason := r1.Equal(r2); !equal { return false, fmt.Sprintf("configurations do not have equal receivers '%s' : %s", id, reason) } } diff --git a/pkg/alerting/routing/email.go b/pkg/alerting/routing/email.go new file mode 100644 index 0000000000..779085b757 --- /dev/null +++ b/pkg/alerting/routing/email.go @@ -0,0 +1,112 @@ +package routing + +import ( + "fmt" + + "github.com/containerd/containerd/pkg/cri/config" + cfg "github.com/prometheus/alertmanager/config" + alertingv1 "github.com/rancher/opni/pkg/apis/alerting/v1" +) + +var _ OpniConfig = (*EmailConfig)(nil) + +type EmailConfig struct { + cfg.NotifierConfig `yaml:",inline" json:",inline"` + To string `yaml:"to,omitempty" json:"to,omitempty"` + From string `yaml:"from,omitempty" json:"from,omitempty"` + Hello string `yaml:"hello,omitempty" json:"hello,omitempty"` + Smarthost cfg.HostPort `yaml:"smarthost,omitempty" json:"smarthost,omitempty"` + AuthUsername string `yaml:"auth_username,omitempty" json:"auth_username,omitempty"` + // Change from secret to string since the string is stored in a kube secret anyways + AuthPassword string `yaml:"auth_password,omitempty" json:"auth_password,omitempty"` + // Change from secret to string since the string is stored in a kube secret anyways + AuthSecret string `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"` + AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` + HTML string `yaml:"html,omitempty" json:"html,omitempty"` + Text string `yaml:"text,omitempty" json:"text,omitempty"` + RequireTLS *bool `yaml:"require_tls,omitempty" json:"require_tls,omitempty"` + TLSConfig config.TLSConfig `yaml:"tls_config,omitempty" json:"tls_config,omitempty"` +} + +func (c *EmailConfig) Equal(other *EmailConfig) (bool, string) { + return emailConfigsAreEqual(c, other) +} + +func (c *EmailConfig) InternalId() string { + return "email" +} + +func (c *EmailConfig) ExtractDetails() *alertingv1.EndpointImplementation { + return &alertingv1.EndpointImplementation{ + Title: c.Headers["Subject"], + Body: c.HTML, + SendResolved: &c.VSendResolved, + } +} + +func (c *EmailConfig) Default() OpniConfig { + return &EmailConfig{ + NotifierConfig: cfg.NotifierConfig{ + VSendResolved: false, + }, + HTML: `{{ template "email.default.html" . }}`, + Text: ``, + } +} + +// AlertManager Compatible unmarshalling that implements the the yaml.Unmarshaler interface. +func (c *EmailConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + DefaultEmail := c.Default().(*EmailConfig) + *c = *DefaultEmail + type plain EmailConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.To == "" { + return fmt.Errorf("missing to address in email config") + } + // Header names are case-insensitive, check for collisions. + normalizedHeaders := map[string]string{} + for h, v := range c.Headers { + normalized := normalizeTitle.String(h) + if _, ok := normalizedHeaders[normalized]; ok { + return fmt.Errorf("duplicate header %q in email config", normalized) + } + normalizedHeaders[normalized] = v + } + c.Headers = normalizedHeaders + + return nil +} + +func emailConfigsAreEqual(e1, e2 *EmailConfig) (equal bool, reason string) { + if e1.To != e2.To { + return false, fmt.Sprintf("to mismatch %s <-> %s", e1.To, e2.To) + } + if e1.From != e2.From { + return false, fmt.Sprintf("from mismatch %s <-> %s ", e1.From, e2.From) + } + if e1.Smarthost != e2.Smarthost { + return false, fmt.Sprintf("smarthost mismatch %s <-> %s ", e1.Smarthost, e2.Smarthost) + } + if e1.AuthUsername != e2.AuthUsername { + return false, fmt.Sprintf("auth username mismatch %s <-> %s ", e1.AuthUsername, e2.AuthUsername) + } + if e1.AuthPassword != e2.AuthPassword { + return false, fmt.Sprintf("auth password mismatch %s <-> %s ", e1.AuthPassword, e2.AuthPassword) + } + if e1.AuthSecret != e2.AuthSecret { + return false, fmt.Sprintf("auth secret mismatch %s <-> %s ", e1.AuthSecret, e2.AuthSecret) + } + if e1.RequireTLS != e2.RequireTLS { + return false, fmt.Sprintf("require tls mismatch %v <-> %v ", e1.RequireTLS, e2.RequireTLS) + } + if e1.HTML != e2.HTML { + return false, fmt.Sprintf("html mismatch %s <-> %s ", e1.HTML, e2.HTML) + } + if e1.Text != e2.Text { + return false, fmt.Sprintf("text mismatch %s <-> %s ", e1.Text, e2.Text) + } + return true, "" +} diff --git a/pkg/alerting/routing/equality_test.go b/pkg/alerting/routing/equality_test.go index cf2ee2a1fd..2bcae21dae 100644 --- a/pkg/alerting/routing/equality_test.go +++ b/pkg/alerting/routing/equality_test.go @@ -135,13 +135,13 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), }, }, ir4) Expect(err4).To(Succeed()) - equal1, _ := r1.IsEqual(r2) + equal1, _ := r1.Equal(r2) Expect(equal1).To(BeFalse()) - equal2, _ := r3.IsEqual(r4) + equal2, _ := r3.Equal(r4) Expect(equal2).To(BeFalse()) - equal3, _ := r1.IsEqual(r3) + equal3, _ := r1.Equal(r3) Expect(equal3).To(BeTrue()) - equal4, _ := r2.IsEqual(r4) + equal4, _ := r2.Equal(r4) Expect(equal4).To(BeTrue()) }) @@ -265,13 +265,13 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), }, }, ir4) Expect(err4).To(Succeed()) - equal1, _ := r1.IsEqual(r2) + equal1, _ := r1.Equal(r2) Expect(equal1).To(BeFalse()) - equal2, _ := r3.IsEqual(r4) + equal2, _ := r3.Equal(r4) Expect(equal2).To(BeFalse()) - equal3, _ := r1.IsEqual(r3) + equal3, _ := r1.Equal(r3) Expect(equal3).To(BeTrue()) - equal4, _ := r2.IsEqual(r4) + equal4, _ := r2.Equal(r4) Expect(equal4).To(BeTrue()) }) @@ -386,13 +386,13 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), }, }, ir4) Expect(err4).To(Succeed()) - equal1, _ := r1.IsEqual(r2) + equal1, _ := r1.Equal(r2) Expect(equal1).To(BeFalse()) - equal2, _ := r3.IsEqual(r4) + equal2, _ := r3.Equal(r4) Expect(equal2).To(BeFalse()) - equal3, _ := r1.IsEqual(r3) + equal3, _ := r1.Equal(r3) Expect(equal3).To(BeTrue()) - equal4, _ := r2.IsEqual(r4) + equal4, _ := r2.Equal(r4) Expect(equal4).To(BeTrue()) }) }) diff --git a/pkg/alerting/routing/interfaces.go b/pkg/alerting/routing/interfaces.go new file mode 100644 index 0000000000..8a48884128 --- /dev/null +++ b/pkg/alerting/routing/interfaces.go @@ -0,0 +1,48 @@ +package routing + +import alertingv1 "github.com/rancher/opni/pkg/apis/alerting/v1" + +func Equal[T EqualityComparer[T]](a, b T) (bool, string) { + return a.Equal(b) +} + +type EqualityComparer[T any] interface { + Equal(T) (bool, string) +} + +type OpniConfig interface { + InternalId() string + ExtractDetails() *alertingv1.EndpointImplementation + Default() OpniConfig +} + +type OpniReceiver interface { + IsEmpty() bool +} + +type OpniRoute[T any] interface { + EqualityComparer[T] + NewRouteBase(conditionId string) + SetGeneralRequestInfo(req *alertingv1.FullAttachedEndpoints) +} + +type OpniVirtualRouting interface { + // when a condition is created + CreateRoutingNode(conditionId string, + endpoints *alertingv1.FullAttachedEndpoints) + // when a condition is updated + UpdateRoutingNode(conditionId string, + endpoints *alertingv1.FullAttachedEndpoints) + // when a condition is deleted + DeleteRoutingNode(conditionId string) + // when an endpoint is updated + UpdateEndpoint(endpointId string) + // when an endpoint is deleted + DeleteEndpoint(endpointId string) + + SyncUserConfig(config string) error + // returns the matched + WalkUserConfig(labels map[string]string) string + Construct() *RoutingTree + From(*RoutingTree) error +} diff --git a/pkg/alerting/routing/internal_routing_test.go b/pkg/alerting/routing/internal_routing_test.go index e6086c0b27..c81490834e 100644 --- a/pkg/alerting/routing/internal_routing_test.go +++ b/pkg/alerting/routing/internal_routing_test.go @@ -44,7 +44,7 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), Expect(err).NotTo(Succeed()) negative := -2 err = testInternalRouting.Add(testConditionId1, testConditionId2, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &negative, }) Expect(err).NotTo(Succeed()) @@ -57,14 +57,14 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), testConditionId1, testEndpointId1, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) m, err := testInternalRouting.GetFromCondition(testConditionId1) Expect(err).To(BeNil()) Expect(m).To(HaveLen(1)) Expect(m[testEndpointId1]).NotTo(BeNil()) - Expect(m[testEndpointId1].EndpointType).To(Equal(routing.SlackEndpointInternalId)) + Expect(m[testEndpointId1].EndpointType).To(Equal((&routing.SlackConfig{}).InternalId())) Expect(m[testEndpointId1].Position).NotTo(BeNil()) Expect(*m[testEndpointId1].Position).To(Equal(zero)) }) @@ -82,18 +82,18 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), It("should fail to update with invalid input", func() { zero := 0 err := testInternalRouting.UpdateEndpoint(testConditionId1, testEndpointId3, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).NotTo(Succeed()) err = testInternalRouting.UpdateEndpoint(testConditionId3, testEndpointId1, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).NotTo(Succeed()) negative := -2 err = testInternalRouting.UpdateEndpoint(testConditionId1, testEndpointId1, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &negative, }) Expect(err).NotTo(Succeed()) @@ -102,7 +102,7 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), It("should update endpoints", func() { five := 5 err := testInternalRouting.UpdateEndpoint(testConditionId1, testEndpointId1, routing.OpniRoutingMetadata{ - EndpointType: routing.EmailEndpointInternalId, + EndpointType: (&routing.EmailConfig{}).InternalId(), Position: &five, }) Expect(err).To(Succeed()) @@ -110,7 +110,7 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), Expect(err).To(Succeed()) Expect(m.Position).NotTo(BeNil()) Expect(*m.Position).To(Equal(five)) - Expect(m.EndpointType).To(Equal(routing.EmailEndpointInternalId)) + Expect(m.EndpointType).To(Equal((&routing.EmailConfig{}).InternalId())) }) It("should fail to delete invalid endpoints", func() { @@ -128,17 +128,17 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), zero := 0 err = testInternalRouting.Add(testConditionId1, testEndpointId3, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).To(Succeed()) err = testInternalRouting.Add(testConditionId1, testEndpointId4, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).To(Succeed()) err = testInternalRouting.Add(testConditionId1, testEndpointId5, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).To(Succeed()) @@ -151,17 +151,17 @@ var _ = Describe("Internal routing tests", Ordered, Label(test.Unit, test.Slow), _, err = testInternalRouting.GetFromCondition(testConditionId1) Expect(err).NotTo(Succeed()) err = testInternalRouting.Add(testConditionId1, testEndpointId3, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).To(Succeed()) err = testInternalRouting.Add(testConditionId1, testEndpointId4, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) Expect(err).To(Succeed()) err = testInternalRouting.Add(testConditionId1, testEndpointId5, routing.OpniRoutingMetadata{ - EndpointType: routing.SlackEndpointInternalId, + EndpointType: (&routing.SlackConfig{}).InternalId(), Position: &zero, }) }) diff --git a/pkg/alerting/routing/ops.go b/pkg/alerting/routing/ops.go index f71baac556..7a14f78756 100644 --- a/pkg/alerting/routing/ops.go +++ b/pkg/alerting/routing/ops.go @@ -115,13 +115,13 @@ func (r *RoutingTree) UpdateIndividualEndpointNode( toTraverse := []TraversalOp{} newEndpointTypeFunc := func() string { if s := req.GetAlertEndpoint().GetSlack(); s != nil { - return SlackEndpointInternalId + return (&SlackConfig{}).InternalId() } if e := req.GetAlertEndpoint().GetEmail(); e != nil { - return EmailEndpointInternalId + return (&EmailConfig{}).InternalId() } if p := req.GetAlertEndpoint().GetPagerDuty(); p != nil { - return PagerDutyEndpointInternalId + return (&PagerdutyConfig{}).InternalId() } return "unknown" } @@ -157,7 +157,7 @@ func (r *RoutingTree) UpdateIndividualEndpointNode( return err } switch toTraverseItem.endpointType { - case SlackEndpointInternalId: + case (&SlackConfig{}).InternalId(): slackCfg, err := NewSlackReceiverNode(req.GetAlertEndpoint().GetSlack()) if err != nil { return err @@ -167,7 +167,7 @@ func (r *RoutingTree) UpdateIndividualEndpointNode( return err } r.Receivers[recvPos].SlackConfigs[toTraverseItem.position] = slackCfg - case EmailEndpointInternalId: + case (&EmailConfig{}).InternalId(): emailCfg, err := NewEmailReceiverNode(req.GetAlertEndpoint().GetEmail()) if err != nil { return err @@ -177,7 +177,7 @@ func (r *RoutingTree) UpdateIndividualEndpointNode( return err } r.Receivers[recvPos].EmailConfigs[toTraverseItem.position] = emailCfg - case PagerDutyEndpointInternalId: + case (&PagerdutyConfig{}).InternalId(): pagerCfg, err := NewPagerDutyReceiverNode(req.GetAlertEndpoint().GetPagerDuty()) if err != nil { return err @@ -208,17 +208,17 @@ func (r *RoutingTree) UpdateIndividualEndpointNode( return err } switch toTraverseItem.endpointType { - case SlackEndpointInternalId: + case (&SlackConfig{}).InternalId(): r.Receivers[recvPos].SlackConfigs = slices.Delete( r.Receivers[recvPos].SlackConfigs, toTraverseItem.position, toTraverseItem.position+1) - case EmailEndpointInternalId: + case (&EmailConfig{}).InternalId(): r.Receivers[recvPos].EmailConfigs = slices.Delete( r.Receivers[recvPos].EmailConfigs, toTraverseItem.position, toTraverseItem.position+1) - case PagerDutyEndpointInternalId: + case (&PagerdutyConfig{}).InternalId(): r.Receivers[recvPos].PagerdutyConfigs = slices.Delete( r.Receivers[recvPos].PagerdutyConfigs, toTraverseItem.position, @@ -278,17 +278,17 @@ func (r *RoutingTree) DeleteIndividualEndpointNode( return nil, err } switch toTraverseItem.endpointType { - case SlackEndpointInternalId: + case (&SlackConfig{}).InternalId(): r.Receivers[recvPos].SlackConfigs = slices.Delete( r.Receivers[recvPos].SlackConfigs, toTraverseItem.position, toTraverseItem.position+1) - case EmailEndpointInternalId: + case (&EmailConfig{}).InternalId(): r.Receivers[recvPos].EmailConfigs = slices.Delete( r.Receivers[recvPos].EmailConfigs, toTraverseItem.position, toTraverseItem.position+1) - case PagerDutyEndpointInternalId: + case (&PagerdutyConfig{}).InternalId(): r.Receivers[recvPos].PagerdutyConfigs = slices.Delete( r.Receivers[recvPos].PagerdutyConfigs, toTraverseItem.position, diff --git a/pkg/alerting/routing/pagerduty.go b/pkg/alerting/routing/pagerduty.go new file mode 100644 index 0000000000..389c132484 --- /dev/null +++ b/pkg/alerting/routing/pagerduty.go @@ -0,0 +1,119 @@ +package routing + +import ( + "fmt" + "strings" + + cfg "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" + alertingv1 "github.com/rancher/opni/pkg/apis/alerting/v1" +) + +var _ OpniConfig = (*PagerdutyConfig)(nil) + +var ( + // DefaultPagerdutyDetails defines the default values for PagerDuty details. + DefaultPagerdutyDetails = map[string]string{ + "firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`, + "resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`, + "num_firing": `{{ .Alerts.Firing | len }}`, + "num_resolved": `{{ .Alerts.Resolved | len }}`, + } +) + +// PagerdutyConfig configures notifications via PagerDuty. +type PagerdutyConfig struct { + *cfg.NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + // Change from secret to string since the string is stored in a kube secret anyways + ServiceKey string `yaml:"service_key,omitempty" json:"service_key,omitempty"` + // Change from secret to string since the string is stored in a kube secret anyways + RoutingKey string `yaml:"routing_key,omitempty" json:"routing_key,omitempty"` + URL *cfg.URL `yaml:"url,omitempty" json:"url,omitempty"` + Client string `yaml:"client,omitempty" json:"client,omitempty"` + ClientURL string `yaml:"client_url,omitempty" json:"client_url,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Details map[string]string `yaml:"details,omitempty" json:"details,omitempty"` + Images []*cfg.PagerdutyImage `yaml:"images,omitempty" json:"images,omitempty"` + Links []*cfg.PagerdutyLink `yaml:"links,omitempty" json:"links,omitempty"` + Severity string `yaml:"severity,omitempty" json:"severity,omitempty"` + Class string `yaml:"class,omitempty" json:"class,omitempty"` + Component string `yaml:"component,omitempty" json:"component,omitempty"` + Group string `yaml:"group,omitempty" json:"group,omitempty"` +} + +// AlertManager Compatible unmarshalling that implements the the yaml.Unmarshaler interface. +func (c *PagerdutyConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + DefaultPagerduty := c.Default().(*PagerdutyConfig) + *c = *DefaultPagerduty + type plain PagerdutyConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.RoutingKey == "" && c.ServiceKey == "" { + return fmt.Errorf("missing service or routing key in PagerDuty config") + } + if c.Details == nil { + c.Details = make(map[string]string) + } + for k, v := range DefaultPagerdutyDetails { + if _, ok := c.Details[k]; !ok { + c.Details[k] = v + } + } + return nil +} + +func (c *PagerdutyConfig) Equal(other *PagerdutyConfig) (bool, string) { + return pagerDutyConfigsAreEqual(c, other) +} + +func (c *PagerdutyConfig) ExtractDetails() *alertingv1.EndpointImplementation { + strArr := strings.Split(c.Description, "\n") + title := strArr[0] + body := strings.Join(strArr[1:], "\n") + return &alertingv1.EndpointImplementation{ + Title: title, + Body: body, + SendResolved: &c.VSendResolved, + } +} + +func (c *PagerdutyConfig) Default() OpniConfig { + return &PagerdutyConfig{ + NotifierConfig: &cfg.NotifierConfig{ + VSendResolved: true, + }, + Description: `{{ template "pagerduty.default.description" .}}`, + Client: `{{ template "pagerduty.default.client" . }}`, + ClientURL: `{{ template "pagerduty.default.clientURL" . }}`, + } +} + +func (p *PagerdutyConfig) InternalId() string { + return "pagerduty" +} + +func pagerDutyConfigsAreEqual(p1, p2 *PagerdutyConfig) (equal bool, reason string) { + if p1.RoutingKey != p2.RoutingKey { + return false, fmt.Sprintf("routing key mismatch %s <-> %s ", p1.RoutingKey, p2.RoutingKey) + } + if p1.ServiceKey != p2.ServiceKey { + return false, fmt.Sprintf("service key mismatch %s <-> %s ", p1.ServiceKey, p2.ServiceKey) + } + if p1.URL != p2.URL { + return false, fmt.Sprintf("url mismatch %s <-> %s ", p1.URL, p2.URL) + } + if p1.Client != p2.Client { + return false, fmt.Sprintf("client mismatch %s <-> %s ", p1.Client, p2.Client) + } + if p1.ClientURL != p2.ClientURL { + return false, fmt.Sprintf("client url mismatch %s <-> %s ", p1.ClientURL, p2.ClientURL) + } + if p1.Description != p2.Description { + return false, fmt.Sprintf("description mismatch %s <-> %s ", p1.Description, p2.Description) + } + return true, "" +} diff --git a/pkg/alerting/routing/receivers.go b/pkg/alerting/routing/receivers.go index 0c5764ad4d..4eae0f9c56 100644 --- a/pkg/alerting/routing/receivers.go +++ b/pkg/alerting/routing/receivers.go @@ -13,10 +13,6 @@ import ( "golang.org/x/exp/slices" ) -const SlackEndpointInternalId = "slack" -const EmailEndpointInternalId = "email" -const PagerDutyEndpointInternalId = "pagerduty" - func (r *Receiver) AddEndpoint( alertEndpoint *alertingv1.AlertEndpoint, details *alertingv1.EndpointImplementation) (int, string, error) { @@ -33,7 +29,7 @@ func (r *Receiver) AddEndpoint( return -1, "", err } r.SlackConfigs = append(r.SlackConfigs, slackCfg) - return len(r.SlackConfigs) - 1, SlackEndpointInternalId, nil + return len(r.SlackConfigs) - 1, slackCfg.InternalId(), nil } if e := alertEndpoint.GetEmail(); e != nil { emailCfg, err := NewEmailReceiverNode(e) @@ -45,7 +41,7 @@ func (r *Receiver) AddEndpoint( return -1, "", err } r.EmailConfigs = append(r.EmailConfigs, emailCfg) - return len(r.EmailConfigs) - 1, EmailEndpointInternalId, nil + return len(r.EmailConfigs) - 1, emailCfg.InternalId(), nil } if p := alertEndpoint.GetPagerDuty(); p != nil { pagerCfg, err := NewPagerDutyReceiverNode(p) @@ -58,7 +54,7 @@ func (r *Receiver) AddEndpoint( return -1, "", err } r.PagerdutyConfigs = append(r.PagerdutyConfigs, pagerCfg) - return len(r.PagerdutyConfigs) - 1, PagerDutyEndpointInternalId, nil + return len(r.PagerdutyConfigs) - 1, pagerCfg.InternalId(), nil } return -1, "", validation.Errorf("unknown endpoint type : %v", alertEndpoint) } @@ -223,19 +219,19 @@ func (r *RoutingTree) ExtractImplementationDetails(conditionId, endpointType str } switch endpointType { - case SlackEndpointInternalId: + case (&SlackConfig{}).InternalId(): return &alertingv1.EndpointImplementation{ Title: r.Receivers[recvIdx].SlackConfigs[position].Title, Body: r.Receivers[recvIdx].SlackConfigs[position].Text, SendResolved: &r.Receivers[recvIdx].SlackConfigs[position].VSendResolved, }, nil - case EmailEndpointInternalId: + case (&EmailConfig{}).InternalId(): return &alertingv1.EndpointImplementation{ Title: r.Receivers[recvIdx].EmailConfigs[position].Headers["Subject"], Body: r.Receivers[recvIdx].EmailConfigs[position].HTML, SendResolved: &r.Receivers[recvIdx].EmailConfigs[position].VSendResolved, }, nil - case PagerDutyEndpointInternalId: + case (&PagerdutyConfig{}).InternalId(): strArr := strings.Split(r.Receivers[recvIdx].PagerdutyConfigs[position].Description, "\n") title := strArr[0] body := strings.Join(strArr[1:], "\n") diff --git a/pkg/alerting/routing/routing_test.go b/pkg/alerting/routing/routing_test.go index 7d266d9569..4293424b55 100644 --- a/pkg/alerting/routing/routing_test.go +++ b/pkg/alerting/routing/routing_test.go @@ -213,9 +213,9 @@ var _ = Describe("Full fledged dynamic opni routing tests", Ordered, Label(test. } for _, metadata := range internalRoutingTree.Content[conditionId] { switch metadata.EndpointType { - case routing.SlackEndpointInternalId: + case (&routing.SlackConfig{}).InternalId(): Expect(len(testRoutingTree.GetReceivers()[idxReceiver].SlackConfigs)).To(BeNumerically(">=", *metadata.Position)) - case routing.EmailEndpointInternalId: + case (&routing.EmailConfig{}).InternalId(): Expect(len(testRoutingTree.GetReceivers()[idxReceiver].EmailConfigs)).To(BeNumerically(">=", *metadata.Position)) default: Fail("invalid endpoint type") diff --git a/pkg/alerting/routing/slack.go b/pkg/alerting/routing/slack.go new file mode 100644 index 0000000000..e193e521f8 --- /dev/null +++ b/pkg/alerting/routing/slack.go @@ -0,0 +1,104 @@ +package routing + +import ( + "fmt" + + cfg "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" + alertingv1 "github.com/rancher/opni/pkg/apis/alerting/v1" +) + +var _ OpniConfig = (*SlackConfig)(nil) + +type SlackConfig struct { + cfg.NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + // string since the string is stored in a kube secret anyways + APIURL string `yaml:"api_url,omitempty" json:"api_url,omitempty"` + APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"` + + // Slack channel override, (like #other-channel or @username). + Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Color string `yaml:"color,omitempty" json:"color,omitempty"` + + Title string `yaml:"title,omitempty" json:"title,omitempty"` + TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` + Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` + Text string `yaml:"text,omitempty" json:"text,omitempty"` + Fields []*cfg.SlackField `yaml:"fields,omitempty" json:"fields,omitempty"` + ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` + Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` + Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` + CallbackID string `yaml:"callback_id,omitempty" json:"callback_id,omitempty"` + IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` + IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` + ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` + ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` + LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` + MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"` + Actions []*cfg.SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"` +} + +func (c *SlackConfig) Equal(other *SlackConfig) (bool, string) { + return slackConfigsAreEqual(c, other) +} + +func (c *SlackConfig) InternalId() string { + return "slack" +} + +func (c *SlackConfig) ExtractDetails() *alertingv1.EndpointImplementation { + return &alertingv1.EndpointImplementation{ + Title: c.Title, + Body: c.Text, + SendResolved: &c.VSendResolved, + } +} + +func (c *SlackConfig) Default() OpniConfig { + return &SlackConfig{ + NotifierConfig: cfg.NotifierConfig{ + VSendResolved: false, + }, + Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`, + Username: `{{ template "slack.default.username" . }}`, + Title: `{{ template "slack.default.title" . }}`, + TitleLink: `{{ template "slack.default.titlelink" . }}`, + IconEmoji: `{{ template "slack.default.iconemoji" . }}`, + IconURL: `{{ template "slack.default.iconurl" . }}`, + Pretext: `{{ template "slack.default.pretext" . }}`, + Text: `{{ template "slack.default.text" . }}`, + Fallback: `{{ template "slack.default.fallback" . }}`, + CallbackID: `{{ template "slack.default.callbackid" . }}`, + Footer: `{{ template "slack.default.footer" . }}`, + } +} + +// AlertManager Compatible unmarshalling that implements the the yaml.Unmarshaler interface. +func (c *SlackConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + DefaultSlack := c.Default().(*SlackConfig) + *c = *DefaultSlack + type plain SlackConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.APIURL != "" && len(c.APIURLFile) > 0 { + return fmt.Errorf("at most one of api_url & api_url_file must be configured") + } + + return nil +} + +func slackConfigsAreEqual(s1, s2 *SlackConfig) (equal bool, reason string) { + if s1.Channel != s2.Channel { + return false, fmt.Sprintf("channel mismatch %s <-> %s ", s1.Channel, s2.Channel) + } + if s1.APIURL != s2.APIURL { + return false, fmt.Sprintf("api url mismatch %s <-> %s ", s1.APIURL, s2.APIURL) + } + return true, "" +}