diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts index ea0597933618461..b717436c5477777 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts @@ -10,10 +10,12 @@ import { actionType as serverLogActionType } from './server_log'; import { actionType as slackActionType } from './slack'; import { actionType as emailActionType } from './email'; import { actionType as indexActionType } from './es_index'; +import { actionType as pagerDutyActionType } from './pagerduty'; export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { actionTypeRegistry.register(serverLogActionType); actionTypeRegistry.register(slackActionType); actionTypeRegistry.register(emailActionType); actionTypeRegistry.register(indexActionType); + actionTypeRegistry.register(pagerDutyActionType); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts new file mode 100644 index 000000000000000..16ec3f3737681bf --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionType, Services } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; +import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; +import { validateConfig, validateSecrets, validateParams } from '../lib'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; + +import { registerBuiltInActionTypes } from './index'; + +const ACTION_TYPE_ID = '.pagerduty'; +const NO_OP_FN = () => {}; + +const services: Services = { + log: NO_OP_FN, + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices(): Services { + return services; +} + +let actionTypeRegistry: ActionTypeRegistry; + +const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }); + registerBuiltInActionTypes(actionTypeRegistry); +}); + +beforeEach(() => { + services.log = NO_OP_FN; +}); + +describe('action is registered', () => { + test('gets registered with builtin actions', () => { + expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('pagerduty'); + }); +}); + +describe('validateConfig()', () => { + let actionType: ActionType; + + beforeAll(() => { + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(actionType).toBeTruthy(); + }); + + test('should validate and pass when config is valid', () => { + expect(validateConfig(actionType, {})).toEqual({ apiUrl: null }); + expect(validateConfig(actionType, { apiUrl: 'bar' })).toEqual({ apiUrl: 'bar' }); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [shouldNotBeHere]: definition for this key is missing"` + ); + expect(() => { + validateConfig(actionType, { apiUrl: false }); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action type config: [apiUrl]: types that failed validation: +- [apiUrl.0]: expected value of type [string] but got [boolean] +- [apiUrl.1]: expected value to equal [null] but got [false]" +`); + }); +}); + +describe('validateSecrets()', () => { + let actionType: ActionType; + + beforeAll(() => { + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(actionType).toBeTruthy(); + }); + + test('should validate and pass when secrets is valid', () => { + const routingKey = '0123456789ABCDEF0123456789ABCDEF'; + expect(validateSecrets(actionType, { routingKey })).toEqual({ + routingKey, + }); + }); + + test('should validate and throw error when secrets is invalid', () => { + const routingKey = '123456789ABCDEF0123456789ABCDEF'; // 31 chars! + expect(() => { + validateSecrets(actionType, { routingKey }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [routingKey]: value is [123456789ABCDEF0123456789ABCDEF] but it must have a minimum length of [32]."` + ); + + expect(() => { + validateSecrets(actionType, { routingKey: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [routingKey]: expected value of type [string] but got [boolean]"` + ); + + expect(() => { + validateSecrets(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [routingKey]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('validateParams()', () => { + let actionType: ActionType; + + beforeAll(() => { + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(actionType).toBeTruthy(); + }); + + test('should validate and pass when params is valid', () => { + expect(validateParams(actionType, {})).toEqual({ + eventAction: 'trigger', + }); + + const params = { + eventAction: 'trigger', + dedupKey: 'a dedupKey', + summary: 'a summary', + source: 'a source', + severity: 'critical', + timestamp: new Date().toISOString(), + component: 'a component', + group: 'a group', + class: 'a class', + }; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, { eventAction: 'ackynollage' }); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action params: [eventAction]: types that failed validation: +- [eventAction.0]: expected value to equal [trigger] but got [ackynollage] +- [eventAction.1]: expected value to equal [resolve] but got [ackynollage] +- [eventAction.2]: expected value to equal [acknowledge] but got [ackynollage]" +`); + }); +}); + +describe('execute()', () => { + test('calls the executor with proper params', async () => { + expect('TBD').toEqual('TBD'); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts new file mode 100644 index 000000000000000..766ba55feed4cf8 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; + +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; + +// uses the PagerDuty Events API v2 +// https://v2.developer.pagerduty.com/docs/events-api-v2 +const PAGER_DUTY_API_URL = 'https://events.pagerduty.com/v2/enqueue'; + +// config definition + +export type ActionTypeConfigType = TypeOf; + +const ConfigSchema = schema.object({ + apiUrl: schema.nullable(schema.string()), +}); + +// secrets definition + +export type ActionTypeSecretsType = TypeOf; + +const SecretsSchema = schema.object({ + routingKey: schema.string({ minLength: 32, maxLength: 32 }), +}); + +// params definition + +export type ActionParamsType = TypeOf; + +const EVENT_ACTION_TRIGGER = 'trigger'; +const EVENT_ACTION_RESOLVE = 'resolve'; +const EVENT_ACTION_ACKNOWLEDGE = 'acknowledge'; + +const EventActionSchema = schema.oneOf( + [ + schema.literal(EVENT_ACTION_TRIGGER), + schema.literal(EVENT_ACTION_RESOLVE), + schema.literal(EVENT_ACTION_ACKNOWLEDGE), + ], + { + defaultValue: EVENT_ACTION_TRIGGER, + } +); + +const PayloadSeveritySchema = schema.oneOf([ + schema.literal('critical'), + schema.literal('error'), + schema.literal('warning'), + schema.literal('info'), +]); + +const ParamsSchema = schema.object( + { + eventAction: EventActionSchema, + dedupKey: schema.maybe(schema.string({ maxLength: 255 })), + summary: schema.maybe(schema.string({ maxLength: 1024 })), + source: schema.maybe(schema.string()), + severity: schema.maybe(PayloadSeveritySchema), + timestamp: schema.maybe(schema.string()), + component: schema.maybe(schema.string()), + group: schema.maybe(schema.string()), + class: schema.maybe(schema.string()), + }, + { validate: validateParams } +); + +function validateParams(paramsObject: any): string | void { + const params: ActionParamsType = paramsObject; + + const { timestamp } = params; + if (timestamp != null) { + let date; + try { + date = Date.parse(timestamp); + } catch (err) { + return 'error parsing timestamp: ${err.message}'; + } + + if (isNaN(date)) { + return 'error parsing timestamp'; + } + } +} + +// action type definition + +export const actionType: ActionType = { + id: '.pagerduty', + name: 'pagerduty', + validate: { + config: ConfigSchema, + secrets: SecretsSchema, + params: ParamsSchema, + }, + executor, +}; + +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; + const config = execOptions.config as ActionTypeConfigType; + const secrets = execOptions.secrets as ActionTypeSecretsType; + const params = execOptions.params as ActionParamsType; + const services = execOptions.services; + + const apiUrl = trimTrailingSlashes(config.apiUrl || PAGER_DUTY_API_URL); + const options = { + headers: { + 'Content-Type': 'application/json', + 'X-Routing-Key': secrets.routingKey, + }, + }; + const data = getBodyForEventAction(id, params.eventAction, params); + + let response; + try { + response = await axios.post(apiUrl, data, options); + } catch (err) { + const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', { + defaultMessage: 'error in action "{id}" posting pagerduty event: {errorMessage}', + values: { + id, + errorMessage: err.message, + }, + }); + services.log(['warn', 'actions', 'pagerduty'], `error posting pagerduty event: ${err.message}`); + return { + status: 'error', + message, + }; + } + + services.log( + ['debug', 'actions', 'pagerduty'], + `response posting pagerduty event: ${response.status}` + ); + + if (response.status === 202) { + return { + status: 'ok', + data: response.data, + }; + } + + if (response.status === 400) { + const message = (response.data && response.data.message) || 'unspecified error from pagerduty'; + + return { + status: 'error', + message, + }; + } + + if (response.status === 429) { + return { + status: 'error', + message: `an error occurred in action ${id} sending a pagerduty event, retry later`, + retry: true, + }; + } + + if (response.status >= 500) { + return { + status: 'error', + message: `an http error ${response.status} occurred in action ${id} sending a pagerduty event, retry later`, + retry: true, + }; + } + + const message = 'unexpected error from pagerduty'; + return { + status: 'error', + message, + data: response.data, + }; +} + +// utilities + +const AcknowledgeOrResolve = new Set([EVENT_ACTION_ACKNOWLEDGE, EVENT_ACTION_RESOLVE]); + +function getBodyForEventAction( + actionId: string, + eventAction: string, + params: ActionParamsType +): any { + const dedupKey = params.dedupKey || `action:${actionId}`; + + const data: any = { + event_action: eventAction, + dedup_key: dedupKey, + }; + + // for acknowledge / resolve, just send the dedup key + if (AcknowledgeOrResolve.has(eventAction)) { + return data; + } + + data.payload = { + summary: params.summary || 'No summary provided.', + source: params.source || `Kibana Action ${actionId}`, + severity: params.severity || 'info', + }; + + if (params.timestamp != null) data.payload.timestamp = params.timestamp; + if (params.component != null) data.payload.component = params.component; + if (params.group != null) data.payload.group = params.group; + if (params.class != null) data.payload.class = params.class; + + return data; +} + +function trimTrailingSlashes(url: string): string { + if (url == null) return url; + return url.replace(/\/+$/, ''); +}