Adds a built-in PagerDuty action (#43395)
The PagerDuty action can be used to post events via the PagerDuty Events API v2: https://v2.developer.pagerduty.com/docs/events-api-v2
This commit is contained in:
parent
70546628de
commit
dedfd62717
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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, { AxiosResponse } from 'axios';
|
||||
import { Services } from '../../types';
|
||||
|
||||
interface PostPagerdutyOptions {
|
||||
apiUrl: string;
|
||||
data: any;
|
||||
headers: Record<string, string>;
|
||||
services: Services;
|
||||
}
|
||||
|
||||
// post an event to pagerduty
|
||||
export async function postPagerduty(options: PostPagerdutyOptions): Promise<AxiosResponse> {
|
||||
const { apiUrl, data, headers } = options;
|
||||
const axiosOptions = {
|
||||
headers,
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
return axios.post(apiUrl, data, axiosOptions);
|
||||
}
|
|
@ -0,0 +1,419 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('./lib/post_pagerduty', () => ({
|
||||
postPagerduty: jest.fn(),
|
||||
}));
|
||||
|
||||
import { ActionType, Services, ActionTypeExecutorOptions } 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 { postPagerduty } from './lib/post_pagerduty';
|
||||
import { registerBuiltInActionTypes } from './index';
|
||||
|
||||
const postPagerdutyMock = postPagerduty as jest.Mock;
|
||||
|
||||
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 actionType: ActionType;
|
||||
let actionTypeRegistry: ActionTypeRegistry;
|
||||
|
||||
const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create();
|
||||
|
||||
beforeAll(() => {
|
||||
actionTypeRegistry = new ActionTypeRegistry({
|
||||
getServices,
|
||||
isSecurityEnabled: true,
|
||||
taskManager: taskManagerMock.create(),
|
||||
encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin,
|
||||
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
|
||||
getBasePath: jest.fn().mockReturnValue(undefined),
|
||||
});
|
||||
registerBuiltInActionTypes(actionTypeRegistry);
|
||||
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
services.log = NO_OP_FN;
|
||||
});
|
||||
|
||||
describe('action registation', () => {
|
||||
test('should be successful', () => {
|
||||
expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
test('should return correct action type', () => {
|
||||
expect(actionType.id).toEqual(ACTION_TYPE_ID);
|
||||
expect(actionType.name).toEqual('pagerduty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig()', () => {
|
||||
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"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSecrets()', () => {
|
||||
test('should validate and pass when secrets is valid', () => {
|
||||
const routingKey = 'super-secret';
|
||||
expect(validateSecrets(actionType, { routingKey })).toEqual({
|
||||
routingKey,
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate and throw error when secrets is invalid', () => {
|
||||
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()', () => {
|
||||
test('should validate and pass when params is valid', () => {
|
||||
expect(validateParams(actionType, {})).toEqual({});
|
||||
|
||||
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()', () => {
|
||||
beforeEach(() => {
|
||||
postPagerdutyMock.mockReset();
|
||||
});
|
||||
|
||||
test('should succeed with minimal valid params', async () => {
|
||||
const secrets = { routingKey: 'super-secret' };
|
||||
const config = {};
|
||||
const params = {};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 202, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0];
|
||||
expect({ apiUrl, data, headers }).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"apiUrl": "https://events.pagerduty.com/v2/enqueue",
|
||||
"data": Object {
|
||||
"dedup_key": "action:some-action-id",
|
||||
"event_action": "trigger",
|
||||
"payload": Object {
|
||||
"severity": "info",
|
||||
"source": "Kibana Action some-action-id",
|
||||
"summary": "No summary provided.",
|
||||
},
|
||||
},
|
||||
"headers": Object {
|
||||
"Content-Type": "application/json",
|
||||
"X-Routing-Key": "super-secret",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "data-here",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should succeed with maximal valid params for trigger', async () => {
|
||||
const randoDate = new Date('1963-09-23T01:23:45Z').toISOString();
|
||||
const secrets = {
|
||||
routingKey: 'super-secret',
|
||||
};
|
||||
const config = {
|
||||
apiUrl: 'the-api-url',
|
||||
};
|
||||
const params = {
|
||||
eventAction: 'trigger',
|
||||
dedupKey: 'a-dedup-key',
|
||||
summary: 'the summary',
|
||||
source: 'the-source',
|
||||
severity: 'critical',
|
||||
timestamp: randoDate,
|
||||
component: 'the-component',
|
||||
group: 'the-group',
|
||||
class: 'the-class',
|
||||
};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 202, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0];
|
||||
expect({ apiUrl, data, headers }).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"apiUrl": "the-api-url",
|
||||
"data": Object {
|
||||
"dedup_key": "a-dedup-key",
|
||||
"event_action": "trigger",
|
||||
"payload": Object {
|
||||
"class": "the-class",
|
||||
"component": "the-component",
|
||||
"group": "the-group",
|
||||
"severity": "critical",
|
||||
"source": "the-source",
|
||||
"summary": "the summary",
|
||||
"timestamp": "1963-09-23T01:23:45.000Z",
|
||||
},
|
||||
},
|
||||
"headers": Object {
|
||||
"Content-Type": "application/json",
|
||||
"X-Routing-Key": "super-secret",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "data-here",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should succeed with maximal valid params for acknowledge', async () => {
|
||||
const randoDate = new Date('1963-09-23T01:23:45Z').toISOString();
|
||||
const secrets = {
|
||||
routingKey: 'super-secret',
|
||||
};
|
||||
const config = {
|
||||
apiUrl: 'the-api-url',
|
||||
};
|
||||
const params = {
|
||||
eventAction: 'acknowledge',
|
||||
dedupKey: 'a-dedup-key',
|
||||
summary: 'the summary',
|
||||
source: 'the-source',
|
||||
severity: 'critical',
|
||||
timestamp: randoDate,
|
||||
component: 'the-component',
|
||||
group: 'the-group',
|
||||
class: 'the-class',
|
||||
};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 202, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0];
|
||||
expect({ apiUrl, data, headers }).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"apiUrl": "the-api-url",
|
||||
"data": Object {
|
||||
"dedup_key": "a-dedup-key",
|
||||
"event_action": "acknowledge",
|
||||
},
|
||||
"headers": Object {
|
||||
"Content-Type": "application/json",
|
||||
"X-Routing-Key": "super-secret",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "data-here",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should succeed with maximal valid params for resolve', async () => {
|
||||
const randoDate = new Date('1963-09-23T01:23:45Z').toISOString();
|
||||
const secrets = {
|
||||
routingKey: 'super-secret',
|
||||
};
|
||||
const config = {
|
||||
apiUrl: 'the-api-url',
|
||||
};
|
||||
const params = {
|
||||
eventAction: 'resolve',
|
||||
dedupKey: 'a-dedup-key',
|
||||
summary: 'the summary',
|
||||
source: 'the-source',
|
||||
severity: 'critical',
|
||||
timestamp: randoDate,
|
||||
component: 'the-component',
|
||||
group: 'the-group',
|
||||
class: 'the-class',
|
||||
};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 202, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0];
|
||||
expect({ apiUrl, data, headers }).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"apiUrl": "the-api-url",
|
||||
"data": Object {
|
||||
"dedup_key": "a-dedup-key",
|
||||
"event_action": "resolve",
|
||||
},
|
||||
"headers": Object {
|
||||
"Content-Type": "application/json",
|
||||
"X-Routing-Key": "super-secret",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "data-here",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should fail when sendPagerdury throws', async () => {
|
||||
const secrets = { routingKey: 'super-secret' };
|
||||
const config = {};
|
||||
const params = {};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
throw new Error('doing some testing');
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "error in pagerduty action \\"some-action-id\\" posting event: doing some testing",
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should fail when sendPagerdury returns 429', async () => {
|
||||
const secrets = { routingKey: 'super-secret' };
|
||||
const config = {};
|
||||
const params = {};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 429, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "error in pagerduty action \\"some-action-id\\" posting event: status 429, retry later",
|
||||
"retry": true,
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should fail when sendPagerdury returns 501', async () => {
|
||||
const secrets = { routingKey: 'super-secret' };
|
||||
const config = {};
|
||||
const params = {};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 501, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "error in pagerduty action \\"some-action-id\\" posting event: status 501, retry later",
|
||||
"retry": true,
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('should fail when sendPagerdury returns 418', async () => {
|
||||
const secrets = { routingKey: 'super-secret' };
|
||||
const config = {};
|
||||
const params = {};
|
||||
|
||||
postPagerdutyMock.mockImplementation(() => {
|
||||
return { status: 418, data: 'data-here' };
|
||||
});
|
||||
|
||||
const id = 'some-action-id';
|
||||
const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services };
|
||||
const actionResponse = await actionType.executor(executorOptions);
|
||||
expect(actionResponse).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "error in pagerduty action \\"some-action-id\\" posting event: unexpected status 418",
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { postPagerduty } from './lib/post_pagerduty';
|
||||
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<typeof ConfigSchema>;
|
||||
|
||||
const ConfigSchema = schema.object({
|
||||
apiUrl: schema.nullable(schema.string()),
|
||||
});
|
||||
|
||||
// secrets definition
|
||||
|
||||
export type ActionTypeSecretsType = TypeOf<typeof SecretsSchema>;
|
||||
|
||||
const SecretsSchema = schema.object({
|
||||
routingKey: schema.string(),
|
||||
});
|
||||
|
||||
// params definition
|
||||
|
||||
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
const PayloadSeveritySchema = schema.oneOf([
|
||||
schema.literal('critical'),
|
||||
schema.literal('error'),
|
||||
schema.literal('warning'),
|
||||
schema.literal('info'),
|
||||
]);
|
||||
|
||||
const ParamsSchema = schema.object(
|
||||
{
|
||||
eventAction: schema.maybe(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<ActionTypeExecutorResult> {
|
||||
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 = config.apiUrl || PAGER_DUTY_API_URL;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Routing-Key': secrets.routingKey,
|
||||
};
|
||||
const data = getBodyForEventAction(id, params);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await postPagerduty({ apiUrl, data, headers, services });
|
||||
} catch (err) {
|
||||
const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', {
|
||||
defaultMessage: 'error in pagerduty action "{id}" posting event: {errorMessage}',
|
||||
values: {
|
||||
id,
|
||||
errorMessage: err.message,
|
||||
},
|
||||
});
|
||||
services.log(
|
||||
['warn', 'actions', 'pagerduty'],
|
||||
`error thrown 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 === 429 || response.status >= 500) {
|
||||
const message = i18n.translate('xpack.actions.builtin.pagerduty.postingRetryErrorMessage', {
|
||||
defaultMessage:
|
||||
'error in pagerduty action "{id}" posting event: status {status}, retry later',
|
||||
values: {
|
||||
id,
|
||||
status: response.status,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message,
|
||||
retry: true,
|
||||
};
|
||||
}
|
||||
|
||||
const message = i18n.translate('xpack.actions.builtin.pagerduty.postingUnexpectedErrorMessage', {
|
||||
defaultMessage: 'error in pagerduty action "{id}" posting event: unexpected status {status}',
|
||||
values: {
|
||||
id,
|
||||
status: response.status,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// utilities
|
||||
|
||||
const AcknowledgeOrResolve = new Set([EVENT_ACTION_ACKNOWLEDGE, EVENT_ACTION_RESOLVE]);
|
||||
|
||||
function getBodyForEventAction(actionId: string, params: ActionParamsType): any {
|
||||
const eventAction = params.eventAction || EVENT_ACTION_TRIGGER;
|
||||
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;
|
||||
}
|
|
@ -1,56 +1,46 @@
|
|||
functional test server slack simulator
|
||||
================================================================================
|
||||
|
||||
The code in this directory will run a Slack HTTP simulator; it will return
|
||||
different responses based on the content of the text message sent to the
|
||||
endpoint.
|
||||
The code in this directory includes external service simulators for testing
|
||||
Kibana actions. The simulators are available when running the function test
|
||||
server.
|
||||
|
||||
This will be used during functional testing runner tests for actions; an
|
||||
action will be created pointing to the simulator, and then messages posted
|
||||
to test handling different error conditions.
|
||||
They are used during function testing for actions; an action will be created
|
||||
pointing to the simulator, and then messages posted to test handling different
|
||||
error conditions.
|
||||
|
||||
Generally, the simulator will generate specialized http responses based on
|
||||
some string property passed as input. Consult the simulators for more details.
|
||||
|
||||
what a Slack server returns
|
||||
simulator usage
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Here's some examples of `curl`'ing a Slack webhook to see the different
|
||||
responses it will return:
|
||||
This may get out of date, consult the code for exact urls and inputs. Each
|
||||
simulator's last path segment should be the name of the service (eg, slack,
|
||||
pagerduty, etc).
|
||||
|
||||
```console
|
||||
$ curl -v $SLACK_WEBHOOK_URL -d '{"text":"Hello, World!"}'
|
||||
< HTTP/2 200
|
||||
< content-type: text/html
|
||||
$ export SLACK_URL=http://elastic:changeme@localhost:5620/api/_actions-FTS-external-service-simulators/slack
|
||||
|
||||
$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"success"}'
|
||||
< HTTP/1.1 200 OK
|
||||
...
|
||||
ok
|
||||
|
||||
$ curl -v $SLACK_WEBHOOK_URL -d '{"txt":"Hello, World!"}'
|
||||
< HTTP/2 400
|
||||
< content-type: text/html
|
||||
no_text
|
||||
|
||||
$ curl -v $SLACK_WEBHOOK_URL -d '[]'
|
||||
< HTTP/2 400
|
||||
< content-type: text/html
|
||||
invalid_payload
|
||||
|
||||
|
||||
$ curl -v $SLACK_WEBHOOK_URL_LESS_ONE_CHAR -d '{"text":"Hello, World!"}'
|
||||
< HTTP/2 403
|
||||
< content-type: text/html
|
||||
invalid_token
|
||||
|
||||
$ curl -v $SLACK_WEBHOOK_URL -d '{"text":"rate limited yet?"}'
|
||||
< HTTP/2 429
|
||||
< content-type: application/json; charset=utf-8
|
||||
$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"rate_limit"}'
|
||||
< HTTP/1.1 429 Too Many Requests
|
||||
...
|
||||
< retry-after: 1
|
||||
<
|
||||
{"retry_after":1,"ok":false,"error":"rate_limited"}
|
||||
```
|
||||
|
||||
abuse a server
|
||||
bonus points: abuse a slack server
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
To get a rate limiting response, run this in one terminal window, and while
|
||||
that is running, run a normal curl command to post a message. You may need to
|
||||
try a few times.
|
||||
To get a rate limiting slack response, from a real slack server, to see what it
|
||||
looks like, run this in one terminal window, and while that is running, run a
|
||||
normal curl command to post a message. You may need to try a few times.
|
||||
|
||||
You should probably do this with a personal slack instance, not a company one :-)
|
||||
|
||||
|
@ -58,16 +48,3 @@ You should probably do this with a personal slack instance, not a company one :-
|
|||
$ autocannon --amount 10000 --method POST --body '{"text":"Hello, World!"}' $SLACK_WEBHOOK_URL
|
||||
```
|
||||
|
||||
simulator usage
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
These may get out of date, consult the code for exact urls and inputs:
|
||||
|
||||
```console
|
||||
$ export SLACK_URL=http://localhost:5620/api/_actions-FTS-external-service-simulators/slack
|
||||
|
||||
$ curl -v $SLACK_URL -H 'content-type: application/json' -d '{"text":"slack-success"}'
|
||||
< HTTP/1.1 200 OK
|
||||
< content-type: text/html; charset=utf-8
|
||||
ok
|
||||
```
|
|
@ -6,12 +6,14 @@
|
|||
import Hapi from 'hapi';
|
||||
import { initPlugin as initSlack } from './slack_simulation';
|
||||
import { initPlugin as initWebhook } from './webhook_simulation';
|
||||
import { initPlugin as initPagerduty } from './pagerduty_simulation';
|
||||
|
||||
const NAME = 'actions-FTS-external-service-simulators';
|
||||
|
||||
export enum ExternalServiceSimulator {
|
||||
SLACK = 'slack',
|
||||
WEBHOOK = 'webhook',
|
||||
PAGERDUTY = 'pagerduty',
|
||||
}
|
||||
|
||||
export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string {
|
||||
|
@ -32,6 +34,7 @@ export default function(kibana: any) {
|
|||
init: (server: Hapi.Server) => {
|
||||
initSlack(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK));
|
||||
initWebhook(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK));
|
||||
initPagerduty(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 Joi from 'joi';
|
||||
import Hapi from 'hapi';
|
||||
|
||||
interface PagerdutyRequest extends Hapi.Request {
|
||||
payload: {
|
||||
dedup_key: string;
|
||||
payload: {
|
||||
summary: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function initPlugin(server: Hapi.Server, path: string) {
|
||||
server.route({
|
||||
method: 'POST',
|
||||
path,
|
||||
options: {
|
||||
auth: false,
|
||||
validate: {
|
||||
options: { abortEarly: false },
|
||||
payload: Joi.object()
|
||||
.unknown(true)
|
||||
.keys({
|
||||
dedup_key: Joi.string(),
|
||||
payload: Joi.object()
|
||||
.unknown(true)
|
||||
.keys({
|
||||
summary: Joi.string(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: pagerdutyHandler,
|
||||
});
|
||||
}
|
||||
// Pagerduty simulator: create an action pointing here, and you can get
|
||||
// different responses based on the message posted. See the README.md for
|
||||
// more info.
|
||||
function pagerdutyHandler(request: PagerdutyRequest, h: any) {
|
||||
const body = request.payload;
|
||||
let dedupKey = body && body.dedup_key;
|
||||
const summary = body && body.payload && body.payload.summary;
|
||||
|
||||
if (dedupKey == null) {
|
||||
dedupKey = `kibana-ft-simulator-dedup-key-${new Date().toISOString()}`;
|
||||
}
|
||||
|
||||
switch (summary) {
|
||||
case 'respond-with-429':
|
||||
return jsonResponse(h, 429);
|
||||
case 'respond-with-502':
|
||||
return jsonResponse(h, 502);
|
||||
case 'respond-with-418':
|
||||
return jsonResponse(h, 418);
|
||||
}
|
||||
|
||||
return jsonResponse(h, 202, {
|
||||
status: 'success',
|
||||
message: 'Event processed',
|
||||
dedup_key: dedupKey,
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(h: any, code: number, object?: any) {
|
||||
if (object == null) {
|
||||
return h.response('').code(code);
|
||||
}
|
||||
|
||||
return h
|
||||
.response(JSON.stringify(object))
|
||||
.type('application/json')
|
||||
.code(code);
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from '../../../../common/fixtures/plugins/actions';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function pagerdutyTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('pagerduty action', () => {
|
||||
let simulatedActionId = '';
|
||||
let pagerdutySimulatorURL: string = '<could not determine kibana url>';
|
||||
|
||||
// need to wait for kibanaServer to settle ...
|
||||
before(() => {
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const kibanaUrl = kibanaServer.status && kibanaServer.status.kibanaServerUrl;
|
||||
pagerdutySimulatorURL = `${kibanaUrl}${getExternalServiceSimulatorPath(
|
||||
ExternalServiceSimulator.PAGERDUTY
|
||||
)}`;
|
||||
});
|
||||
|
||||
after(() => esArchiver.unload('empty_kibana'));
|
||||
|
||||
it('should return successfully when passed valid create parameters', async () => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
description: 'A pagerduty action',
|
||||
actionTypeId: '.pagerduty',
|
||||
secrets: {
|
||||
routingKey: 'pager-duty-routing-key',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(createdAction).to.eql({
|
||||
id: createdAction.id,
|
||||
description: 'A pagerduty action',
|
||||
actionTypeId: '.pagerduty',
|
||||
config: {
|
||||
apiUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(typeof createdAction.id).to.be('string');
|
||||
|
||||
const { body: fetchedAction } = await supertest
|
||||
.get(`/api/action/${createdAction.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(fetchedAction).to.eql({
|
||||
id: fetchedAction.id,
|
||||
description: 'A pagerduty action',
|
||||
actionTypeId: '.pagerduty',
|
||||
config: {
|
||||
apiUrl: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unsuccessfully when passed invalid create parameters', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
description: 'A pagerduty action',
|
||||
actionTypeId: '.pagerduty',
|
||||
secrets: {},
|
||||
})
|
||||
.expect(400)
|
||||
.then((resp: any) => {
|
||||
expect(resp.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [routingKey]: expected value of type [string] but got [undefined]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should create pagerduty simulator action successfully', async () => {
|
||||
const { body: createdSimulatedAction } = await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
description: 'A pagerduty simulator',
|
||||
actionTypeId: '.pagerduty',
|
||||
config: {
|
||||
apiUrl: pagerdutySimulatorURL,
|
||||
},
|
||||
secrets: {
|
||||
routingKey: 'pager-duty-routing-key',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
simulatedActionId = createdSimulatedAction.id;
|
||||
});
|
||||
|
||||
it('should handle executing with a simulated success', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
summary: 'just a test',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
expect(result).to.eql({
|
||||
status: 'ok',
|
||||
data: {
|
||||
dedup_key: `action:${simulatedActionId}`,
|
||||
message: 'Event processed',
|
||||
status: 'success',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a 40x pagerduty error', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
summary: 'respond-with-418',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
expect(result.status).to.equal('error');
|
||||
expect(result.message).to.match(
|
||||
/error in pagerduty action .+ posting event: unexpected status 418/
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a 429 pagerduty error', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
summary: 'respond-with-429',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.equal('error');
|
||||
expect(result.message).to.match(
|
||||
/error in pagerduty action .+ posting event: status 429, retry later/
|
||||
);
|
||||
expect(result.retry).to.equal(true);
|
||||
});
|
||||
|
||||
it('should handle a 500 pagerduty error', async () => {
|
||||
const { body: result } = await supertest
|
||||
.post(`/api/action/${simulatedActionId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
summary: 'respond-with-502',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(result.status).to.equal('error');
|
||||
expect(result.message).to.match(
|
||||
/error in pagerduty action .+ posting event: status 502, retry later/
|
||||
);
|
||||
expect(result.retry).to.equal(true);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -20,5 +20,6 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./builtin_action_types/slack'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/email'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/es_index'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/pagerduty'));
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue