File: pagerduty.go

package info (click to toggle)
golang-github-nicholas-fedor-shoutrrr 0.12.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 5,680 kB
  • sloc: sh: 74; makefile: 58
file content (368 lines) | stat: -rw-r--r-- 10,319 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
package pagerduty

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/nicholas-fedor/shoutrrr/pkg/format"
	"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
	"github.com/nicholas-fedor/shoutrrr/pkg/types"
)

const (
	eventEndpointTemplate = "https://%s:%d/v2/enqueue"
	defaultHTTPTimeout    = 30 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests.
	maxMessageLength      = 1024             // maxMessageLength is the maximum permitted length of the summary property.

	contextTypeLink  = "link"
	contextTypeImage = "image"
)

// Service provides PagerDuty as a notification service.
type Service struct {
	standard.Standard
	Config     *Config
	pkr        format.PropKeyResolver
	httpClient *http.Client
}

// SetHTTPClient allows users to provide a custom HTTP client for enterprise environments
// requiring proxies, custom TLS configurations, etc.
func (service *Service) SetHTTPClient(client *http.Client) {
	service.httpClient = client
}

// sendAlert sends an alert payload to the specified PagerDuty endpoint URL.
func (service *Service) sendAlert(ctx context.Context, url string, payload EventPayload) error {
	// Marshal the payload into JSON format
	jsonBody, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("failed to marshal payload: %w", err)
	}

	jsonBuffer := bytes.NewBuffer(jsonBody)

	// Create a new HTTP POST request with the JSON body
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, jsonBuffer)
	if err != nil {
		return fmt.Errorf("failed to create HTTP request: %w", err)
	}

	// Set the Content-Type header to application/json
	req.Header.Add("Content-Type", "application/json")

	// Use the custom HTTP client
	if service.httpClient == nil {
		return errServiceNotInitialized
	}

	// Send the HTTP request to PagerDuty
	resp, err := service.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("failed to send notification to PagerDuty: %w", err)
	}
	defer resp.Body.Close()

	// Check if the response status indicates success (2xx)
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// Parse error response body for better error reporting
		errorMsg := fmt.Sprintf("HTTP %d", resp.StatusCode)
		if resp.Body != nil {
			bodyBytes, err := io.ReadAll(resp.Body)
			if err == nil && len(bodyBytes) > 0 {
				// Try to parse as PagerDuty error response
				var errorResponse struct {
					Status  string   `json:"status"`
					Message string   `json:"message"`
					Error   string   `json:"error"`
					Errors  []string `json:"errors"`
				}
				if jsonErr := json.Unmarshal(bodyBytes, &errorResponse); jsonErr == nil {
					switch {
					case errorResponse.Message != "":
						errorMsg = errorResponse.Message
					case errorResponse.Error != "":
						errorMsg = errorResponse.Error
					case len(errorResponse.Errors) > 0:
						errorMsg = strings.Join(errorResponse.Errors, "; ")
					}
				} else {
					// Fallback to raw body if JSON parsing fails
					errorMsg = string(bodyBytes)
				}
			}
		}

		return fmt.Errorf("%w: %s", errPagerDutyNotificationFailed, errorMsg)
	}

	return nil
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
	service.SetLogger(logger)
	service.Config = &Config{}
	service.pkr = format.NewPropKeyResolver(service.Config)

	if err := service.setDefaults(); err != nil {
		return err
	}

	if err := service.Config.setURL(&service.pkr, configURL); err != nil {
		return err
	}

	if service.httpClient == nil {
		// Initialize HTTP client with timeout
		service.httpClient = &http.Client{
			Timeout: defaultHTTPTimeout,
		}
	}

	return nil
}

// GetID returns the service identifier.
func (service *Service) GetID() string {
	return Scheme
}

// Send a notification message to PagerDuty
// See: https://developer.pagerduty.com/docs/events-api-v2-overview
func (service *Service) Send(message string, params *types.Params) error {
	return service.SendWithContext(context.Background(), message, params)
}

// SendWithContext sends a notification message to PagerDuty with context support
// See: https://developer.pagerduty.com/docs/events-api-v2-overview
func (service *Service) SendWithContext(
	ctx context.Context,
	message string,
	params *types.Params,
) error {
	config := service.Config
	endpointURL := fmt.Sprintf(eventEndpointTemplate, config.Host, config.Port)

	payload, err := service.newEventPayload(message, params)
	if err != nil {
		return err
	}

	return service.sendAlert(ctx, endpointURL, payload)
}

func (service *Service) newEventPayload(
	message string,
	params *types.Params,
) (EventPayload, error) {
	if params == nil {
		params = &types.Params{}
	}

	// Defensive copy
	payloadFields := *service.Config

	if err := service.pkr.UpdateConfigFromParams(&payloadFields, params); err != nil {
		return EventPayload{}, fmt.Errorf("failed to update config from params: %w", err)
	}

	// Validate severity
	if err := validateSeverity(payloadFields.Severity); err != nil {
		return EventPayload{}, err
	}

	// Validate event action
	if err := validateEventAction(payloadFields.Action); err != nil {
		return EventPayload{}, err
	}

	// The maximum permitted length of this property is 1024 characters.
	runes := []rune(message)
	if len(runes) > maxMessageLength {
		message = string(runes[:maxMessageLength])
	}

	result := EventPayload{
		Payload: Payload{
			Summary:  message,
			Severity: payloadFields.Severity,
			Source:   payloadFields.Source,
		},
		RoutingKey:  payloadFields.IntegrationKey,
		EventAction: payloadFields.Action,
	}

	// Add optional dedup_key if provided
	if payloadFields.DedupKey != "" {
		result.DedupKey = payloadFields.DedupKey
	}

	// Add optional fields if provided
	if payloadFields.Details != "" {
		var details any
		if err := json.Unmarshal([]byte(payloadFields.Details), &details); err != nil {
			return EventPayload{}, fmt.Errorf(
				"failed to unmarshal details %q: %w",
				payloadFields.Details,
				err,
			)
		}

		result.Details = details
	}

	if payloadFields.Client != "" {
		result.Client = payloadFields.Client
	}

	if payloadFields.ClientURL != "" {
		result.ClientURL = payloadFields.ClientURL
	}

	if payloadFields.Contexts != "" {
		contexts, err := parseContexts(payloadFields.Contexts)
		if err != nil {
			return EventPayload{}, fmt.Errorf("failed to parse contexts: %w", err)
		}

		result.Contexts = contexts
	}

	return result, nil
}

// validateSeverity checks if the provided severity is one of the allowed values.
func validateSeverity(severity string) error {
	validSeverities := map[string]bool{
		"critical": true,
		"error":    true,
		"warning":  true,
		"info":     true,
	}

	if !validSeverities[severity] {
		return errInvalidSeverity
	}

	return nil
}

// validateEventAction checks if the provided event action is one of the allowed values.
func validateEventAction(action string) error {
	validActions := map[string]bool{
		"trigger":     true,
		"acknowledge": true,
		"resolve":     true,
	}

	if !validActions[action] {
		return errInvalidEventAction
	}

	return nil
}

func (service *Service) setDefaults() error {
	if err := service.pkr.SetDefaultProps(service.Config); err != nil {
		return fmt.Errorf("failed to set default props: %w", err)
	}

	return nil
}

// parseContexts parses contexts from either a JSON array format or legacy comma-separated string format.
// It first attempts to unmarshal the input as a JSON array of PagerDutyContext objects.
// If JSON unmarshaling fails, it falls back to parsing the legacy string format like "type:src,type2:src2".
// Legacy format supports:
// - "link:http://example.com" -> {Type: "link", Href: "http://example.com"}
// - "image:http://example.com/img.png" -> {Type: "image", Src: "http://example.com/img.png"}.
func parseContexts(contextsStr string) ([]PagerDutyContext, error) {
	if contextsStr == "" {
		return nil, nil
	}

	// First, attempt to parse as JSON array
	var result []PagerDutyContext //nolint:prealloc // length is unknown for JSON case
	if err := json.Unmarshal([]byte(contextsStr), &result); err == nil {
		// Validate Type field and required fields for each context in JSON format
		for _, ctx := range result {
			if ctx.Type != contextTypeLink && ctx.Type != contextTypeImage {
				return nil, fmt.Errorf("%w: found %q", errInvalidContextType, ctx.Type)
			}

			if ctx.Type == contextTypeLink && ctx.Href == "" {
				return nil, fmt.Errorf("%w: %+v", errMissingHrefForLinkContext, ctx)
			}

			if ctx.Type == contextTypeImage && ctx.Src == "" {
				return nil, fmt.Errorf("%w: %+v", errMissingSrcForImageContext, ctx)
			}
		}

		return result, nil
	}

	// Fall back to legacy comma-separated parsing
	// Split the input string by commas to get individual context entries
	contexts := strings.Split(contextsStr, ",")
	result = make([]PagerDutyContext, 0, len(contexts))

	for _, ctx := range contexts {
		// Trim whitespace from each context entry
		ctx = strings.TrimSpace(ctx)
		if ctx == "" {
			continue
		}

		const expectedParts = 2

		// Split each context by colon to separate type and value, limiting to 2 parts
		parts := strings.SplitN(ctx, ":", expectedParts)
		if len(parts) != expectedParts {
			return nil, fmt.Errorf("%w: %q", errInvalidContextFormat, ctx)
		}

		// Trim whitespace from type and value parts
		contextType := strings.TrimSpace(parts[0])
		value := strings.TrimSpace(parts[1])

		// Validate that neither type nor value is empty after trimming
		if contextType == "" || value == "" {
			return nil, fmt.Errorf("%w: %q", errEmptyContextTypeOrValue, ctx)
		}

		var context PagerDutyContext

		// Map context types to appropriate PagerDutyContext fields
		switch contextType {
		case "link":
			// Create a link context with href
			context = PagerDutyContext{Type: "link", Href: value}
		case "image":
			// Create an image context with src
			context = PagerDutyContext{Type: "image", Src: value}
		case "text":
			// Skip text contexts
			continue
		default:
			return nil, fmt.Errorf(
				"%w: unsupported context type %q",
				errInvalidContextFormat,
				contextType,
			)
		}

		// Add the parsed context to the result slice
		result = append(result, context)
	}

	return result, nil
}