From ec94f2893f493b1fbff0b861d195d18ef5839b90 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 24 Jul 2019 19:19:31 -0400 Subject: [PATCH] Adds index action as built-in action (#41592) (#41932) * Adds index action as built-in action * resolve PR comments - moved `nullableType` to a module so it can be reused across action types - changed index action param `body` to `documents`, and it's no longer an object or array, it's only an array - rename index action param `execution_time_field` to `executionTimeField` - remove index action param `doc_id` as it's no longer relevant (only useful in the single document story previously) --- x-pack/legacy/plugins/actions/README.md | 22 ++ .../server/builtin_action_types/email.test.ts | 3 +- .../server/builtin_action_types/email.ts | 10 +- .../builtin_action_types/es_index.test.ts | 328 ++++++++++++++++++ .../server/builtin_action_types/es_index.ts | 103 ++++++ .../server/builtin_action_types/index.ts | 2 + .../builtin_action_types/lib/nullable.ts | 13 + .../builtin_action_types/server_log.test.ts | 2 + .../server/builtin_action_types/server_log.ts | 3 +- .../server/builtin_action_types/slack.test.ts | 2 + .../server/builtin_action_types/slack.ts | 29 +- .../actions/server/lib/execute.test.ts | 17 +- .../plugins/actions/server/lib/execute.ts | 13 +- x-pack/legacy/plugins/actions/server/types.ts | 1 + .../actions/builtin_action_types/es_index.ts | 259 ++++++++++++++ .../actions/builtin_action_types/slack.ts | 6 +- .../api_integration/apis/actions/index.ts | 1 + 17 files changed, 783 insertions(+), 31 deletions(-) create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts create mode 100644 x-pack/test/api_integration/apis/actions/builtin_action_types/es_index.ts diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md index 0dca7d2e12f06..abfd0dbae0e99 100644 --- a/x-pack/legacy/plugins/actions/README.md +++ b/x-pack/legacy/plugins/actions/README.md @@ -175,6 +175,7 @@ Kibana ships with a set of built-in action types: - server log: logs messages to the Kibana log using `server.log()` - email: send an email - slack: post a message to a slack channel +- index: index document(s) into elasticsearch ## server log, action id: `.log` @@ -247,6 +248,27 @@ This action type interfaces with the [Slack Incoming Webhooks feature](https://a |---|---|---| |message|the message text|string| + +## index, action id: `.index` + +The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elastic-stack-overview/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. + +#### config properties + +|Property|Description|Type| +|---|---|---| +|index|The Elasticsearch index to index into.|string _(optional)_| + +#### params properties + +|Property|Description|Type| +|---|---|---| +|index|The Elasticsearch index to index into.|string _(optional)_| +|doc_id|The optional _id of the document.|string _(optional)_| +|execution_time_field|The field that will store/index the action execution time.|string _(optional)_| +|refresh|Setting of the refresh policy for the write request|boolean _(optional)_| +|body|The documument body/bodies to index.|object or object[]| + # Command Line Utility The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 5bfcb8d211ddb..8f50f21f590b7 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -185,7 +185,8 @@ describe('execute()', () => { message: 'a message to you', }; - const executorOptions: ActionTypeExecutorOptions = { config, params, services }; + const id = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, services }; sendEmailMock.mockReset(); await actionType.executor(executorOptions); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index 7d8df0e35314b..8cc4e8215fde2 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf, Type } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerServices from 'nodemailer/lib/well-known/services.json'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; +import { nullableType } from './lib/nullable'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; const PORT_MAX = 256 * 256 - 1; -function nullableType(type: Type) { - return schema.oneOf([type, schema.literal(null)], { defaultValue: () => null }); -} - // config definition const unencryptedConfigProperties = ['service', 'host', 'port', 'secure', 'from']; @@ -108,6 +105,7 @@ export const actionType: ActionType = { // action executor async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; const config = execOptions.config as ActionTypeConfigType; const params = execOptions.params as ActionParamsType; const services = execOptions.services; @@ -146,7 +144,7 @@ async function executor(execOptions: ActionTypeExecutorOptions): Promise ({ + sendEmail: jest.fn(), +})); + +import { ActionType, ActionTypeExecutorOptions } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; +import { validateActionTypeConfig, validateActionTypeParams } from '../lib'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { registerBuiltInActionTypes } from './index'; +import { ActionParamsType, ActionTypeConfigType } from './es_index'; + +const ACTION_TYPE_ID = '.index'; +const NO_OP_FN = () => {}; + +const services = { + log: NO_OP_FN, + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices() { + return services; +} + +let actionTypeRegistry: ActionTypeRegistry; +let actionType: ActionType; + +const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + }); + + registerBuiltInActionTypes(actionTypeRegistry); + + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('action is registered', () => { + test('gets registered with builtin actions', () => { + expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true); + }); +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('index'); + }); +}); + +describe('config validation', () => { + test('config validation succeeds when config is valid', () => { + const config: Record = {}; + + expect(validateActionTypeConfig(actionType, config)).toEqual({ + ...config, + index: null, + }); + + config.index = 'testing-123'; + expect(validateActionTypeConfig(actionType, config)).toEqual({ + ...config, + index: 'testing-123', + }); + }); + + test('config validation fails when config is not valid', () => { + const baseConfig: Record = { + indeX: 'bob', + }; + + expect(() => { + validateActionTypeConfig(actionType, baseConfig); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionTypeConfig is invalid: [indeX]: definition for this key is missing"` + ); + + delete baseConfig.user; + baseConfig.index = 666; + + expect(() => { + validateActionTypeConfig(actionType, baseConfig); + }).toThrowErrorMatchingInlineSnapshot(` +"The actionTypeConfig is invalid: [index]: types that failed validation: +- [index.0]: expected value of type [string] but got [number] +- [index.1]: expected value to equal [null] but got [666]" +`); + }); +}); + +describe('params validation', () => { + test('params validation succeeds when params is valid', () => { + const params: Record = { + index: 'testing-123', + executionTimeField: 'field-used-for-time', + refresh: true, + documents: [{ rando: 'thing' }], + }; + expect(validateActionTypeParams(actionType, params)).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "rando": "thing", + }, + ], + "executionTimeField": "field-used-for-time", + "index": "testing-123", + "refresh": true, + } + `); + + delete params.index; + delete params.refresh; + delete params.executionTimeField; + expect(validateActionTypeParams(actionType, params)).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "rando": "thing", + }, + ], + } + `); + }); + + test('params validation fails when params is not valid', () => { + expect(() => { + validateActionTypeParams(actionType, { documents: [{}], jim: 'bob' }); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [jim]: definition for this key is missing"` + ); + + expect(() => { + validateActionTypeParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [documents]: expected value of type [array] but got [undefined]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { index: 666 }); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [index]: expected value of type [string] but got [number]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { executionTimeField: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [executionTimeField]: expected value of type [string] but got [boolean]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { refresh: 'true' }); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [refresh]: expected value of type [boolean] but got [string]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { documents: ['should be an object'] }); + }).toThrowErrorMatchingInlineSnapshot( + `"The actionParams is invalid: [documents.0]: expected value of type [object] but got [string]"` + ); + }); +}); + +describe('execute()', () => { + test('ensure parameters are as expected', async () => { + let config: ActionTypeConfigType; + let params: ActionParamsType; + let executorOptions: ActionTypeExecutorOptions; + + // minimal params, index via param + config = { index: null }; + params = { + index: 'index-via-param', + documents: [{ jim: 'bob' }], + executionTimeField: undefined, + refresh: undefined, + }; + + const id = 'some-id'; + + executorOptions = { id, config, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jim": "bob", + }, + ], + "index": "index-via-param", + }, + ], + ] + `); + + // full params (except index), index via config + config = { index: 'index-via-config' }; + params = { + index: undefined, + documents: [{ jimbob: 'jr' }], + executionTimeField: 'field_to_use_for_time', + refresh: true, + }; + + executorOptions = { id, config, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + const calls = services.callCluster.mock.calls; + const timeValue = calls[0][1].body[1].field_to_use_for_time; + expect(timeValue).toBeInstanceOf(Date); + delete calls[0][1].body[1].field_to_use_for_time; + expect(calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jimbob": "jr", + }, + ], + "index": "index-via-config", + "refresh": true, + }, + ], + ] + `); + + // minimal params, index via config and param + config = { index: 'index-via-config' }; + params = { + index: 'index-via-param', + documents: [{ jim: 'bob' }], + executionTimeField: undefined, + refresh: undefined, + }; + + executorOptions = { id, config, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jim": "bob", + }, + ], + "index": "index-via-config", + }, + ], + ] + `); + + // multiple documents + config = { index: null }; + params = { + index: 'index-via-param', + documents: [{ a: 1 }, { b: 2 }], + executionTimeField: undefined, + refresh: undefined, + }; + + executorOptions = { id, config, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "a": 1, + }, + Object { + "index": Object {}, + }, + Object { + "b": 2, + }, + ], + "index": "index-via-param", + }, + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts new file mode 100644 index 0000000000000..5e456121cbfd3 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -0,0 +1,103 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +import { nullableType } from './lib/nullable'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; + +// config definition + +const unencryptedConfigProperties: string[] = ['index']; + +export type ActionTypeConfigType = TypeOf; + +const ConfigSchema = schema.object({ + index: nullableType(schema.string()), +}); + +// params definition + +export type ActionParamsType = TypeOf; + +// see: https://www.elastic.co/guide/en/elastic-stack-overview/current/actions-index.html +// - timeout not added here, as this seems to be a generic thing we want to do +// eventually: https://github.com/elastic/kibana/projects/26#card-24087404 +const ParamsSchema = schema.object({ + index: schema.maybe(schema.string()), + executionTimeField: schema.maybe(schema.string()), + refresh: schema.maybe(schema.boolean()), + documents: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), +}); + +// action type definition + +export const actionType: ActionType = { + id: '.index', + name: 'index', + unencryptedAttributes: unencryptedConfigProperties, + validate: { + config: ConfigSchema, + params: ParamsSchema, + }, + executor, +}; + +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; + const config = execOptions.config as ActionTypeConfigType; + const params = execOptions.params as ActionParamsType; + const services = execOptions.services; + + if (config.index == null && params.index == null) { + return { + status: 'error', + message: `index param needs to be set because not set in config for action ${id}`, + }; + } + + if (config.index != null && params.index != null) { + services.log( + ['debug', 'actions'], + `index passed in params overridden by index set in config for action ${id}` + ); + } + + const index = config.index || params.index; + + const bulkBody = []; + for (const document of params.documents) { + if (params.executionTimeField != null) { + document[params.executionTimeField] = new Date(); + } + + bulkBody.push({ index: {} }); + bulkBody.push(document); + } + + const bulkParams: any = { + index, + body: bulkBody, + }; + + if (params.refresh != null) { + bulkParams.refresh = params.refresh; + } + + let result; + try { + result = await services.callCluster('bulk', bulkParams); + } catch (err) { + return { + status: 'error', + message: `error in action ${id} indexing documents: ${err.message}`, + }; + } + + return { status: 'ok', data: result }; +} 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 1b59a6438120f..ea05979336184 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 @@ -9,9 +9,11 @@ import { ActionTypeRegistry } from '../action_type_registry'; 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'; export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { actionTypeRegistry.register(serverLogActionType); actionTypeRegistry.register(slackActionType); actionTypeRegistry.register(emailActionType); + actionTypeRegistry.register(indexActionType); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts new file mode 100644 index 0000000000000..e2ea1005ca181 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts @@ -0,0 +1,13 @@ +/* + * 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 { schema, Type } from '@kbn/config-schema'; + +// TODO: remove once this is merged: https://github.com/elastic/kibana/pull/41728 + +export function nullableType(type: Type) { + return schema.oneOf([type, schema.literal(null)], { defaultValue: () => null }); +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index 83f5cf06b7957..18a9460e68fb5 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -114,7 +114,9 @@ describe('execute()', () => { services.log = mockLog; const actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + const id = 'some-id'; await actionType.executor({ + id, services: { log: mockLog, callCluster: async (path: string, opts: any) => {}, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 426dbb606744c..6a41e5404aa15 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -41,6 +41,7 @@ export const actionType: ActionType = { // action executor async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; const params = execOptions.params as ActionParamsType; const services = execOptions.services; @@ -49,7 +50,7 @@ async function executor(execOptions: ActionTypeExecutorOptions): Promise { describe('execute()', () => { test('calls the mock executor with success', async () => { const response = await actionType.executor({ + id: 'some-id', services, config: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, @@ -135,6 +136,7 @@ Object { test('calls the mock executor with failure', async () => { await expect( actionType.executor({ + id: 'some-id', services, config: { webhookUrl: 'http://example.com' }, params: { message: 'failure: this invocation should fail' }, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index e077c06305bda..8b3b0144f57bc 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -58,6 +58,7 @@ export const actionType = getActionType(); async function slackExecutor( execOptions: ActionTypeExecutorOptions ): Promise { + const id = execOptions.id; const config = execOptions.config as ActionTypeConfigType; const params = execOptions.params as ActionParamsType; @@ -70,14 +71,14 @@ async function slackExecutor( result = await webhook.send(message); } catch (err) { if (err.original == null || err.original.response == null) { - return errorResult(err.message); + return errorResult(id, err.message); } const { status, statusText, headers } = err.original.response; // special handling for 5xx if (status >= 500) { - return retryResult(err.message); + return retryResult(id, err.message); } // special handling for rate limiting @@ -86,20 +87,20 @@ async function slackExecutor( if (retryAfterString != null) { const retryAfter = parseInt(retryAfterString, 10); if (!isNaN(retryAfter)) { - return retryResultSeconds(err.message, retryAfter); + return retryResultSeconds(id, err.message, retryAfter); } } } - return errorResult(`${err.message} - ${statusText}`); + return errorResult(id, `${err.message} - ${statusText}`); } if (result == null) { - return errorResult(`unexpected null response from slack`); + return errorResult(id, `unexpected null response from slack`); } if (result.text !== 'ok') { - return errorResult(`unexpected text response from slack (expecting 'ok')`); + return errorResult(id, `unexpected text response from slack (expecting 'ok')`); } return successResult(result); @@ -109,28 +110,32 @@ function successResult(data: any): ActionTypeExecutorResult { return { status: 'ok', data }; } -function errorResult(message: string): ActionTypeExecutorResult { +function errorResult(id: string, message: string): ActionTypeExecutorResult { return { status: 'error', - message: `an error occurred posting a slack message: ${message}`, + message: `an error occurred in action ${id} posting a slack message: ${message}`, }; } -function retryResult(message: string): ActionTypeExecutorResult { +function retryResult(id: string, message: string): ActionTypeExecutorResult { return { status: 'error', - message: `an error occurred posting a slack message, retrying later`, + message: `an error occurred in action ${id} posting a slack message, retrying later`, retry: true, }; } -function retryResultSeconds(message: string, retryAfter: number = 60): ActionTypeExecutorResult { +function retryResultSeconds( + id: string, + message: string, + retryAfter: number = 60 +): ActionTypeExecutorResult { const retryEpoch = Date.now() + retryAfter * 1000; const retry = new Date(retryEpoch); const retryString = retry.toISOString(); return { status: 'error', - message: `an error occurred posting a slack message, retry at ${retryString}: ${message}`, + message: `an error occurred in action ${id} posting a slack message, retry at ${retryString}: ${message}`, retry, }; } diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts index 73a5566474a2a..2d184a36d56ea 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts @@ -70,6 +70,7 @@ test('successfully executes', async () => { expect(actionTypeRegistry.get).toHaveBeenCalledWith('test'); expect(actionType.executor).toHaveBeenCalledWith({ + id: '1', services: expect.anything(), config: { bar: true, @@ -128,9 +129,11 @@ test('throws an error when config is invalid', async () => { encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); - await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` - ); + const result = await execute(executeParams); + expect(result).toEqual({ + status: 'error', + message: `The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]`, + }); }); test('throws an error when params is invalid', async () => { @@ -157,7 +160,9 @@ test('throws an error when params is invalid', async () => { encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); - await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]"` - ); + const result = await execute(executeParams); + expect(result).toEqual({ + status: 'error', + message: `The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]`, + }); }); diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.ts b/x-pack/legacy/plugins/actions/server/lib/execute.ts index e75425e0cae6c..899efbaead088 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.ts @@ -35,8 +35,16 @@ export async function execute({ ...(action.attributes.actionTypeConfig || {}), ...(action.attributes.actionTypeConfigSecrets || {}), }; - const validatedConfig = validateActionTypeConfig(actionType, mergedActionTypeConfig); - const validatedParams = validateActionTypeParams(actionType, params); + + let validatedConfig; + let validatedParams; + + try { + validatedConfig = validateActionTypeConfig(actionType, mergedActionTypeConfig); + validatedParams = validateActionTypeParams(actionType, params); + } catch (err) { + return { status: 'error', message: err.message }; + } let result: ActionTypeExecutorResult | null = null; @@ -45,6 +53,7 @@ export async function execute({ try { result = await actionType.executor({ + id: actionId, services, config: validatedConfig, params: validatedParams, diff --git a/x-pack/legacy/plugins/actions/server/types.ts b/x-pack/legacy/plugins/actions/server/types.ts index 3999370d7acc7..70f79327ae93e 100644 --- a/x-pack/legacy/plugins/actions/server/types.ts +++ b/x-pack/legacy/plugins/actions/server/types.ts @@ -31,6 +31,7 @@ export interface ActionsPlugin { // the parameters passed to an action type executor function export interface ActionTypeExecutorOptions { + id: string; services: Services; config: Record; params: Record; diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/es_index.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/es_index.ts new file mode 100644 index 0000000000000..afb52d21311c7 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/es_index.ts @@ -0,0 +1,259 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../../types/providers'; + +const ES_TEST_INDEX_NAME = 'functional-test-actions-index'; + +// eslint-disable-next-line import/no-default-export +export default function indexTest({ getService }: KibanaFunctionalTestDefaultProviders) { + const es = getService('es'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('index action', () => { + after(() => esArchiver.unload('empty_kibana')); + beforeEach(() => clearTestIndex(es)); + + let createdActionID: string; + let createdActionIDWithIndex: string; + + it('should be created successfully', async () => { + // create action with no config + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'An index action', + actionTypeId: '.index', + actionTypeConfig: {}, + }, + }) + .expect(200); + + expect(createdAction).to.eql({ id: createdAction.id }); + createdActionID = createdAction.id; + expect(typeof createdActionID).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/action/${createdActionID}`) + .expect(200); + + expect(fetchedAction).to.eql({ + type: 'action', + id: fetchedAction.id, + attributes: { + description: 'An index action', + actionTypeId: '.index', + actionTypeConfig: { index: null }, + }, + references: [], + updated_at: fetchedAction.updated_at, + version: fetchedAction.version, + }); + + // create action with index config + const { body: createdActionWithIndex } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'An index action with index config', + actionTypeId: '.index', + actionTypeConfig: { + index: ES_TEST_INDEX_NAME, + }, + }, + }) + .expect(200); + + expect(createdActionWithIndex).to.eql({ id: createdActionWithIndex.id }); + createdActionIDWithIndex = createdActionWithIndex.id; + expect(typeof createdActionIDWithIndex).to.be('string'); + + const { body: fetchedActionWithIndex } = await supertest + .get(`/api/action/${createdActionIDWithIndex}`) + .expect(200); + + expect(fetchedActionWithIndex).to.eql({ + type: 'action', + id: fetchedActionWithIndex.id, + attributes: { + description: 'An index action with index config', + actionTypeId: '.index', + actionTypeConfig: { + index: ES_TEST_INDEX_NAME, + }, + }, + references: [], + updated_at: fetchedActionWithIndex.updated_at, + version: fetchedActionWithIndex.version, + }); + }); + + it('should respond with error when creation unsuccessful', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'An index action', + actionTypeId: '.index', + actionTypeConfig: { index: 666 }, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'The actionTypeConfig is invalid: [index]: types that failed validation:\n- [index.0]: expected value of type [string] but got [number]\n- [index.1]: expected value to equal [null] but got [666]', + }); + }); + }); + + it('should fire successly when expected for a single body', async () => { + const { body: result } = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: ES_TEST_INDEX_NAME, + documents: [{ testing: [1, 2, 3] }], + refresh: true, + }, + }) + .expect(200); + expect(result.status).to.eql('ok'); + + const items = await getTestIndexItems(es); + expect(items.length).to.eql(1); + expect(items[0]._source).to.eql({ testing: [1, 2, 3] }); + }); + + it('should fire successly when expected for with multiple bodies', async () => { + const { body: result } = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: ES_TEST_INDEX_NAME, + documents: [{ testing: [1, 2, 3] }, { Testing: [4, 5, 6] }], + refresh: true, + }, + }) + .expect(200); + expect(result.status).to.eql('ok'); + + const items = await getTestIndexItems(es); + expect(items.length).to.eql(2); + let passed1 = false; + let passed2 = false; + for (const item of items) { + if (item._source.testing != null) { + expect(item._source).to.eql({ testing: [1, 2, 3] }); + passed1 = true; + } + + if (item._source.Testing != null) { + expect(item._source).to.eql({ Testing: [4, 5, 6] }); + passed2 = true; + } + } + expect(passed1).to.be(true); + expect(passed2).to.be(true); + }); + + it('should fire successly with refresh false', async () => { + const { body: result } = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: ES_TEST_INDEX_NAME, + documents: [{ refresh: 'not set' }], + }, + }) + .expect(200); + expect(result.status).to.eql('ok'); + + let items; + items = await getTestIndexItems(es); + expect(items.length).to.be.lessThan(2); + + const { body: result2 } = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: ES_TEST_INDEX_NAME, + documents: [{ refresh: 'true' }], + refresh: true, + }, + }) + .expect(200); + expect(result2.status).to.eql('ok'); + + items = await getTestIndexItems(es); + expect(items.length).to.eql(2); + }); + + it('should fire unsuccessfully when expected', async () => { + let response; + let result; + + response = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + indeX: ES_TEST_INDEX_NAME, + documents: [{ testing: [1, 2, 3] }], + }, + }) + .expect(200); + result = response.body; + expect(result.status).to.equal('error'); + expect(result.message).to.eql( + 'The actionParams is invalid: [indeX]: definition for this key is missing' + ); + + response = await supertest + .post(`/api/action/${createdActionID}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + documents: [{ testing: [1, 2, 3] }], + }, + }) + .expect(200); + result = response.body; + expect(result.status).to.equal('error'); + expect(result.message).to.eql( + `index param needs to be set because not set in config for action ${createdActionID}` + ); + }); + }); +} + +async function clearTestIndex(es: any) { + return await es.indices.delete({ + index: ES_TEST_INDEX_NAME, + ignoreUnavailable: true, + }); +} + +async function getTestIndexItems(es: any) { + const result = await es.search({ + index: ES_TEST_INDEX_NAME, + }); + + return result.hits.hits; +} diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts index 22d37586c375c..89a3990617efa 100644 --- a/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts @@ -131,7 +131,7 @@ export default function slackTest({ getService }: KibanaFunctionalTestDefaultPro }) .expect(200); expect(result.status).to.equal('error'); - expect(result.message).to.match(/an error occurred posting a slack message/); + expect(result.message).to.match(/an error occurred in action .+ posting a slack message/); }); it('should handle a 429 slack error', async () => { @@ -147,7 +147,7 @@ export default function slackTest({ getService }: KibanaFunctionalTestDefaultPro .expect(200); expect(result.status).to.equal('error'); - expect(result.message).to.match(/an error occurred posting a slack message/); + expect(result.message).to.match(/an error occurred in action .+ posting a slack message/); expect(result.message).to.match(/retry at/); const dateRetry = new Date(result.retry).getTime(); @@ -166,7 +166,7 @@ export default function slackTest({ getService }: KibanaFunctionalTestDefaultPro .expect(200); expect(result.status).to.equal('error'); - expect(result.message).to.match(/an error occurred posting a slack message/); + expect(result.message).to.match(/an error occurred in action .+ posting a slack message/); expect(result.retry).to.equal(true); }); }); diff --git a/x-pack/test/api_integration/apis/actions/index.ts b/x-pack/test/api_integration/apis/actions/index.ts index 33b16dcd43472..7f1f646ab041c 100644 --- a/x-pack/test/api_integration/apis/actions/index.ts +++ b/x-pack/test/api_integration/apis/actions/index.ts @@ -19,5 +19,6 @@ export default function actionsTests({ loadTestFile }: KibanaFunctionalTestDefau loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/email')); + loadTestFile(require.resolve('./builtin_action_types/es_index')); }); }