diff --git a/docs/services/pagerduty_v2.md b/docs/services/pagerduty_v2.md new file mode 100644 index 00000000..21e8d942 --- /dev/null +++ b/docs/services/pagerduty_v2.md @@ -0,0 +1,78 @@ +# PagerDuty V2 + +## Parameters + +The PagerDuty notification service is used to trigger PagerDuty events and requires specifying the following settings: + +* `serviceKeys` - a dictionary with the following structure: + * `service-name: $pagerduty-key-service-name` where `service-name` is the name you want to use for the service to make events for, and `$pagerduty-key-service-name` is a reference to the secret that contains the actual PagerDuty integration key (Events API v2 integration) + +If you want multiple Argo apps to trigger events to their respective PagerDuty services, create an integration key in each service you want to setup alerts for. + +To create a PagerDuty integration key, [follow these instructions](https://support.pagerduty.com/docs/services-and-integrations#create-a-generic-events-api-integration) to add an Events API v2 integration to the service of your choice. + +## Configuration + +The following snippet contains sample PagerDuty service configuration. It assumes the service you want to alert on is called `my-service`. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: +stringData: + pagerduty-key-my-service: +``` + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: +data: + service.pagerdutyv2: | + serviceKeys: + my-service: $pagerduty-key-my-service +``` + +## Template + +[Notification templates](../templates.md) support specifying subject for PagerDuty notifications: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: +data: + template.rollout-aborted: | + message: Rollout {{.rollout.metadata.name}} is aborted. + pagerdutyv2: + summary: "Rollout {{.rollout.metadata.name}} is aborted." + severity: "critical" + source: "{{.rollout.metadata.name}}" +``` + +The parameters for the PagerDuty configuration in the template generally match with the payload for the Events API v2 endpoint. All parameters are strings. + +* `summary` - (required) A brief text summary of the event, used to generate the summaries/titles of any associated alerts. +* `severity` - (required) The perceived severity of the status the event is describing with respect to the affected system. Allowed values: `critical`, `warning`, `error`, `info` +* `source` - (required) The unique location of the affected system, preferably a hostname or FQDN. +* `component` - Component of the source machine that is responsible for the event. +* `group` - Logical grouping of components of a service. +* `class` - The class/type of the event. +* `url` - The URL that should be used for the link "View in ArgoCD" in PagerDuty. + +The `timestamp` and `custom_details` parameters are not currently supported. + +## Annotation + +Annotation sample for PagerDuty notifications: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + annotations: + notifications.argoproj.io/subscribe.on-rollout-aborted.pagerdutyv2: "" +``` diff --git a/pkg/services/pagerdutyv2.go b/pkg/services/pagerdutyv2.go new file mode 100644 index 00000000..91641e54 --- /dev/null +++ b/pkg/services/pagerdutyv2.go @@ -0,0 +1,165 @@ +package services + +import ( + "bytes" + "context" + "fmt" + texttemplate "text/template" + + "github.com/PagerDuty/go-pagerduty" + log "github.com/sirupsen/logrus" +) + +type PagerDutyV2Notification struct { + Summary string `json:"summary"` + Severity string `json:"severity"` + Source string `json:"source"` + Component string `json:"component,omitempty"` + Group string `json:"group,omitempty"` + Class string `json:"class,omitempty"` + URL string `json:"url"` +} + +type PagerdutyV2Options struct { + ServiceKeys map[string]string `json:"serviceKeys"` +} + +func (p *PagerDutyV2Notification) GetTemplater(name string, f texttemplate.FuncMap) (Templater, error) { + summary, err := texttemplate.New(name).Funcs(f).Parse(p.Summary) + if err != nil { + return nil, err + } + severity, err := texttemplate.New(name).Funcs(f).Parse(p.Severity) + if err != nil { + return nil, err + } + source, err := texttemplate.New(name).Funcs(f).Parse(p.Source) + if err != nil { + return nil, err + } + component, err := texttemplate.New(name).Funcs(f).Parse(p.Component) + if err != nil { + return nil, err + } + group, err := texttemplate.New(name).Funcs(f).Parse(p.Group) + if err != nil { + return nil, err + } + class, err := texttemplate.New(name).Funcs(f).Parse(p.Class) + if err != nil { + return nil, err + } + url, err := texttemplate.New(name).Funcs(f).Parse(p.URL) + if err != nil { + return nil, err + } + + return func(notification *Notification, vars map[string]interface{}) error { + if notification.PagerdutyV2 == nil { + notification.PagerdutyV2 = &PagerDutyV2Notification{} + } + var summaryData bytes.Buffer + if err := summary.Execute(&summaryData, vars); err != nil { + return err + } + notification.PagerdutyV2.Summary = summaryData.String() + + var severityData bytes.Buffer + if err := severity.Execute(&severityData, vars); err != nil { + return err + } + notification.PagerdutyV2.Severity = severityData.String() + + var sourceData bytes.Buffer + if err := source.Execute(&sourceData, vars); err != nil { + return err + } + notification.PagerdutyV2.Source = sourceData.String() + + var componentData bytes.Buffer + if err := component.Execute(&componentData, vars); err != nil { + return err + } + notification.PagerdutyV2.Component = componentData.String() + + var groupData bytes.Buffer + if err := group.Execute(&groupData, vars); err != nil { + return err + } + notification.PagerdutyV2.Group = groupData.String() + + var classData bytes.Buffer + if err := class.Execute(&classData, vars); err != nil { + return err + } + notification.PagerdutyV2.Class = classData.String() + + var urlData bytes.Buffer + if err := url.Execute(&urlData, vars); err != nil { + return err + } + notification.PagerdutyV2.URL = urlData.String() + + return nil + }, nil +} + +func NewPagerdutyV2Service(opts PagerdutyV2Options) NotificationService { + return &pagerdutyV2Service{opts: opts} +} + +type pagerdutyV2Service struct { + opts PagerdutyV2Options +} + +func (p pagerdutyV2Service) Send(notification Notification, dest Destination) error { + routingKey, ok := p.opts.ServiceKeys[dest.Recipient] + if !ok { + return fmt.Errorf("no API key configured for recipient %s", dest.Recipient) + } + + if notification.PagerdutyV2 == nil { + return fmt.Errorf("no config found for pagerdutyv2") + } + + event := buildEvent(routingKey, notification) + + response, err := pagerduty.ManageEventWithContext(context.TODO(), event) + if err != nil { + log.Errorf("Error: %v", err) + return err + } + log.Debugf("PagerDuty event triggered succesfully. Status: %v, Message: %v", response.Status, response.Message) + return nil +} + +func buildEvent(routingKey string, notification Notification) pagerduty.V2Event { + payload := pagerduty.V2Payload{ + Summary: notification.PagerdutyV2.Summary, + Severity: notification.PagerdutyV2.Severity, + Source: notification.PagerdutyV2.Source, + } + + if len(notification.PagerdutyV2.Component) > 0 { + payload.Component = notification.PagerdutyV2.Component + } + if len(notification.PagerdutyV2.Group) > 0 { + payload.Group = notification.PagerdutyV2.Group + } + if len(notification.PagerdutyV2.Class) > 0 { + payload.Class = notification.PagerdutyV2.Class + } + + event := pagerduty.V2Event{ + RoutingKey: routingKey, + Action: "trigger", + Payload: &payload, + Client: "ArgoCD", + } + + if len(notification.PagerdutyV2.URL) > 0 { + event.ClientURL = notification.PagerdutyV2.URL + } + + return event +} diff --git a/pkg/services/pagerdutyv2_test.go b/pkg/services/pagerdutyv2_test.go new file mode 100644 index 00000000..b73810c6 --- /dev/null +++ b/pkg/services/pagerdutyv2_test.go @@ -0,0 +1,272 @@ +package services + +import ( + "errors" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestGetTemplater_PagerDutyV2(t *testing.T) { + t.Run("all parameters specified", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}}", + Component: "{{.component}}", + Group: "{{.group}}", + Class: "{{.class}}", + URL: "{{.url}}", + }, + } + + templater, err := n.GetTemplater("", template.FuncMap{}) + if !assert.NoError(t, err) { + return + } + + var notification Notification + + err = templater(¬ification, map[string]interface{}{ + "summary": "hello", + "severity": "critical", + "source": "my-app", + "component": "test-component", + "group": "test-group", + "class": "test-class", + "url": "http://example.com", + }) + + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "hello", notification.PagerdutyV2.Summary) + assert.Equal(t, "critical", notification.PagerdutyV2.Severity) + assert.Equal(t, "my-app", notification.PagerdutyV2.Source) + assert.Equal(t, "test-component", notification.PagerdutyV2.Component) + assert.Equal(t, "test-group", notification.PagerdutyV2.Group) + assert.Equal(t, "test-class", notification.PagerdutyV2.Class) + assert.Equal(t, "http://example.com", notification.PagerdutyV2.URL) + }) + + t.Run("handle error for summary", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}", + Severity: "{{.severity}", + Source: "{{.source}", + Component: "{{.component}", + Group: "{{.group}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for severity", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}", + Source: "{{.source}", + Component: "{{.component}", + Group: "{{.group}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for source", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}", + Component: "{{.component}", + Group: "{{.group}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for component", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}}", + Component: "{{.component}", + Group: "{{.group}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for group", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}}", + Component: "{{.component}}", + Group: "{{.group}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for class", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}}", + Component: "{{.component}}", + Group: "{{.group}}", + Class: "{{.class}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("handle error for url", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", + Severity: "{{.severity}}", + Source: "{{.source}}", + Component: "{{.component}}", + Group: "{{.group}}", + Class: "{{.class}}", + URL: "{{.url}", + }, + } + + _, err := n.GetTemplater("", template.FuncMap{}) + assert.Error(t, err) + }) + + t.Run("only required parameters specified", func(t *testing.T) { + n := Notification{ + PagerdutyV2: &PagerDutyV2Notification{ + Summary: "{{.summary}}", Severity: "{{.severity}}", Source: "{{.source}}", + }, + } + + templater, err := n.GetTemplater("", template.FuncMap{}) + if !assert.NoError(t, err) { + return + } + + var notification Notification + + err = templater(¬ification, map[string]interface{}{ + "summary": "hello", + "severity": "critical", + "source": "my-app", + }) + + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "hello", notification.PagerdutyV2.Summary) + assert.Equal(t, "critical", notification.PagerdutyV2.Severity) + assert.Equal(t, "my-app", notification.PagerdutyV2.Source) + assert.Equal(t, "", notification.PagerdutyV2.Component) + assert.Equal(t, "", notification.PagerdutyV2.Group) + assert.Equal(t, "", notification.PagerdutyV2.Class) + }) +} + +func TestSend_PagerDuty(t *testing.T) { + t.Run("builds event with full payload", func(t *testing.T) { + routingKey := "routing-key" + summary := "test-app failed to deploy" + severity := "error" + source := "test-app" + component := "test-component" + group := "platform" + class := "test-class" + url := "https://www.example.com/" + + event := buildEvent(routingKey, Notification{ + Message: "message", + PagerdutyV2: &PagerDutyV2Notification{ + Summary: summary, + Severity: severity, + Source: source, + Component: component, + Group: group, + Class: class, + URL: url, + }, + }) + + assert.Equal(t, routingKey, event.RoutingKey) + assert.Equal(t, summary, event.Payload.Summary) + assert.Equal(t, severity, event.Payload.Severity) + assert.Equal(t, source, event.Payload.Source) + assert.Equal(t, component, event.Payload.Component) + assert.Equal(t, group, event.Payload.Group) + assert.Equal(t, class, event.Payload.Class) + assert.Equal(t, url, event.ClientURL) + }) + + t.Run("missing config", func(t *testing.T) { + service := NewPagerdutyV2Service(PagerdutyV2Options{ + ServiceKeys: map[string]string{ + "test-service": "key", + }, + }) + err := service.Send(Notification{ + Message: "message", + }, Destination{ + Service: "pagerdutyv2", + Recipient: "test-service", + }) + + if assert.Error(t, err) { + assert.Equal(t, err, errors.New("no config found for pagerdutyv2")) + } + }) + + t.Run("missing apiKey", func(t *testing.T) { + service := NewPagerdutyV2Service(PagerdutyV2Options{}) + err := service.Send(Notification{ + Message: "message", + }, Destination{ + Service: "pagerduty", + Recipient: "test-service", + }) + + if assert.Error(t, err) { + assert.Equal(t, err, errors.New("no API key configured for recipient test-service")) + } + }) +} diff --git a/pkg/services/services.go b/pkg/services/services.go index ced22bbe..d919981e 100644 --- a/pkg/services/services.go +++ b/pkg/services/services.go @@ -24,6 +24,7 @@ type Notification struct { Alertmanager *AlertmanagerNotification `json:"alertmanager,omitempty"` GoogleChat *GoogleChatNotification `json:"googlechat,omitempty"` Pagerduty *PagerDutyNotification `json:"pagerduty,omitempty"` + PagerdutyV2 *PagerDutyV2Notification `json:"pagerdutyv2,omitempty"` Newrelic *NewrelicNotification `json:"newrelic,omitempty"` } @@ -97,6 +98,10 @@ func (n *Notification) GetTemplater(name string, f texttemplate.FuncMap) (Templa sources = append(sources, n.Pagerduty) } + if n.PagerdutyV2 != nil { + sources = append(sources, n.PagerdutyV2) + } + if n.Newrelic != nil { sources = append(sources, n.Newrelic) } @@ -197,6 +202,12 @@ func NewService(serviceType string, optsData []byte) (NotificationService, error return nil, err } return NewPagerdutyService(opts), nil + case "pagerdutyv2": + var opts PagerdutyV2Options + if err := yaml.Unmarshal(optsData, &opts); err != nil { + return nil, err + } + return NewPagerdutyV2Service(opts), nil case "newrelic": var opts NewrelicOptions if err := yaml.Unmarshal(optsData, &opts); err != nil {