diff --git a/.i18nrc.json b/.i18nrc.json index ad3b7ddd4cb08..d675eb02479aa 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -23,6 +23,8 @@ "tagCloud": "src/legacy/core_plugins/tagcloud", "tsvb": "src/legacy/core_plugins/metrics", "kbnESQuery": "packages/kbn-es-query", + "xpack.actions": "x-pack/legacy/plugins/actions", + "xpack.alerting": "x-pack/legacy/plugins/alerting", "xpack.apm": "x-pack/legacy/plugins/apm", "xpack.beatsManagement": "x-pack/legacy/plugins/beats_management", "xpack.canvas": "x-pack/legacy/plugins/canvas", diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2fac83e3a2798..b7a9ac2c79454 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -21,3 +21,4 @@ export { configServiceMock } from './config/config_service.mock'; export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service.mock'; export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; +export { SavedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 14a4f99314e03..adc25a6d045e9 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -142,12 +142,25 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +/** + * + * @public + */ +export type SavedObjectAttribute = + | string + | number + | boolean + | null + | undefined + | SavedObjectAttributes + | SavedObjectAttributes[]; + /** * * @public */ export interface SavedObjectAttributes { - [key: string]: SavedObjectAttributes | string | number | boolean | null; + [key: string]: SavedObjectAttribute | SavedObjectAttribute[]; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 14dea167d3189..086d0f4bef1a2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -421,8 +421,10 @@ export interface SavedObject { // @public (undocumented) export interface SavedObjectAttributes { + // Warning: (ae-forgotten-export) The symbol "SavedObjectAttribute" needs to be exported by the entry point index.d.ts + // // (undocumented) - [key: string]: SavedObjectAttributes | string | number | boolean | null; + [key: string]: SavedObjectAttribute | SavedObjectAttribute[]; } // @public diff --git a/x-pack/index.js b/x-pack/index.js index 1d93f818bb4f5..0d25f871bd306 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -43,6 +43,8 @@ import { fileUpload } from './legacy/plugins/file_upload'; import { telemetry } from './legacy/plugins/telemetry'; import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; import { snapshotRestore } from './legacy/plugins/snapshot_restore'; +import { actions } from './legacy/plugins/actions'; +import { alerting } from './legacy/plugins/alerting'; module.exports = function (kibana) { return [ @@ -85,5 +87,7 @@ module.exports = function (kibana) { fileUpload(kibana), encryptedSavedObjects(kibana), snapshotRestore(kibana), + actions(kibana), + alerting(kibana), ]; }; diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md new file mode 100644 index 0000000000000..6b5bbe44c0248 --- /dev/null +++ b/x-pack/legacy/plugins/actions/README.md @@ -0,0 +1,177 @@ +# Kibana actions + +The Kibana actions plugin provides a common place to execute actions. You can: + +- Register an action type +- View a list of registered types +- Fire an action either manually or by using an alert +- Perform CRUD on actions with encrypted configurations + +## Terminology + +**Action Type**: A programatically defined integration with another service, with an expected set of configuration and parameters. + +**Action**: A user-defined configuration that satisfies an action type's expected configuration. + +## Usage + +1. Develop and register an action type (see action types -> example). +2. Create an action by using the RESTful API (see actions -> create action). +3. Use alerts to fire actions or fire manually (see firing actions). + +## Action types + +### Methods + +**server.plugins.actions.registerType(options)** + +The following table describes the properties of the `options` object. + +|Property|Description|Type| +|---|---|---| +|id|Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types.|string| +|name|A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types.|string| +|unencryptedAttributes|A list of opt-out attributes that don't need to be encrypted. These attributes won't need to be re-entered on import / export when the feature becomes available. These attributes will also be readable / displayed when it comes to a table / edit screen.|array of strings| +|validate.params|When developing an action type, it needs to accept parameters to know what to do with the action. (Example to, from, subject, body of an email). Use joi object validation if you would like `params` to be validated before being passed to the executor.|Joi schema| +|validate.config|Similar to params, a config is required when creating an action (for example host, port, username, and password of an email server). Use the joi object validation if you would like the config to be validated before being passed to the executor.|Joi schema| +|executor|This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below.|Function| + +### Executor + +This is the primary function for an action type. Whenever the action needs to execute, this function will perform the action. It receives a variety of parameters. The following table describes the properties that the executor receives. + +**executor(options)** + +|Property|Description| +|---|---| +|config|The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type.| +|params|Parameters for the execution. These will be given at fire time by either an alert or manually provided when calling the plugin provided fire function.| +|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| +|services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

**NOTE**: This currently only works when security is disabled. A future PR will add support for enabling security using Elasticsearch API tokens.| +|services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| + +### Example + +Below is an example email action type. The attributes `host` and `port` are configured to be unencrypted by using the `unencryptedAttributes` attribute. + +``` +server.plugins.actions.registerType({ + id: 'smtp', + name: 'Email', + unencryptedAttributes: ['host', 'port'], + validate: { + params: Joi.object() + .keys({ + to: Joi.array().items(Joi.string()).required(), + from: Joi.string().required(), + subject: Joi.string().required(), + body: Joi.string().required(), + }) + .required(), + config: Joi.object() + .keys({ + host: Joi.string().required(), + port: Joi.number().default(465), + username: Joi.string().required(), + password: Joi.string().required(), + }) + .required(), + }, + async executor({ config, params, services }) { + const transporter = nodemailer. createTransport(config); + await transporter.sendMail(params); + }, +}); +``` + +## RESTful API + +Using an action type requires an action to be created that will contain and encrypt configuration for a given action type. See below for CRUD operations using the API. + +#### `POST /api/action`: Create action + +Payload: + +|Property|Description|Type| +|---|---|---| +|attributes.description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| +|attributes.actionTypeId|The id value of the action type you want to call when the action executes.|string| +|attributes.actionTypeConfig|The configuration the action type expects. See related action type to see what attributes is expected. This will also validate against the action type if config validation is defined.|object| +|references|An array of `name`, `type` and `id`. This is the same as `references` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|Array| +|migrationVersion|The version of the most recent migrations. This is the same as `migrationVersion` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|object| + +#### `DELETE /api/action/{id}`: Delete action + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the action you're trying to delete.|string| + +#### `GET /api/action/_find`: Find actions + +Params: + +See the saved objects API documentation for find. All the properties are the same except that you cannot pass in `type`. + +#### `GET /api/action/{id}`: Get action + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the action you're trying to get.|string| + +#### `GET /api/action/types` List action types + +No parameters. + +#### `PUT /api/action/{id}`: Update action + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the action you're trying to update.|string| + +Payload: + +|Property|Description|Type| +|---|---|---| +|attributes.description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| +|attributes.actionTypeConfig|The configuration the action type expects. See related action type to see what attributes is expected. This will also validate against the action type if config validation is defined.|object| +|references|An array of `name`, `type` and `id`. This is the same as `references` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|Array| +|version|The document version when read|string| + +## Firing actions + +The plugin exposes a fire function that you can use to fire actions. + +**server.plugins.actions.fire(options)** + +The following table describes the properties of the `options` object. + +|Property|Description|Type| +|---|---|---| +|id|The id of the action you want to fire.|string| +|params|The `params` value to give the action type executor.|object| +|namespace|The saved object namespace the action exists within.|string| +|basePath|This is a temporary parameter, but we need to capture and track the value of `request.getBasePath()` until future changes are made.

In most cases this can be `undefined` unless you need cross spaces support.|string| + +### Example + +This example makes action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` fire an email. The action plugin will load the saved object and find what action type to call with `params`. + +``` +server.plugins.actions.fire({ + id: '3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5', + params: { + from: 'example@elastic.co', + to: ['destination@elastic.co'], + subject: 'My email subject', + body: 'My email body', + }, + namespace: undefined, // The namespace the action exists within + basePath: undefined, // Usually `request.getBasePath();` or `undefined` +}); +``` diff --git a/x-pack/legacy/plugins/actions/index.ts b/x-pack/legacy/plugins/actions/index.ts new file mode 100644 index 0000000000000..ee46431b4ec57 --- /dev/null +++ b/x-pack/legacy/plugins/actions/index.ts @@ -0,0 +1,38 @@ +/* + * 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 { Legacy } from 'kibana'; +import { Root } from 'joi'; +import mappings from './mappings.json'; +import { init } from './server'; + +export { ActionsPlugin, ActionsClient, ActionType, ActionTypeExecutorOptions } from './server'; + +export function actions(kibana: any) { + return new kibana.Plugin({ + id: 'actions', + configPrefix: 'xpack.actions', + require: ['kibana', 'elasticsearch', 'task_manager', 'encrypted_saved_objects'], + isEnabled(config: Legacy.KibanaConfig) { + return ( + config.get('xpack.encrypted_saved_objects.enabled') === true && + config.get('xpack.actions.enabled') === true && + config.get('xpack.task_manager.enabled') === true + ); + }, + config(Joi: Root) { + return Joi.object() + .keys({ + enabled: Joi.boolean().default(true), + }) + .default(); + }, + init, + uiExports: { + mappings, + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/mappings.json b/x-pack/legacy/plugins/actions/mappings.json new file mode 100644 index 0000000000000..e76612ca56c48 --- /dev/null +++ b/x-pack/legacy/plugins/actions/mappings.json @@ -0,0 +1,19 @@ +{ + "action": { + "properties": { + "description": { + "type": "text" + }, + "actionTypeId": { + "type": "keyword" + }, + "actionTypeConfig": { + "enabled": false, + "type": "object" + }, + "actionTypeConfigSecrets": { + "type": "binary" + } + } + } +} diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.mock.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.mock.ts new file mode 100644 index 0000000000000..553f2d2654540 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.mock.ts @@ -0,0 +1,23 @@ +/* + * 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 { ActionTypeRegistry } from './action_type_registry'; + +type ActionTypeRegistryContract = PublicMethodsOf; + +const createActionTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const actionTypeRegistryMock = { + create: createActionTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts new file mode 100644 index 0000000000000..16525d0acd09f --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -0,0 +1,151 @@ +/* + * 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/get_create_task_runner_function', () => ({ + getCreateTaskRunnerFunction: jest.fn(), +})); + +import { taskManagerMock } from '../../task_manager/task_manager.mock'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/plugin.mock'; +import { ActionTypeRegistry } from './action_type_registry'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; + +const mockTaskManager = taskManagerMock.create(); + +function getServices() { + return { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; +} +const actionTypeRegistryParams = { + getServices, + taskManager: mockTaskManager, + encryptedSavedObjectsPlugin: encryptedSavedObjectsMock.create(), +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('register()', () => { + test('able to register action types', () => { + const executor = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getCreateTaskRunnerFunction } = require('./lib/get_create_task_runner_function'); + getCreateTaskRunnerFunction.mockReturnValueOnce(jest.fn()); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(actionTypeRegistry.has('my-action-type')).toEqual(true); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(mockTaskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "actions:my-action-type": Object { + "createTaskRunner": [MockFunction], + "title": "My action type", + "type": "actions:my-action-type", + }, + }, +] +`); + expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1); + const call = getCreateTaskRunnerFunction.mock.calls[0][0]; + expect(call.actionType).toMatchInlineSnapshot(` +Object { + "executor": [MockFunction], + "id": "my-action-type", + "name": "My action type", +} +`); + expect(call.encryptedSavedObjectsPlugin).toBeTruthy(); + expect(call.getServices).toBeTruthy(); + }); + + test('throws error if action type already registered', () => { + const executor = jest.fn(); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(() => + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is already registered."` + ); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionType = actionTypeRegistry.get('my-action-type'); + expect(actionType).toMatchInlineSnapshot(` +Object { + "executor": [Function], + "id": "my-action-type", + "name": "My action type", +} +`); + }); + + test(`throws an error when action type doesn't exist`, () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(() => actionTypeRegistry.get('my-action-type')).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is not registered."` + ); + }); +}); + +describe('list()', () => { + test('returns list of action types', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionTypes = actionTypeRegistry.list(); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + name: 'My action type', + }, + ]); + }); +}); + +describe('has()', () => { + test('returns false for unregistered action types', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + expect(actionTypeRegistry.has('my-action-type')).toEqual(false); + }); + + test('returns true after registering an action type', () => { + const executor = jest.fn(); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(actionTypeRegistry.has('my-action-type')); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts new file mode 100644 index 0000000000000..0795fe270822e --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -0,0 +1,93 @@ +/* + * 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 Boom from 'boom'; +import { i18n } from '@kbn/i18n'; +import { ActionType, Services } from './types'; +import { TaskManager } from '../../task_manager'; +import { getCreateTaskRunnerFunction } from './lib'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; + +interface ConstructorOptions { + getServices: (basePath: string) => Services; + taskManager: TaskManager; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; +} + +export class ActionTypeRegistry { + private readonly getServices: (basePath: string) => Services; + private readonly taskManager: TaskManager; + private readonly actionTypes: Map = new Map(); + private readonly encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + + constructor({ getServices, taskManager, encryptedSavedObjectsPlugin }: ConstructorOptions) { + this.getServices = getServices; + this.taskManager = taskManager; + this.encryptedSavedObjectsPlugin = encryptedSavedObjectsPlugin; + } + + /** + * Returns if the action type registry has the given action type registered + */ + public has(id: string) { + return this.actionTypes.has(id); + } + + /** + * Registers an action type to the action type registry + */ + public register(actionType: ActionType) { + if (this.has(actionType.id)) { + throw new Error( + i18n.translate('xpack.actions.actionTypeRegistry.register.duplicateActionTypeError', { + defaultMessage: 'Action type "{id}" is already registered.', + values: { + id: actionType.id, + }, + }) + ); + } + this.actionTypes.set(actionType.id, actionType); + this.taskManager.registerTaskDefinitions({ + [`actions:${actionType.id}`]: { + title: actionType.name, + type: `actions:${actionType.id}`, + createTaskRunner: getCreateTaskRunnerFunction({ + actionType, + getServices: this.getServices, + encryptedSavedObjectsPlugin: this.encryptedSavedObjectsPlugin, + }), + }, + }); + } + + /** + * Returns an action type, throws if not registered + */ + public get(id: string): ActionType { + if (!this.has(id)) { + throw Boom.badRequest( + i18n.translate('xpack.actions.actionTypeRegistry.get.missingActionTypeError', { + defaultMessage: 'Action type "{id}" is not registered.', + values: { + id, + }, + }) + ); + } + return this.actionTypes.get(id)!; + } + + /** + * Returns a list of registered action types [{ id, name }] + */ + public list() { + return Array.from(this.actionTypes).map(([actionTypeId, actionType]) => ({ + id: actionTypeId, + name: actionType.name, + })); + } +} diff --git a/x-pack/legacy/plugins/actions/server/actions_client.mock.ts b/x-pack/legacy/plugins/actions/server/actions_client.mock.ts new file mode 100644 index 0000000000000..8a39d68f40bb6 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/actions_client.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { ActionsClient } from './actions_client'; + +type ActionsClientContract = PublicMethodsOf; + +const createActionsClientMock = () => { + const mocked: jest.Mocked = { + create: jest.fn(), + get: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }; + return mocked; +}; + +export const actionsClientMock = { + create: createActionsClientMock, +}; diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts new file mode 100644 index 0000000000000..587d62610d6a0 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -0,0 +1,429 @@ +/* + * 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 { ActionTypeRegistry } from './action_type_registry'; +import { ActionsClient } from './actions_client'; +import { taskManagerMock } from '../../task_manager/task_manager.mock'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; + +const savedObjectsClient = SavedObjectsClientMock.create(); + +const mockTaskManager = taskManagerMock.create(); + +const mockEncryptedSavedObjectsPlugin = { + getDecryptedAsInternalUser: jest.fn() as EncryptedSavedObjectsPlugin['getDecryptedAsInternalUser'], +} as EncryptedSavedObjectsPlugin; + +function getServices() { + return { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; +} + +const actionTypeRegistryParams = { + getServices, + taskManager: mockTaskManager, + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('create()', () => { + test('creates an action with all given properties', async () => { + const expectedResult = { + id: '1', + type: 'type', + attributes: {}, + references: [], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.create.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.create({ + attributes: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + options: { + migrationVersion: {}, + references: [], + }, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object { + "migrationVersion": Object {}, + "references": Array [], + }, +] +`); + }); + + test('validates actionTypeConfig', async () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + config: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + await expect( + actionsClient.create({ + attributes: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + ); + }); + + test(`throws an error when an action type doesn't exist`, async () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + await expect( + actionsClient.create({ + attributes: { + description: 'my description', + actionTypeId: 'unregistered-action-type', + actionTypeConfig: {}, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"unregistered-action-type\\" is not registered."` + ); + }); + + test('encrypts action type options unless specified not to', async () => { + const expectedResult = { + id: '1', + type: 'type', + attributes: {}, + references: [], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + unencryptedAttributes: ['a', 'c'], + async executor() {}, + }); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.create.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.create({ + attributes: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: { + a: true, + b: true, + c: true, + }, + }, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + undefined, +] +`); + }); +}); + +describe('get()', () => { + test('calls savedObjectsClient with id', async () => { + const expectedResult = { + id: '1', + type: 'type', + attributes: {}, + references: [], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.get({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "1", +] +`); + }); +}); + +describe('find()', () => { + test('calls savedObjectsClient with parameters', async () => { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: {}, + references: [], + }, + ], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.find.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.find({}); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "type": "action", + }, +] +`); + }); +}); + +describe('delete()', () => { + test('calls savedObjectsClient with id', async () => { + const expectedResult = Symbol(); + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.delete({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "1", +] +`); + }); +}); + +describe('update()', () => { + test('updates an action with all given properties', async () => { + const expectedResult = { + id: '1', + type: 'action', + attributes: {}, + references: [], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + savedObjectsClient.update.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.update({ + id: 'my-action', + attributes: { + description: 'my description', + actionTypeConfig: {}, + }, + options: {}, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, +] +`); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "my-action", +] +`); + }); + + test('validates actionTypeConfig', async () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + config: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + await expect( + actionsClient.update({ + id: 'my-action', + attributes: { + description: 'my description', + actionTypeConfig: {}, + }, + options: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + ); + }); + + test('encrypts action type options unless specified not to', async () => { + const expectedResult = { + id: '1', + type: 'type', + attributes: {}, + references: [], + }; + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + unencryptedAttributes: ['a', 'c'], + async executor() {}, + }); + const actionsClient = new ActionsClient({ + actionTypeRegistry, + savedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + savedObjectsClient.update.mockResolvedValueOnce(expectedResult); + const result = await actionsClient.update({ + id: 'my-action', + attributes: { + description: 'my description', + actionTypeConfig: { + a: true, + b: true, + c: true, + }, + }, + options: {}, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, +] +`); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/actions_client.ts b/x-pack/legacy/plugins/actions/server/actions_client.ts new file mode 100644 index 0000000000000..4ab62a6e434ad --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/actions_client.ts @@ -0,0 +1,157 @@ +/* + * 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 { SavedObjectsClientContract, SavedObjectAttributes } from 'src/core/server'; +import { ActionTypeRegistry } from './action_type_registry'; +import { SavedObjectReference } from './types'; +import { validateActionTypeConfig } from './lib'; + +interface Action extends SavedObjectAttributes { + description: string; + actionTypeId: string; + actionTypeConfig: SavedObjectAttributes; +} + +interface CreateOptions { + attributes: Action; + options?: { + migrationVersion?: Record; + references?: SavedObjectReference[]; + }; +} + +interface FindOptions { + options?: { + perPage?: number; + page?: number; + search?: string; + defaultSearchOperator?: 'AND' | 'OR'; + searchFields?: string[]; + sortField?: string; + hasReference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +interface ConstructorOptions { + actionTypeRegistry: ActionTypeRegistry; + savedObjectsClient: SavedObjectsClientContract; +} + +interface UpdateOptions { + id: string; + attributes: { + description: string; + actionTypeConfig: SavedObjectAttributes; + }; + options: { version?: string; references?: SavedObjectReference[] }; +} + +export class ActionsClient { + private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly actionTypeRegistry: ActionTypeRegistry; + + constructor({ actionTypeRegistry, savedObjectsClient }: ConstructorOptions) { + this.actionTypeRegistry = actionTypeRegistry; + this.savedObjectsClient = savedObjectsClient; + } + + /** + * Create an action + */ + public async create({ attributes, options }: CreateOptions) { + const { actionTypeId } = attributes; + const actionType = this.actionTypeRegistry.get(actionTypeId); + const validatedActionTypeConfig = validateActionTypeConfig( + actionType, + attributes.actionTypeConfig + ); + const actionWithSplitActionTypeConfig = this.moveEncryptedAttributesToSecrets( + actionType.unencryptedAttributes, + { + ...attributes, + actionTypeConfig: validatedActionTypeConfig, + } + ); + return await this.savedObjectsClient.create('action', actionWithSplitActionTypeConfig, options); + } + + /** + * Get an action + */ + public async get({ id }: { id: string }) { + return await this.savedObjectsClient.get('action', id); + } + + /** + * Find actions + */ + public async find({ options = {} }: FindOptions) { + return await this.savedObjectsClient.find({ + ...options, + type: 'action', + }); + } + + /** + * Delete action + */ + public async delete({ id }: { id: string }) { + return await this.savedObjectsClient.delete('action', id); + } + + /** + * Update action + */ + public async update({ id, attributes, options = {} }: UpdateOptions) { + const existingObject = await this.savedObjectsClient.get('action', id); + const { actionTypeId } = existingObject.attributes; + const actionType = this.actionTypeRegistry.get(actionTypeId); + + const validatedActionTypeConfig = validateActionTypeConfig( + actionType, + attributes.actionTypeConfig + ); + attributes = this.moveEncryptedAttributesToSecrets(actionType.unencryptedAttributes, { + ...attributes, + actionTypeConfig: validatedActionTypeConfig, + }); + return await this.savedObjectsClient.update( + 'action', + id, + { + ...attributes, + actionTypeId, + }, + options + ); + } + + /** + * Set actionTypeConfigSecrets values on a given action + */ + private moveEncryptedAttributesToSecrets( + unencryptedAttributes: string[] = [], + action: Action | UpdateOptions['attributes'] + ) { + const actionTypeConfig: Record = {}; + const actionTypeConfigSecrets = { ...action.actionTypeConfig }; + for (const attributeKey of unencryptedAttributes) { + actionTypeConfig[attributeKey] = actionTypeConfigSecrets[attributeKey]; + delete actionTypeConfigSecrets[attributeKey]; + } + + return { + ...action, + // Important these overwrite attributes for encryption purposes + actionTypeConfig, + actionTypeConfigSecrets, + }; + } +} 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 new file mode 100644 index 0000000000000..6f410d2ee20ec --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.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 { ActionTypeRegistry } from '../action_type_registry'; + +import { actionType as serverLogActionType } from './server_log'; + +export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { + actionTypeRegistry.register(serverLogActionType); +} 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 new file mode 100644 index 0000000000000..0a955522ab5df --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -0,0 +1,148 @@ +/* + * 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 } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; +import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; +import { validateActionTypeParams } from '../lib'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; + +import { registerBuiltInActionTypes } from './index'; + +const ACTION_TYPE_ID = 'kibana.server-log'; +const NO_OP_FN = () => {}; + +const services = { + log: NO_OP_FN, + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices() { + return services; +} + +let actionTypeRegistry: ActionTypeRegistry; + +const mockEncryptedSavedObjectsPlugin = { + getDecryptedAsInternalUser: jest.fn() as EncryptedSavedObjectsPlugin['getDecryptedAsInternalUser'], +} as EncryptedSavedObjectsPlugin; + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + }); + 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('server-log'); + }); +}); + +describe('validateActionTypeParams()', () => { + let actionType: ActionType; + + beforeAll(() => { + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + expect(actionType).toBeTruthy(); + }); + + test('should validate and pass when params is valid', () => { + expect(validateActionTypeParams(actionType, { message: 'a message' })).toEqual({ + message: 'a message', + tags: ['info', 'alerting'], + }); + expect( + validateActionTypeParams(actionType, { + message: 'a message', + tags: ['info', 'blorg'], + }) + ).toEqual({ + message: 'a message', + tags: ['info', 'blorg'], + }); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateActionTypeParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"message\\" fails because [\\"message\\" is required]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { message: 1 }); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"message\\" fails because [\\"message\\" must be a string]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { message: 'x', tags: 2 }); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"tags\\" fails because [\\"tags\\" must be an array]"` + ); + + expect(() => { + validateActionTypeParams(actionType, { message: 'x', tags: [2] }); + }).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"tags\\" fails because [\\"tags\\" at position 0 fails because [\\"0\\" must be a string]]"` + ); + }); +}); + +describe('execute()', () => { + test('calls the executor with proper params', async () => { + const mockLog = jest.fn().mockResolvedValueOnce({ success: true }); + + services.log = mockLog; + const actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + await actionType.executor({ + services: { + log: mockLog, + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: SavedObjectsClientMock.create(), + }, + config: {}, + params: { message: 'message text here', tags: ['tag1', 'tag2'] }, + }); + expect(mockLog).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Array [ + "tag1", + "tag2", + ], + "message text here", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); 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 new file mode 100644 index 0000000000000..c14f5679d253a --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -0,0 +1,34 @@ +/* + * 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 { ActionType, ActionTypeExecutorOptions } from '../types'; + +const DEFAULT_TAGS = ['info', 'alerting']; + +const PARAMS_SCHEMA = Joi.object().keys({ + message: Joi.string().required(), + tags: Joi.array() + .items(Joi.string()) + .optional() + .default(DEFAULT_TAGS), +}); + +export const actionType: ActionType = { + id: 'kibana.server-log', + name: 'server-log', + validate: { + params: PARAMS_SCHEMA, + }, + executor, +}; + +async function executor({ params, services }: ActionTypeExecutorOptions): Promise { + const { message, tags } = params; + + services.log(tags, message); +} diff --git a/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts b/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts new file mode 100644 index 0000000000000..8def316c12df5 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { taskManagerMock } from '../../task_manager/task_manager.mock'; +import { createFireFunction } from './create_fire_function'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; + +const mockTaskManager = taskManagerMock.create(); +const savedObjectsClient = SavedObjectsClientMock.create(); + +beforeEach(() => jest.resetAllMocks()); + +describe('fire()', () => { + test('schedules the action with all given parameters', async () => { + const fireFn = createFireFunction({ + taskManager: mockTaskManager, + internalSavedObjectsRepository: savedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '123', + type: 'action', + attributes: { + actionTypeId: 'mock-action', + }, + references: [], + }); + await fireFn({ + id: '123', + params: { baz: false }, + namespace: 'abc', + basePath: '/s/default', + }); + expect(mockTaskManager.schedule).toHaveBeenCalledTimes(1); + expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "params": Object { + "actionTypeParams": Object { + "baz": false, + }, + "basePath": "/s/default", + "id": "123", + "namespace": "abc", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", + }, +] +`); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "action", + "123", + Object { + "namespace": "abc", + }, +] +`); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/create_fire_function.ts b/x-pack/legacy/plugins/actions/server/create_fire_function.ts new file mode 100644 index 0000000000000..7ff31f4d455f9 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/create_fire_function.ts @@ -0,0 +1,40 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { TaskManager } from '../../task_manager'; + +interface CreateFireFunctionOptions { + taskManager: TaskManager; + internalSavedObjectsRepository: SavedObjectsClientContract; +} + +interface FireOptions { + id: string; + params: Record; + namespace?: string; + basePath: string; +} + +export function createFireFunction({ + taskManager, + internalSavedObjectsRepository, +}: CreateFireFunctionOptions) { + return async function fire({ id, params, namespace, basePath }: FireOptions) { + const actionSavedObject = await internalSavedObjectsRepository.get('action', id, { namespace }); + await taskManager.schedule({ + taskType: `actions:${actionSavedObject.attributes.actionTypeId}`, + params: { + id, + basePath, + namespace, + actionTypeParams: params, + }, + state: {}, + scope: ['actions'], + }); + }; +} diff --git a/x-pack/legacy/plugins/actions/server/index.ts b/x-pack/legacy/plugins/actions/server/index.ts new file mode 100644 index 0000000000000..4e1016b9eef43 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { init } from './init'; +export { ActionsPlugin, ActionTypeExecutorOptions, ActionType } from './types'; +export { ActionsClient } from './actions_client'; diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts new file mode 100644 index 0000000000000..cecae7f46f134 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -0,0 +1,89 @@ +/* + * 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 { Legacy } from 'kibana'; +import { ActionsClient } from './actions_client'; +import { ActionTypeRegistry } from './action_type_registry'; +import { createFireFunction } from './create_fire_function'; +import { ActionsPlugin, Services } from './types'; +import { + createRoute, + deleteRoute, + findRoute, + getRoute, + updateRoute, + listActionTypesRoute, +} from './routes'; + +import { registerBuiltInActionTypes } from './builtin_action_types'; + +export function init(server: Legacy.Server) { + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); + const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( + callWithInternalUser + ); + + // Encrypted attributes + server.plugins.encrypted_saved_objects!.registerType({ + type: 'action', + attributesToEncrypt: new Set(['actionTypeConfigSecrets']), + attributesToExcludeFromAAD: new Set(['description']), + }); + + function getServices(basePath: string): Services { + // Fake request is here to allow creating a scoped saved objects client + // and use it when security is disabled. This will be replaced when the + // future phase of API tokens is complete. + const fakeRequest: any = { + headers: {}, + getBasePath: () => basePath, + }; + return { + log: server.log, + callCluster: callWithInternalUser, + savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(fakeRequest), + }; + } + + const { taskManager } = server; + const actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManager!, + encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects!, + }); + + registerBuiltInActionTypes(actionTypeRegistry); + + // Routes + createRoute(server); + deleteRoute(server); + getRoute(server); + findRoute(server); + updateRoute(server); + listActionTypesRoute(server); + + const fireFn = createFireFunction({ + taskManager: taskManager!, + internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, + }); + + // Expose functions to server + server.decorate('request', 'getActionsClient', function() { + const request = this; + const savedObjectsClient = request.getSavedObjectsClient(); + const actionsClient = new ActionsClient({ + savedObjectsClient, + actionTypeRegistry, + }); + return actionsClient; + }); + const exposedFunctions: ActionsPlugin = { + fire: fireFn, + registerType: actionTypeRegistry.register.bind(actionTypeRegistry), + listTypes: actionTypeRegistry.list.bind(actionTypeRegistry), + }; + server.expose(exposedFunctions); +} diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts new file mode 100644 index 0000000000000..2cfbe21f6b0dd --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; +import { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; + +const mockedEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); + +const getCreateTaskRunnerFunctionParams = { + getServices() { + return { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; + }, + actionType: { + id: '1', + name: '1', + executor: jest.fn(), + }, + encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin, +}; + +const taskInstanceMock = { + runAt: new Date(), + state: {}, + params: { + id: '2', + actionTypeParams: { baz: true }, + namespace: 'test', + }, + taskType: 'actions:1', +}; + +beforeEach(() => jest.resetAllMocks()); + +test('successfully executes the task', async () => { + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'action', + references: [], + attributes: { + actionTypeConfig: { foo: true }, + actionTypeConfigSecrets: { bar: true }, + }, + }); + const runnerResult = await runner.run(); + expect(runnerResult).toBeUndefined(); + expect(mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mock.calls[0]) + .toMatchInlineSnapshot(` +Array [ + "action", + "2", + Object { + "namespace": "test", + }, +] +`); + expect(getCreateTaskRunnerFunctionParams.actionType.executor).toHaveBeenCalledTimes(1); + const call = getCreateTaskRunnerFunctionParams.actionType.executor.mock.calls[0][0]; + expect(call.config).toMatchInlineSnapshot(` +Object { + "bar": true, + "foo": true, +} +`); + expect(call.params).toMatchInlineSnapshot(` +Object { + "baz": true, +} +`); + expect(call.services).toBeTruthy(); +}); + +test('validates params before executing the task', async () => { + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + actionType: { + ...getCreateTaskRunnerFunctionParams.actionType, + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + }, + }); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'action', + references: [], + attributes: { + actionTypeConfig: { foo: true }, + actionTypeConfigSecrets: { bar: true }, + }, + }); + await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); +}); + +test('validates config before executing the task', async () => { + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + actionType: { + ...getCreateTaskRunnerFunctionParams.actionType, + validate: { + config: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + }, + }); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'action', + references: [], + attributes: { + actionTypeConfig: { foo: true }, + actionTypeConfigSecrets: { bar: true }, + }, + }); + await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + ); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts new file mode 100644 index 0000000000000..566d88b5b0e52 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts @@ -0,0 +1,52 @@ +/* + * 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 { TaskInstance } from '../../../task_manager'; +import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; +import { validateActionTypeConfig } from './validate_action_type_config'; +import { validateActionTypeParams } from './validate_action_type_params'; + +interface CreateTaskRunnerFunctionOptions { + getServices: (basePath: string) => Services; + actionType: ActionType; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; +} + +interface TaskRunnerOptions { + taskInstance: TaskInstance; +} + +export function getCreateTaskRunnerFunction({ + getServices, + actionType, + encryptedSavedObjectsPlugin, +}: CreateTaskRunnerFunctionOptions) { + return ({ taskInstance }: TaskRunnerOptions) => { + return { + run: async () => { + const { namespace, id, actionTypeParams } = taskInstance.params; + const action = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser('action', id, { + namespace, + }); + const mergedActionTypeConfig = { + ...(action.attributes.actionTypeConfig || {}), + ...(action.attributes.actionTypeConfigSecrets || {}), + }; + const validatedActionTypeConfig = validateActionTypeConfig( + actionType, + mergedActionTypeConfig + ); + const validatedActionTypeParams = validateActionTypeParams(actionType, actionTypeParams); + await actionType.executor({ + services: getServices(taskInstance.params.basePath), + config: validatedActionTypeConfig, + params: validatedActionTypeParams, + }); + }, + }; + }; +} diff --git a/x-pack/legacy/plugins/actions/server/lib/index.ts b/x-pack/legacy/plugins/actions/server/lib/index.ts new file mode 100644 index 0000000000000..f52485aa5ae37 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; +export { validateActionTypeConfig } from './validate_action_type_config'; +export { validateActionTypeParams } from './validate_action_type_params'; diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts new file mode 100644 index 0000000000000..b1654d27a0392 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { validateActionTypeConfig } from './validate_action_type_config'; + +test('should return passed in config when validation not defined', () => { + const result = validateActionTypeConfig( + { + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }, + { + foo: true, + } + ); + expect(result).toEqual({ foo: true }); +}); + +test('should validate and apply defaults when actionTypeConfig is valid', () => { + const result = validateActionTypeConfig( + { + id: 'my-action-type', + name: 'My action type', + validate: { + config: Joi.object() + .keys({ + param1: Joi.string().required(), + param2: Joi.strict().default('default-value'), + }) + .required(), + }, + async executor() {}, + }, + { param1: 'value' } + ); + expect(result).toEqual({ + param1: 'value', + param2: 'default-value', + }); +}); + +test('should validate and throw error when actionTypeConfig is invalid', () => { + expect(() => + validateActionTypeConfig( + { + id: 'my-action-type', + name: 'My action type', + validate: { + config: Joi.object() + .keys({ + obj: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }) + .required(), + }, + async executor() {}, + }, + { + obj: {}, + } + ) + ).toThrowErrorMatchingInlineSnapshot( + `"The following actionTypeConfig attributes are invalid: obj.param1 [any.required]"` + ); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts new file mode 100644 index 0000000000000..169afc506d2e8 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts @@ -0,0 +1,28 @@ +/* + * 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 Boom from 'boom'; +import { ActionType } from '../types'; + +export function validateActionTypeConfig>( + actionType: ActionType, + config: T +): T { + const validator = actionType.validate && actionType.validate.config; + if (!validator) { + return config; + } + const { error, value } = validator.validate(config); + if (error) { + const invalidPaths = error.details.map( + (details: any) => `${details.path.join('.')} [${details.type}]` + ); + throw Boom.badRequest( + `The following actionTypeConfig attributes are invalid: ${invalidPaths.join(', ')}` + ); + } + return value; +} diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts new file mode 100644 index 0000000000000..141bf7e2135b2 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { validateActionTypeParams } from './validate_action_type_params'; + +test('should return passed in params when validation not defined', () => { + const result = validateActionTypeParams( + { + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }, + { + foo: true, + } + ); + expect(result).toEqual({ + foo: true, + }); +}); + +test('should validate and apply defaults when params is valid', () => { + const result = validateActionTypeParams( + { + id: 'my-action-type', + name: 'My action type', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + param2: Joi.string().default('default-value'), + }) + .required(), + }, + async executor() {}, + }, + { param1: 'value' } + ); + expect(result).toEqual({ + param1: 'value', + param2: 'default-value', + }); +}); + +test('should validate and throw error when params is invalid', () => { + expect(() => + validateActionTypeParams( + { + id: 'my-action-type', + name: 'My action type', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }, + {} + ) + ).toThrowErrorMatchingInlineSnapshot( + `"params invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts new file mode 100644 index 0000000000000..93d4e83b3a887 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts @@ -0,0 +1,23 @@ +/* + * 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 Boom from 'boom'; +import { ActionType } from '../types'; + +export function validateActionTypeParams>( + actionType: ActionType, + params: T +): T { + const validator = actionType.validate && actionType.validate.params; + if (!validator) { + return params; + } + const { error, value } = validator.validate(params); + if (error) { + throw Boom.badRequest(`params invalid: ${error.message}`); + } + return value; +} diff --git a/x-pack/legacy/plugins/actions/server/routes/_mock_server.ts b/x-pack/legacy/plugins/actions/server/routes/_mock_server.ts new file mode 100644 index 0000000000000..545f678db881f --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/_mock_server.ts @@ -0,0 +1,48 @@ +/* + * 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 Hapi from 'hapi'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { actionsClientMock } from '../actions_client.mock'; +import { actionTypeRegistryMock } from '../action_type_registry.mock'; + +const defaultConfig = { + 'kibana.index': '.kibana', +}; + +export function createMockServer(config: Record = defaultConfig) { + const server = new Hapi.Server({ + port: 0, + }); + + const actionsClient = actionsClientMock.create(); + const actionTypeRegistry = actionTypeRegistryMock.create(); + const savedObjectsClient = SavedObjectsClientMock.create(); + + server.config = () => { + return { + get(key: string) { + return config[key]; + }, + has(key: string) { + return config.hasOwnProperty(key); + }, + }; + }; + + server.register({ + name: 'actions', + register(pluginServer: Hapi.Server) { + pluginServer.expose('registerType', actionTypeRegistry.register); + pluginServer.expose('listTypes', actionTypeRegistry.list); + }, + }); + + server.decorate('request', 'getSavedObjectsClient', () => savedObjectsClient); + server.decorate('request', 'getActionsClient', () => actionsClient); + + return { server, savedObjectsClient, actionsClient, actionTypeRegistry }; +} diff --git a/x-pack/legacy/plugins/actions/server/routes/create.test.ts b/x-pack/legacy/plugins/actions/server/routes/create.test.ts new file mode 100644 index 0000000000000..4298645e26971 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/create.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { createRoute } from './create'; + +const { server, actionsClient } = createMockServer(); +createRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('creates an action with proper parameters', async () => { + const request = { + method: 'POST', + url: '/api/action', + payload: { + attributes: { + description: 'My description', + actionTypeId: 'abc', + actionTypeConfig: { foo: true }, + }, + migrationVersion: { + abc: '1.2.3', + }, + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }, + }; + const expectedResult = { + id: '1', + type: 'action', + attributes: { + description: 'My description', + actionTypeId: 'abc', + actionTypeConfig: { foo: true }, + actionTypeConfigSecrets: {}, + }, + migrationVersion: { + abc: '1.2.3', + }, + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }; + + actionsClient.create.mockResolvedValueOnce(expectedResult); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(expectedResult); + expect(actionsClient.create).toHaveBeenCalledTimes(1); + expect(actionsClient.create.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "attributes": Object { + "actionTypeConfig": Object { + "foo": true, + }, + "actionTypeId": "abc", + "description": "My description", + }, + "options": Object { + "migrationVersion": Object { + "abc": "1.2.3", + }, + "references": Array [ + Object { + "id": "234", + "name": "ref_0", + "type": "bcd", + }, + ], + }, + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/create.ts b/x-pack/legacy/plugins/actions/server/routes/create.ts new file mode 100644 index 0000000000000..d4deaaeffef3d --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/create.ts @@ -0,0 +1,71 @@ +/* + * 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'; +import { WithoutQueryAndParams, SavedObjectReference } from '../types'; + +interface CreateRequest extends WithoutQueryAndParams { + query: { + overwrite: boolean; + }; + params: { + id?: string; + }; + payload: { + attributes: { + description: string; + actionTypeId: string; + actionTypeConfig: Record; + }; + migrationVersion?: Record; + references: SavedObjectReference[]; + }; +} + +export function createRoute(server: Hapi.Server) { + server.route({ + method: 'POST', + path: `/api/action`, + options: { + validate: { + options: { + abortEarly: false, + }, + payload: Joi.object().keys({ + attributes: Joi.object() + .keys({ + description: Joi.string().required(), + actionTypeId: Joi.string().required(), + actionTypeConfig: Joi.object().required(), + }) + .required(), + migrationVersion: Joi.object().optional(), + references: Joi.array() + .items( + Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + }), + }, + }, + async handler(request: CreateRequest) { + const actionsClient = request.getActionsClient!(); + + return await actionsClient.create({ + attributes: request.payload.attributes, + options: { + migrationVersion: request.payload.migrationVersion, + references: request.payload.references, + }, + }); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/delete.test.ts b/x-pack/legacy/plugins/actions/server/routes/delete.test.ts new file mode 100644 index 0000000000000..37c7f3b2c89a9 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/delete.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { deleteRoute } from './delete'; + +const { server, actionsClient } = createMockServer(); +deleteRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('deletes an action with proper parameters', async () => { + const request = { + method: 'DELETE', + url: '/api/action/1', + }; + + actionsClient.delete.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.delete).toHaveBeenCalledTimes(1); + expect(actionsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "id": "1", + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/delete.ts b/x-pack/legacy/plugins/actions/server/routes/delete.ts new file mode 100644 index 0000000000000..eed8b7a10cded --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/delete.ts @@ -0,0 +1,35 @@ +/* + * 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 Hapi from 'hapi'; +import Joi from 'joi'; + +interface DeleteRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function deleteRoute(server: Hapi.Server) { + server.route({ + method: 'DELETE', + path: `/api/action/{id}`, + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: DeleteRequest) { + const { id } = request.params; + const actionsClient = request.getActionsClient!(); + return await actionsClient.delete({ id }); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/find.test.ts b/x-pack/legacy/plugins/actions/server/routes/find.test.ts new file mode 100644 index 0000000000000..afb8f583e541a --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/find.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { findRoute } from './find'; + +const { server, actionsClient } = createMockServer(); +findRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('sends proper arguments to action find function', async () => { + const request = { + method: 'GET', + url: + '/api/action/_find?' + + 'per_page=1&' + + 'page=1&' + + 'search=text*&' + + 'default_search_operator=AND&' + + 'search_fields=description&' + + 'sort_field=description&' + + 'fields=description', + }; + const expectedResult = { + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }; + + actionsClient.find.mockResolvedValueOnce(expectedResult); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(expectedResult); + expect(actionsClient.find).toHaveBeenCalledTimes(1); + expect(actionsClient.find.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "options": Object { + "defaultSearchOperator": "AND", + "fields": Array [ + "description", + ], + "hasReference": undefined, + "page": 1, + "perPage": 1, + "search": "text*", + "searchFields": Array [ + "description", + ], + "sortField": "description", + }, + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/find.ts b/x-pack/legacy/plugins/actions/server/routes/find.ts new file mode 100644 index 0000000000000..784b2541425fc --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/find.ts @@ -0,0 +1,82 @@ +/* + * 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'; + +import { WithoutQueryAndParams } from '../types'; + +interface FindRequest extends WithoutQueryAndParams { + query: { + per_page: number; + page: number; + search?: string; + default_search_operator: 'AND' | 'OR'; + search_fields?: string[]; + sort_field?: string; + has_reference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +export function findRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/_find`, + options: { + validate: { + query: Joi.object() + .keys({ + per_page: Joi.number() + .min(0) + .default(20), + page: Joi.number() + .min(1) + .default(1), + search: Joi.string() + .allow('') + .optional(), + default_search_operator: Joi.string() + .valid('OR', 'AND') + .default('OR'), + search_fields: Joi.array() + .items(Joi.string()) + .single(), + sort_field: Joi.string(), + has_reference: Joi.object() + .keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }) + .optional(), + fields: Joi.array() + .items(Joi.string()) + .single(), + }) + .default(), + }, + }, + async handler(request: FindRequest) { + const query = request.query; + const actionsClient = request.getActionsClient!(); + return await actionsClient.find({ + options: { + perPage: query.per_page, + page: query.page, + search: query.search, + defaultSearchOperator: query.default_search_operator, + searchFields: query.search_fields, + sortField: query.sort_field, + hasReference: query.has_reference, + fields: query.fields, + }, + }); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/get.test.ts b/x-pack/legacy/plugins/actions/server/routes/get.test.ts new file mode 100644 index 0000000000000..bec51eff1e803 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/get.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { getRoute } from './get'; + +const { server, actionsClient } = createMockServer(); +getRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls get with proper parameters', async () => { + const request = { + method: 'GET', + url: '/api/action/1', + }; + const expectedResult = { + id: '1', + type: 'action', + attributes: {}, + references: [], + }; + + actionsClient.get.mockResolvedValueOnce(expectedResult); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(expectedResult); + expect(actionsClient.get).toHaveBeenCalledTimes(1); + expect(actionsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "id": "1", + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/get.ts b/x-pack/legacy/plugins/actions/server/routes/get.ts new file mode 100644 index 0000000000000..e2c9c006bdf81 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/get.ts @@ -0,0 +1,35 @@ +/* + * 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 GetRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function getRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/{id}`, + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: GetRequest) { + const { id } = request.params; + const actionsClient = request.getActionsClient!(); + return await actionsClient.get({ id }); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/index.ts b/x-pack/legacy/plugins/actions/server/routes/index.ts new file mode 100644 index 0000000000000..7ed6dd222fe00 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { createRoute } from './create'; +export { deleteRoute } from './delete'; +export { findRoute } from './find'; +export { getRoute } from './get'; +export { updateRoute } from './update'; +export { listActionTypesRoute } from './list_action_types'; diff --git a/x-pack/legacy/plugins/actions/server/routes/list_action_types.test.ts b/x-pack/legacy/plugins/actions/server/routes/list_action_types.test.ts new file mode 100644 index 0000000000000..3cfda61864040 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/list_action_types.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { listActionTypesRoute } from './list_action_types'; + +const { server, actionTypeRegistry } = createMockServer(); +listActionTypesRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls the list function', async () => { + const request = { + method: 'GET', + url: '/api/action/types', + }; + const expectedResult = [ + { + id: '1', + name: 'One', + }, + ]; + + actionTypeRegistry.list.mockReturnValueOnce(expectedResult); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(expectedResult); + expect(actionTypeRegistry.list).toHaveBeenCalledTimes(1); + expect(actionTypeRegistry.list.mock.calls[0]).toMatchInlineSnapshot(`Array []`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts b/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts new file mode 100644 index 0000000000000..9ff04af72beaa --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/list_action_types.ts @@ -0,0 +1,17 @@ +/* + * 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 Hapi from 'hapi'; + +export function listActionTypesRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/types`, + async handler(request: Hapi.Request) { + return request.server.plugins.actions!.listTypes(); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/update.test.ts b/x-pack/legacy/plugins/actions/server/routes/update.test.ts new file mode 100644 index 0000000000000..4fe2ad80701e2 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/update.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { updateRoute } from './update'; + +const { server, actionsClient } = createMockServer(); +updateRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls the update function with proper parameters', async () => { + const request = { + method: 'PUT', + url: '/api/action/1', + payload: { + attributes: { + description: 'My description', + actionTypeConfig: { foo: true }, + }, + version: '2', + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }, + }; + const expectedResult = { + id: '1', + type: 'action', + attributes: { + description: 'My description', + actionTypeConfig: { foo: true }, + }, + version: '2', + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }; + + actionsClient.update.mockResolvedValueOnce(expectedResult); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(expectedResult); + expect(actionsClient.update).toHaveBeenCalledTimes(1); + expect(actionsClient.update.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "attributes": Object { + "actionTypeConfig": Object { + "foo": true, + }, + "description": "My description", + }, + "id": "1", + "options": Object { + "references": Array [ + Object { + "id": "234", + "name": "ref_0", + "type": "bcd", + }, + ], + "version": "2", + }, + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/actions/server/routes/update.ts b/x-pack/legacy/plugins/actions/server/routes/update.ts new file mode 100644 index 0000000000000..166dd5dd5510e --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/routes/update.ts @@ -0,0 +1,72 @@ +/* + * 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'; + +import { SavedObjectReference } from '../types'; + +interface UpdateRequest extends Hapi.Request { + payload: { + attributes: { + description: string; + actionTypeId: string; + actionTypeConfig: Record; + }; + version?: string; + references: SavedObjectReference[]; + }; +} + +export function updateRoute(server: Hapi.Server) { + server.route({ + method: 'PUT', + path: `/api/action/{id}`, + options: { + validate: { + options: { + abortEarly: false, + }, + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + payload: Joi.object() + .keys({ + attributes: Joi.object() + .keys({ + description: Joi.string().required(), + actionTypeConfig: Joi.object().required(), + }) + .required(), + version: Joi.string(), + references: Joi.array() + .items( + Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + }) + .required(), + }, + }, + async handler(request: UpdateRequest) { + const { id } = request.params; + const { attributes, version, references } = request.payload; + const options = { version, references }; + const actionsClient = request.getActionsClient!(); + return await actionsClient.update({ + id, + attributes, + options, + }); + }, + }); +} diff --git a/x-pack/legacy/plugins/actions/server/types.ts b/x-pack/legacy/plugins/actions/server/types.ts new file mode 100644 index 0000000000000..62d272b97be28 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/types.ts @@ -0,0 +1,45 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { ActionTypeRegistry } from './action_type_registry'; + +export type WithoutQueryAndParams = Pick>; + +export interface SavedObjectReference { + name: string; + type: string; + id: string; +} + +export interface Services { + callCluster(path: string, opts: any): Promise; + savedObjectsClient: SavedObjectsClientContract; + log: (tags: string | string[], data?: string | object | (() => any), timestamp?: number) => void; +} + +export interface ActionsPlugin { + registerType: ActionTypeRegistry['register']; + listTypes: ActionTypeRegistry['list']; + fire(options: { id: string; params: Record; basePath: string }): Promise; +} + +export interface ActionTypeExecutorOptions { + services: Services; + config: Record; + params: Record; +} + +export interface ActionType { + id: string; + name: string; + unencryptedAttributes?: string[]; + validate?: { + params?: any; + config?: any; + }; + executor({ services, config, params }: ActionTypeExecutorOptions): Promise; +} diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md new file mode 100644 index 0000000000000..27bf614da7044 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/README.md @@ -0,0 +1,304 @@ +# Kibana alerting + +The Kibana alerting plugin provides a common place to set up alerts. You can: + +- Register types of alerts +- List the types of registered alerts +- Perform CRUD actions on alerts + +## Terminology + +**Alert Type**: A function that takes parameters and fires actions to alert instances. + +**Alert**: A configuration that defines a schedule, an alert type w/ parameters, state information and actions. + +**Alert Instance**: The instance(s) created from an alert type execution. + +A Kibana alert detects a condition and fires one or more actions when that condition occurs. Alerts work by going through the followings steps: + +1. Run a periodic check to detect a condition (the check is provided by an Alert Type) +2. Convert that condition into one or more stateful Alert Instances +3. Map Alert Instances to pre-defined Actions, using templating +4. Execute the Actions + +## Usage + +1. Develop and register an alert type (see alert types -> example). +2. Create an alert using the RESTful API (see alerts -> create). + +## Alert types + +### Methods + +**server.plugins.alerting.registerType(options)** + +The following table describes the properties of the `options` object. + +|Property|Description|Type| +|---|---|---| +|id|Unique identifier for the alert type. For convention purposes, ids starting with `.` are reserved for built in alert types. We recommend using a convention like `.mySpecialAlert` for your alert types to avoid conflicting with another plugin.|string| +|name|A user-friendly name for the alert type. These will be displayed in dropdowns when choosing alert types.|string| +|validate.params|When developing an alert type, you can choose to accept a series of parameters. You may also have the parameters validated before they are passed to the `execute` function or created as an alert saved object. In order to do this, provide a joi schema that we will use to validate the `params` attribute.|Joi schema| +|execute|This is where the code of the alert type lives. This is a function to be called when executing an alert on an interval basis. For full details, see executor section below.|Function| + +### Executor + +This is the primary function for an alert type. Whenever the alert needs to execute, this function will perform the execution. It receives a variety of parameters. The following table describes the properties the executor receives. + +**execute(options)** + +|Property|Description| +|---|---| +|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| +|services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

**NOTE**: This currently only works when security is disabled. A future PR will add support for enabled security using Elasticsearch API tokens.| +|services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| +|scheduledRunAt|The date and time the alert type execution was scheduled to be called.| +|previousScheduledRunAt|The previous date and time the alert type was scheduled to be called.| +|params|Parameters for the execution. This is where the parameters you require will be passed in. (example threshold). Use alert type validation to ensure values are set before execution.| +|state|State returned from previous execution. This is the alert level state. What the executor returns will be serialized and provided here at the next execution.| + +### Example + +This example receives server and threshold as parameters. It will read the CPU usage of the server and fire actions if the reading is greater than the threshold. + +``` +server.plugins.alerting.registerType({ + id: 'my-alert-type', + name: 'My alert type', + validate: { + params: Joi.object() + .keys({ + server: Joi.string().required(), + threshold: Joi.number().min(0).max(1).required(), + }) + .required(), + }, + async execute({ + scheduledRunAt, + previousScheduledRunAt, + services, + params, + state, + }: AlertExecuteOptions) { + const { server, threshold } = params; // Let's assume params is { server: 'server_1', threshold: 0.8 } + + // Call a function to get the server's current CPU usage + const currentCpuUsage = await getCpuUsage(server); + + // Only fire if CPU usage is greater than threshold + if (currentCpuUsage > threshold) { + // The first argument is a unique identifier the alert instance is about. In this scenario + // the provided server will be used. Also, this id will be used to make `getState()` return + // previous state, if any, on matching identifiers. + const alertInstance = services.alertInstanceFactory(server); + + // State from last execution. This will exist if an alert instance was created and fired + // in the previous execution + const { cpuUsage: previousCpuUsage } = alertInstance.getState(); + + // Replace state entirely with new values + alertInstance.replaceState({ + cpuUsage: currentCpuUsage, + }); + + // 'default' refers to a group of actions to fire, see 'actions' in create alert section + alertInstance.fire('default', { + server, + hasCpuUsageIncreased: currentCpuUsage > previousCpuUsage, + }); + } + + // Returning updated alert type level state, this will become available + // within the `state` function parameter at the next execution + return { + // This is an example attribute you could set, it makes more sense to use this state when + // the alert type fires multiple instances but wants a single place to track certain values. + lastChecked: new Date(), + }; + }, +}); +``` + +This example only receives threshold as a parameter. It will read the CPU usage of all the servers and fire individual actions if the reading for a server is greater than the threshold. This is a better implementation than above as only one query is performed for all the servers instead of one query per server. + +``` +server.plugins.alerting.registerType({ + id: 'my-alert-type', + name: 'My alert type', + validate: { + params: Joi.object() + .keys({ + threshold: Joi.number().min(0).max(1).required(), + }) + .required(), + }, + async execute({ + scheduledRunAt, + previousScheduledRunAt, + services, + params, + state, + }: AlertExecuteOptions) { + const { threshold } = params; // Let's assume params is { threshold: 0.8 } + + // Call a function to get the CPU readings on all the servers. The result will be + // an array of { server, cpuUsage }. + const cpuUsageByServer = await getCpuUsageByServer(); + + for (const { server, cpuUsage: currentCpuUsage } of cpuUsageByServer) { + // Only fire if CPU usage is greater than threshold + if (currentCpuUsage > threshold) { + // The first argument is a unique identifier the alert instance is about. In this scenario + // the provided server will be used. Also, this id will be used to make `getState()` return + // previous state, if any, on matching identifiers. + const alertInstance = services.alertInstanceFactory(server); + + // State from last execution. This will exist if an alert instance was created and fired + // in the previous execution + const { cpuUsage: previousCpuUsage } = alertInstance.getState(); + + // Replace state entirely with new values + alertInstance.replaceState({ + cpuUsage: currentCpuUsage, + }); + + // 'default' refers to a group of actions to fire, see 'actions' in create alert section + alertInstance.fire('default', { + server, + hasCpuUsageIncreased: currentCpuUsage > previousCpuUsage, + }); + } + } + + // Single object containing state that isn't specific to a server, this will become available + // within the `state` function parameter at the next execution + return { + lastChecked: new Date(), + }; + }, +}); +``` + +## RESTful API + +Using an alert type requires you to create an alert that will contain parameters and actions for a given alert type. See below for CRUD operations using the API. + +#### `POST /api/alert`: Create alert + +Payload: + +|Property|Description|Type| +|---|---|---| +|alertTypeId|The id value of the alert type you want to call when the alert is scheduled to execute.|string| +|interval|The interval in milliseconds the alert should execute.|number| +|alertTypeParams|The parameters to pass in to the alert type executor `params` value. This will also validate against the alert type params validator if defined.|object| +|actions|Array of the following:
- `group` (string): We support grouping actions in the scenario of escalations or different types of alert instances. If you don't need this, feel free to use `default` as a value.
- `id` (string): The id of the action saved object to fire.
- `params` (object): The map to the `params` the action type will receive. In order to help apply context to strings, we handle them as mustache templates and pass in a default set of context. (see templating actions).|array| + +#### `DELETE /api/alert/{id}`: Delete alert + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the alert you're trying to delete.|string| + +#### `GET /api/alert/_find`: Find alerts + +Params: + +See the saved objects API documentation for find. All the properties are the same except you cannot pass in `type`. + +#### `GET /api/alert/{id}`: Get alert + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the alert you're trying to get.|string| + +#### `GET /api/alert/types`: List alert types + +No parameters. + +#### `PUT /api/alert/{id}`: Update alert + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the alert you're trying to update.|string| + +Payload: + +|Property|Description|Type| +|---|---|---| +|interval|The interval in milliseconds the alert should execute.|number| +|alertTypeParams|The parameters to pass in to the alert type executor `params` value. This will also validate against the alert type params validator if defined.|object| +|actions|Array of the following:
- `group` (string): We support grouping actions in the scenario of escalations or different types of alert instances. If you don't need this, feel free to use `default` as a value.
- `id` (string): The id of the action saved object to fire.
- `params` (object): There map to the `params` the action type will receive. In order to help apply context to strings, we handle them as mustache templates and pass in a default set of context. (see templating actions).|array| + +## Alert instance factory + +**alertInstanceFactory(id)** + +One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to fire actions. The id you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same id. These instances support state persisting between alert type execution, but will clear out once the alert instance stops firing. + +This factory returns an instance of `AlertInstance`. The alert instance class has the following methods, note that we have removed the methods that you shouldn't touch. + +|Method|Description| +|---|---| +|getState()|Get the current state of the alert instance.| +|fire(actionGroup, context)|Called to fire actions. The actionGroup relates to the group of alert `actions` to fire and the context will be used for templating purposes. This should only be called once per alert instance.| +|replaceState(state)|Used to replace the current state of the alert instance. This doesn't work like react, the entire state must be provided. Use this feature as you see fit. The state that is set will persist between alert type executions whenever you re-create an alert instance with the same id. The instance state will be erased when fire isn't called during an execution.| + +## Templating actions + +There needs to be a way to map alert context into action parameters. For this, we started off by adding template support. Any string within the `params` of an alert saved object's `actions` will be processed as a template and can inject context or state values. + +When an alert instance fires, the first argument is the `group` of actions to fire and the second is the context the alert exposes to templates. We iterate through each action params attributes recursively and render templates if they are a string. Templates have access to the `context` (provided by second argument of `.fire(...)` on an alert instance) and the alert instance's `state` (provided by the most recent `replaceState` call on an alert instance). + +### Examples + +The following code would be within an alert type. As you can see `cpuUsage ` will replace the state of the alert instance and `server` is the context for the alert instance to fire. The difference between the two is `cpuUsage ` will be accessible at the next execution. + +``` +alertInstanceFactory('server_1') + .replaceState({ + cpuUsage: 80, + }) + .fire('default', { + server: 'server_1', + }); +``` + +Below is an example of an alert that takes advantage of templating: + +``` +{ + ... + actions: [ + { + "group": "default", + "id": "3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5", + "params": { + "from": "example@elastic.co", + "to": ["destination@elastic.co"], + "subject": "A notification about {{context.server}}" + "body": "The server {{context.server}} has a CPU usage of {{state.cpuUsage}}%" + } + } + ] +} +``` + +The templating system will take the alert and alert type as described above and convert the action parameters to the following: + +``` +{ + "from": "example@elastic.co", + "to": ["destination@elastic.co"], + "subject": "A notification about server_1" + "body": "The server server_1 has a CPU usage of 80%" +} +``` + +There are limitations that we are aware of using only templates, and we are gathering feedback and use cases for these. (for example passing an array of strings to an action). diff --git a/x-pack/legacy/plugins/alerting/index.ts b/x-pack/legacy/plugins/alerting/index.ts new file mode 100644 index 0000000000000..f5350cf7afba9 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/index.ts @@ -0,0 +1,39 @@ +/* + * 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 { Legacy } from 'kibana'; +import { Root } from 'joi'; +import { init } from './server'; +import mappings from './mappings.json'; + +export { AlertingPlugin, AlertsClient, AlertType, AlertExecuteOptions } from './server'; + +export function alerting(kibana: any) { + return new kibana.Plugin({ + id: 'alerting', + configPrefix: 'xpack.alerting', + require: ['kibana', 'elasticsearch', 'actions', 'task_manager'], + isEnabled(config: Legacy.KibanaConfig) { + return ( + config.get('xpack.alerting.enabled') === true && + config.get('xpack.actions.enabled') === true && + config.get('xpack.encrypted_saved_objects.enabled') === true && + config.get('xpack.task_manager.enabled') === true + ); + }, + config(Joi: Root) { + return Joi.object() + .keys({ + enabled: Joi.boolean().default(true), + }) + .default(); + }, + init, + uiExports: { + mappings, + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/mappings.json b/x-pack/legacy/plugins/alerting/mappings.json new file mode 100644 index 0000000000000..16d20459bea04 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/mappings.json @@ -0,0 +1,34 @@ +{ + "alert": { + "properties": { + "alertTypeId": { + "type": "keyword" + }, + "interval": { + "type": "long" + }, + "actions": { + "type": "nested", + "properties": { + "group": { + "type": "keyword" + }, + "actionRef": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertTypeParams": { + "enabled": false, + "type": "object" + }, + "scheduledTaskId": { + "type": "keyword" + } + } + } +} diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.mock.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.mock.ts new file mode 100644 index 0000000000000..b6b36e4d4b7e0 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.mock.ts @@ -0,0 +1,23 @@ +/* + * 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 { AlertTypeRegistry } from './alert_type_registry'; + +type Schema = PublicMethodsOf; + +const createAlertTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const alertTypeRegistryMock = { + create: createAlertTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts new file mode 100644 index 0000000000000..c60c7b9ef889f --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -0,0 +1,152 @@ +/* + * 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/get_create_task_runner_function', () => ({ + getCreateTaskRunnerFunction: jest.fn().mockReturnValue(jest.fn()), +})); + +import { AlertTypeRegistry } from './alert_type_registry'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../task_manager/task_manager.mock'; + +const taskManager = taskManagerMock.create(); + +const alertTypeRegistryParams = { + getServices() { + return { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; + }, + taskManager, + fireAction: jest.fn(), + internalSavedObjectsRepository: SavedObjectsClientMock.create(), +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('has()', () => { + test('returns false for unregistered alert types', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + expect(registry.has('foo')).toEqual(false); + }); + + test('returns true for registered alert types', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + registry.register({ + id: 'foo', + name: 'Foo', + execute: jest.fn(), + }); + expect(registry.has('foo')).toEqual(true); + }); +}); + +describe('registry()', () => { + test('registers the executor with the task manager', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getCreateTaskRunnerFunction } = require('./lib/get_create_task_runner_function'); + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + getCreateTaskRunnerFunction.mockReturnValue(jest.fn()); + registry.register({ + id: 'test', + name: 'Test', + execute: jest.fn(), + }); + expect(taskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(taskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "alerting:test": Object { + "createTaskRunner": [MockFunction], + "title": "Test", + "type": "alerting:test", + }, + }, +] +`); + expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1); + const firstCall = getCreateTaskRunnerFunction.mock.calls[0][0]; + expect(firstCall.alertType).toMatchInlineSnapshot(` +Object { + "execute": [MockFunction], + "id": "test", + "name": "Test", +} +`); + expect(firstCall.internalSavedObjectsRepository).toBeTruthy(); + expect(firstCall.fireAction).toMatchInlineSnapshot(`[MockFunction]`); + }); + + test('should throw an error if type is already registered', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + registry.register({ + id: 'test', + name: 'Test', + execute: jest.fn(), + }); + expect(() => + registry.register({ + id: 'test', + name: 'Test', + execute: jest.fn(), + }) + ).toThrowErrorMatchingInlineSnapshot(`"Alert type \\"test\\" is already registered."`); + }); +}); + +describe('get()', () => { + test('should return registered type', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + registry.register({ + id: 'test', + name: 'Test', + execute: jest.fn(), + }); + const alertType = registry.get('test'); + expect(alertType).toMatchInlineSnapshot(` +Object { + "execute": [MockFunction], + "id": "test", + "name": "Test", +} +`); + }); + + test(`should throw an error if type isn't registered`, () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + expect(() => registry.get('test')).toThrowErrorMatchingInlineSnapshot( + `"Alert type \\"test\\" is not registered."` + ); + }); +}); + +describe('list()', () => { + test('should return empty when nothing is registered', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + const result = registry.list(); + expect(result).toMatchInlineSnapshot(`Array []`); + }); + + test('should return registered types', () => { + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + registry.register({ + id: 'test', + name: 'Test', + execute: jest.fn(), + }); + const result = registry.list(); + expect(result).toMatchInlineSnapshot(` +Array [ + Object { + "id": "test", + "name": "Test", + }, +] +`); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts new file mode 100644 index 0000000000000..61a358473ef46 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -0,0 +1,91 @@ +/* + * 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 Boom from 'boom'; +import { i18n } from '@kbn/i18n'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { AlertType, Services } from './types'; +import { TaskManager } from '../../task_manager'; +import { getCreateTaskRunnerFunction } from './lib'; +import { ActionsPlugin } from '../../actions'; + +interface ConstructorOptions { + getServices: (basePath: string) => Services; + taskManager: TaskManager; + fireAction: ActionsPlugin['fire']; + internalSavedObjectsRepository: SavedObjectsClientContract; +} + +export class AlertTypeRegistry { + private readonly getServices: (basePath: string) => Services; + private readonly taskManager: TaskManager; + private readonly fireAction: ActionsPlugin['fire']; + private readonly alertTypes: Map = new Map(); + private readonly internalSavedObjectsRepository: SavedObjectsClientContract; + + constructor({ + internalSavedObjectsRepository, + fireAction, + taskManager, + getServices, + }: ConstructorOptions) { + this.taskManager = taskManager; + this.fireAction = fireAction; + this.internalSavedObjectsRepository = internalSavedObjectsRepository; + this.getServices = getServices; + } + + public has(id: string) { + return this.alertTypes.has(id); + } + + public register(alertType: AlertType) { + if (this.has(alertType.id)) { + throw new Error( + i18n.translate('xpack.alerting.alertTypeRegistry.register.duplicateAlertTypeError', { + defaultMessage: 'Alert type "{id}" is already registered.', + values: { + id: alertType.id, + }, + }) + ); + } + this.alertTypes.set(alertType.id, alertType); + this.taskManager.registerTaskDefinitions({ + [`alerting:${alertType.id}`]: { + title: alertType.name, + type: `alerting:${alertType.id}`, + createTaskRunner: getCreateTaskRunnerFunction({ + alertType, + getServices: this.getServices, + fireAction: this.fireAction, + internalSavedObjectsRepository: this.internalSavedObjectsRepository, + }), + }, + }); + } + + public get(id: string): AlertType { + if (!this.has(id)) { + throw Boom.badRequest( + i18n.translate('xpack.alerting.alertTypeRegistry.get.missingAlertTypeError', { + defaultMessage: 'Alert type "{id}" is not registered.', + values: { + id, + }, + }) + ); + } + return this.alertTypes.get(id)!; + } + + public list() { + return Array.from(this.alertTypes).map(([alertTypeId, alertType]) => ({ + id: alertTypeId, + name: alertType.name, + })); + } +} diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts new file mode 100644 index 0000000000000..73a682753ac45 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { AlertsClient } from './alerts_client'; + +type Schema = PublicMethodsOf; + +const createAlertsClientMock = () => { + const mocked: jest.Mocked = { + create: jest.fn(), + get: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + }; + return mocked; +}; + +export const alertsClientMock = { + create: createAlertsClientMock, +}; diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts new file mode 100644 index 0000000000000..db7e70948d1de --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -0,0 +1,747 @@ +/* + * 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 { AlertsClient } from './alerts_client'; +import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../task_manager/task_manager.mock'; +import { alertTypeRegistryMock } from './alert_type_registry.mock'; + +const taskManager = taskManagerMock.create(); +const alertTypeRegistry = alertTypeRegistryMock.create(); +const savedObjectsClient = SavedObjectsClientMock.create(); + +const alertsClientParams = { + log: jest.fn(), + taskManager, + alertTypeRegistry, + savedObjectsClient, + basePath: '/s/default', +}; + +beforeEach(() => jest.resetAllMocks()); + +const mockedDate = new Date('2019-02-12T21:01:22.479Z'); +(global as any).Date = class Date { + constructor() { + return mockedDate; + } + static now() { + return mockedDate.getTime(); + } +}; + +function getMockData() { + return { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }; +} + +describe('create()', () => { + test('creates an alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async execute() {}, + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + sequenceNumber: 1, + primaryTerm: 1, + scheduledAt: new Date(), + attempts: 1, + status: 'idle', + runAt: new Date(), + state: {}, + params: {}, + }); + savedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + scheduledTaskId: 'task-123', + }, + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + }); + const result = await alertsClient.create({ data }); + expect(result).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": 10000, + "scheduledTaskId": "task-123", +} +`); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(savedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(savedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "interval": 10000, +} +`); + expect(savedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` +Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], +} +`); + expect(taskManager.schedule).toHaveBeenCalledTimes(1); + expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "params": Object { + "alertId": "1", + "basePath": "/s/default", + }, + "scope": Array [ + "alerting", + ], + "state": Object { + "alertInstances": Object {}, + "alertTypeState": Object {}, + "previousScheduledRunAt": null, + "scheduledRunAt": 2019-02-12T21:01:22.479Z, + }, + "taskType": "alerting:123", + }, +] +`); + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` +Object { + "scheduledTaskId": "task-123", +} +`); + expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` +Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], +} +`); + }); + + test('should validate alertTypeParams', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async execute() {}, + }); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"alertTypeParams invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); + + test('throws error if create saved object fails', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async execute() {}, + }); + savedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure')); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Test failure"` + ); + expect(taskManager.schedule).not.toHaveBeenCalled(); + }); + + test('attempts to remove saved object if scheduling failed', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async execute() {}, + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockRejectedValueOnce(new Error('Test failure')); + savedObjectsClient.delete.mockResolvedValueOnce({}); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Test failure"` + ); + expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "alert", + "1", +] +`); + }); + + test('returns task manager error if cleanup fails, logs to console', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async execute() {}, + }); + savedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockRejectedValueOnce(new Error('Task manager error')); + savedObjectsClient.delete.mockRejectedValueOnce(new Error('Saved object delete error')); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Task manager error"` + ); + expect(alertsClientParams.log).toHaveBeenCalledTimes(1); + expect(alertsClientParams.log.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Array [ + "alerting", + "error", + ], + "Failed to cleanup alert \\"1\\" after scheduling task failed. Error: Saved object delete error", +] +`); + }); + + test('throws an error if alert type not registerd', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const data = getMockData(); + alertTypeRegistry.get.mockImplementation(() => { + throw new Error('Invalid type'); + }); + await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid type"` + ); + }); +}); + +describe('get()', () => { + test('calls saved objects client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + const result = await alertsClient.get({ id: '1' }); + expect(result).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": 10000, +} +`); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "alert", + "1", +] +`); + }); + + test(`throws an error when references aren't found`, async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [], + }); + await expect(alertsClient.get({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Reference action_0 not found"` + ); + }); +}); + +describe('find()', () => { + test('calls saved objects client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + const result = await alertsClient.find(); + expect(result).toMatchInlineSnapshot(` +Array [ + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": 10000, + }, +] +`); + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "type": "alert", + }, +] +`); + }); +}); + +describe('delete()', () => { + test('successfully removes an alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + scheduledTaskId: 'task-123', + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + savedObjectsClient.delete.mockResolvedValueOnce({ + success: true, + }); + taskManager.remove.mockResolvedValueOnce({ + index: '.task_manager', + id: 'task-123', + sequenceNumber: 1, + primaryTerm: 1, + result: '', + }); + const result = await alertsClient.delete({ id: '1' }); + expect(result).toMatchInlineSnapshot(` +Object { + "success": true, +} +`); + expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "alert", + "1", +] +`); + expect(taskManager.remove).toHaveBeenCalledTimes(1); + expect(taskManager.remove.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + "task-123", +] +`); + }); +}); + +describe('update()', () => { + test('updates given parameters', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + async execute() {}, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + }, + references: [], + }); + savedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + const result = await alertsClient.update({ + id: '1', + data: { + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + options: { + version: '123', + }, + }); + expect(result).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": 10000, +} +`); + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "interval": 10000, +} +`); + expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` +Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", +} +`); + }); + + it('should validate alertTypeParams', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertTypeRegistry.get.mockReturnValueOnce({ + id: '123', + name: 'Test', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async execute() {}, + }); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + }, + references: [], + }); + await expect( + alertsClient.update({ + id: '1', + data: { + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + options: { + version: '123', + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"alertTypeParams invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts new file mode 100644 index 0000000000000..d1b2c5b9b8ef8 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -0,0 +1,248 @@ +/* + * 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 { omit } from 'lodash'; +import { SavedObjectsClientContract, SavedObjectReference } from 'src/core/server'; +import { Alert, RawAlert, AlertTypeRegistry, AlertAction, Log } from './types'; +import { TaskManager } from '../../task_manager'; +import { validateAlertTypeParams } from './lib'; + +interface ConstructorOptions { + log: Log; + taskManager: TaskManager; + savedObjectsClient: SavedObjectsClientContract; + alertTypeRegistry: AlertTypeRegistry; + basePath: string; +} + +interface FindOptions { + options?: { + perPage?: number; + page?: number; + search?: string; + defaultSearchOperator?: 'AND' | 'OR'; + searchFields?: string[]; + sortField?: string; + hasReference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +interface CreateOptions { + data: Alert; + options?: { + migrationVersion?: Record; + }; +} + +interface UpdateOptions { + id: string; + data: { + interval: number; + actions: AlertAction[]; + alertTypeParams: Record; + }; + options?: { version?: string }; +} + +export class AlertsClient { + private readonly log: Log; + private readonly basePath: string; + private readonly taskManager: TaskManager; + private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly alertTypeRegistry: AlertTypeRegistry; + + constructor({ + alertTypeRegistry, + savedObjectsClient, + taskManager, + log, + basePath, + }: ConstructorOptions) { + this.log = log; + this.basePath = basePath; + this.taskManager = taskManager; + this.alertTypeRegistry = alertTypeRegistry; + this.savedObjectsClient = savedObjectsClient; + } + + public async create({ data, options }: CreateOptions) { + // Throws an error if alert type isn't registered + const alertType = this.alertTypeRegistry.get(data.alertTypeId); + const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.alertTypeParams); + const { alert: rawAlert, references } = this.getRawAlert({ + ...data, + alertTypeParams: validatedAlertTypeParams, + }); + const createdAlert = await this.savedObjectsClient.create('alert', rawAlert, { + ...options, + references, + }); + let scheduledTask; + try { + scheduledTask = await this.scheduleAlert(createdAlert.id, rawAlert, this.basePath); + } catch (e) { + // Cleanup data, something went wrong scheduling the task + try { + await this.savedObjectsClient.delete('alert', createdAlert.id); + } catch (err) { + // Skip the cleanup error and throw the task manager error to avoid confusion + this.log( + ['alerting', 'error'], + `Failed to cleanup alert "${createdAlert.id}" after scheduling task failed. Error: ${ + err.message + }` + ); + } + throw e; + } + await this.savedObjectsClient.update( + 'alert', + createdAlert.id, + { + scheduledTaskId: scheduledTask.id, + }, + { references } + ); + createdAlert.attributes.scheduledTaskId = scheduledTask.id; + return this.getAlertFromRaw(createdAlert.id, createdAlert.attributes, references); + } + + public async get({ id }: { id: string }) { + const result = await this.savedObjectsClient.get('alert', id); + return this.getAlertFromRaw(result.id, result.attributes, result.references); + } + + public async find({ options = {} }: FindOptions = {}) { + const results = await this.savedObjectsClient.find({ + ...options, + type: 'alert', + }); + return results.saved_objects.map(result => + this.getAlertFromRaw(result.id, result.attributes, result.references) + ); + } + + public async delete({ id }: { id: string }) { + const alertSavedObject = await this.savedObjectsClient.get('alert', id); + const removeResult = await this.savedObjectsClient.delete('alert', id); + await this.taskManager.remove(alertSavedObject.attributes.scheduledTaskId); + return removeResult; + } + + public async update({ id, data, options = {} }: UpdateOptions) { + const existingObject = await this.savedObjectsClient.get('alert', id); + const { alertTypeId } = existingObject.attributes; + const alertType = this.alertTypeRegistry.get(alertTypeId); + + // Validate + const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.alertTypeParams); + + const { actions, references } = this.extractReferences(data.actions); + const updatedObject = await this.savedObjectsClient.update( + 'alert', + id, + { + ...data, + alertTypeParams: validatedAlertTypeParams, + actions, + }, + { + ...options, + references, + } + ); + return this.getAlertFromRaw(id, updatedObject.attributes, updatedObject.references); + } + + private async scheduleAlert(id: string, alert: RawAlert, basePath: string) { + return await this.taskManager.schedule({ + taskType: `alerting:${alert.alertTypeId}`, + params: { + alertId: id, + basePath, + }, + state: { + // This is here because we can't rely on the task manager's internal runAt. + // It changes it for timeout, etc when a task is running. + scheduledRunAt: new Date(Date.now() + alert.interval), + previousScheduledRunAt: null, + alertTypeState: {}, + alertInstances: {}, + }, + scope: ['alerting'], + }); + } + + private extractReferences(actions: Alert['actions']) { + const references: SavedObjectReference[] = []; + const rawActions = actions.map((action, i) => { + const actionRef = `action_${i}`; + references.push({ + name: actionRef, + type: 'action', + id: action.id, + }); + return { + ...omit(action, 'id'), + actionRef, + }; + }) as RawAlert['actions']; + return { + actions: rawActions, + references, + }; + } + + private injectReferencesIntoActions( + actions: RawAlert['actions'], + references: SavedObjectReference[] + ) { + return actions.map((action, i) => { + const reference = references.find(ref => ref.name === action.actionRef); + if (!reference) { + throw new Error(`Reference ${action.actionRef} not found`); + } + return { + ...omit(action, 'actionRef'), + id: reference.id, + }; + }) as Alert['actions']; + } + + private getAlertFromRaw( + id: string, + rawAlert: Partial, + references: SavedObjectReference[] + ) { + if (!rawAlert.actions) { + return { + id, + ...rawAlert, + }; + } + const actions = this.injectReferencesIntoActions(rawAlert.actions, references); + return { + id, + ...rawAlert, + actions, + }; + } + + private getRawAlert(alert: Alert): { alert: RawAlert; references: SavedObjectReference[] } { + const { references, actions } = this.extractReferences(alert.actions); + return { + alert: { + ...alert, + actions, + }, + references, + }; + } +} diff --git a/x-pack/legacy/plugins/alerting/server/index.ts b/x-pack/legacy/plugins/alerting/server/index.ts new file mode 100644 index 0000000000000..ef3c7111b379f --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { init } from './init'; +export { AlertType, AlertingPlugin, AlertExecuteOptions } from './types'; +export { AlertsClient } from './alerts_client'; diff --git a/x-pack/legacy/plugins/alerting/server/init.ts b/x-pack/legacy/plugins/alerting/server/init.ts new file mode 100644 index 0000000000000..aa6e939b9b616 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/init.ts @@ -0,0 +1,72 @@ +/* + * 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 { Legacy } from 'kibana'; +import { + createAlertRoute, + deleteAlertRoute, + findRoute, + getRoute, + listAlertTypesRoute, + updateAlertRoute, +} from './routes'; +import { AlertingPlugin, Services } from './types'; +import { AlertTypeRegistry } from './alert_type_registry'; +import { AlertsClient } from './alerts_client'; + +export function init(server: Legacy.Server) { + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); + const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( + callWithInternalUser + ); + + function getServices(basePath: string): Services { + const fakeRequest: any = { + headers: {}, + getBasePath: () => basePath, + }; + return { + log: server.log, + callCluster: callWithInternalUser, + savedObjectsClient: server.savedObjects.getScopedSavedObjectsClient(fakeRequest), + }; + } + + const { taskManager } = server; + const alertTypeRegistry = new AlertTypeRegistry({ + getServices, + taskManager: taskManager!, + fireAction: server.plugins.actions!.fire, + internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, + }); + + // Register routes + createAlertRoute(server); + deleteAlertRoute(server); + findRoute(server); + getRoute(server); + listAlertTypesRoute(server); + updateAlertRoute(server); + + // Expose functions + server.decorate('request', 'getAlertsClient', function() { + const request = this; + const savedObjectsClient = request.getSavedObjectsClient(); + const alertsClient = new AlertsClient({ + log: server.log, + savedObjectsClient, + alertTypeRegistry, + taskManager: taskManager!, + basePath: request.getBasePath(), + }); + return alertsClient; + }); + const exposedFunctions: AlertingPlugin = { + registerType: alertTypeRegistry.register.bind(alertTypeRegistry), + listTypes: alertTypeRegistry.list.bind(alertTypeRegistry), + }; + server.expose(exposedFunctions); +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts b/x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts new file mode 100644 index 0000000000000..dee0510e94321 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { AlertInstance } from './alert_instance'; + +describe('shouldFire()', () => { + test('defaults to false', () => { + const alertInstance = new AlertInstance(); + expect(alertInstance.shouldFire()).toEqual(false); + }); +}); + +describe('getFireOptions()', () => { + test('defaults to undefined', () => { + const alertInstance = new AlertInstance(); + expect(alertInstance.getFireOptions()).toBeUndefined(); + }); +}); + +describe('resetFire()', () => { + test('makes shouldFire() return false', () => { + const alertInstance = new AlertInstance(); + alertInstance.fire('default'); + expect(alertInstance.shouldFire()).toEqual(true); + alertInstance.resetFire(); + expect(alertInstance.shouldFire()).toEqual(false); + }); + + test('makes getFireOptions() return undefined', () => { + const alertInstance = new AlertInstance(); + alertInstance.fire('default'); + expect(alertInstance.getFireOptions()).toEqual({ + actionGroup: 'default', + context: {}, + state: {}, + }); + alertInstance.resetFire(); + expect(alertInstance.getFireOptions()).toBeUndefined(); + }); +}); + +describe('getState()', () => { + test('returns state passed to constructor', () => { + const state = { foo: true }; + const alertInstance = new AlertInstance({ state }); + expect(alertInstance.getState()).toEqual(state); + }); +}); + +describe('getMeta()', () => { + test('returns meta passed to constructor', () => { + const meta = { bar: true }; + const alertInstance = new AlertInstance({ meta }); + expect(alertInstance.getMeta()).toEqual(meta); + }); +}); + +describe('fire()', () => { + test('makes shouldFire() return true', () => { + const alertInstance = new AlertInstance({ state: { foo: true }, meta: { bar: true } }); + alertInstance.replaceState({ otherField: true }).fire('default', { field: true }); + expect(alertInstance.shouldFire()).toEqual(true); + }); + + test('makes getFireOptions() return given options', () => { + const alertInstance = new AlertInstance({ state: { foo: true }, meta: { bar: true } }); + alertInstance.replaceState({ otherField: true }).fire('default', { field: true }); + expect(alertInstance.getFireOptions()).toEqual({ + actionGroup: 'default', + context: { field: true }, + state: { otherField: true }, + }); + }); + + test('cannot fire twice', () => { + const alertInstance = new AlertInstance(); + alertInstance.fire('default', { field: true }); + expect(() => + alertInstance.fire('default', { field: false }) + ).toThrowErrorMatchingInlineSnapshot(`"Alert instance already fired, cannot fire twice"`); + }); +}); + +describe('replaceState()', () => { + test('replaces previous state', () => { + const alertInstance = new AlertInstance({ state: { foo: true } }); + alertInstance.replaceState({ bar: true }); + expect(alertInstance.getState()).toEqual({ bar: true }); + alertInstance.replaceState({ baz: true }); + expect(alertInstance.getState()).toEqual({ baz: true }); + }); +}); + +describe('replaceMeta()', () => { + test('replaces previous meta', () => { + const alertInstance = new AlertInstance({ meta: { foo: true } }); + alertInstance.replaceMeta({ bar: true }); + expect(alertInstance.getMeta()).toEqual({ bar: true }); + alertInstance.replaceMeta({ baz: true }); + expect(alertInstance.getMeta()).toEqual({ baz: true }); + }); +}); + +describe('toJSON', () => { + test('only serializes state and meta', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { bar: true }, + }); + expect(JSON.stringify(alertInstance)).toEqual('{"state":{"foo":true},"meta":{"bar":true}}'); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts new file mode 100644 index 0000000000000..22a74562e7d49 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts @@ -0,0 +1,72 @@ +/* + * 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 { State, Context } from '../types'; + +interface ConstructorOptions { + state?: Record; + meta?: Record; +} + +export class AlertInstance { + private fireOptions?: Record; + private meta: Record; + private state: Record; + + constructor({ state = {}, meta = {} }: ConstructorOptions = {}) { + this.state = state; + this.meta = meta; + } + + shouldFire() { + return this.fireOptions !== undefined; + } + + getFireOptions() { + return this.fireOptions; + } + + resetFire() { + this.fireOptions = undefined; + return this; + } + + getState() { + return this.state; + } + + getMeta() { + return this.meta; + } + + fire(actionGroup: string, context: Context = {}) { + if (this.shouldFire()) { + throw new Error('Alert instance already fired, cannot fire twice'); + } + this.fireOptions = { actionGroup, context, state: this.state }; + return this; + } + + replaceState(state: State) { + this.state = state; + return this; + } + + replaceMeta(meta: Record) { + this.meta = meta; + return this; + } + + /** + * Used to serialize alert instance state + */ + toJSON() { + return { + state: this.state, + meta: this.meta, + }; + } +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts b/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts new file mode 100644 index 0000000000000..a83c0c936e00a --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { AlertInstance } from './alert_instance'; +import { createAlertInstanceFactory } from './create_alert_instance_factory'; + +test('creates new instances for ones not passed in', () => { + const alertInstanceFactory = createAlertInstanceFactory({}); + const result = alertInstanceFactory('1'); + expect(result).toMatchInlineSnapshot(` +Object { + "meta": Object {}, + "state": Object {}, +} +`); +}); + +test('reuses existing instances', () => { + const alertInstance = new AlertInstance({ state: { foo: true }, meta: { bar: false } }); + const alertInstanceFactory = createAlertInstanceFactory({ + '1': alertInstance, + }); + const result = alertInstanceFactory('1'); + expect(result).toMatchInlineSnapshot(` +Object { + "meta": Object { + "bar": false, + }, + "state": Object { + "foo": true, + }, +} +`); +}); + +test('mutates given instances', () => { + const alertInstances = {}; + const alertInstanceFactory = createAlertInstanceFactory(alertInstances); + alertInstanceFactory('1'); + expect(alertInstances).toMatchInlineSnapshot(` +Object { + "1": Object { + "meta": Object {}, + "state": Object {}, + }, +} +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts b/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts new file mode 100644 index 0000000000000..0b29262ddcc07 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts @@ -0,0 +1,17 @@ +/* + * 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 { AlertInstance } from './alert_instance'; + +export function createAlertInstanceFactory(alertInstances: Record) { + return (id: string): AlertInstance => { + if (!alertInstances[id]) { + alertInstances[id] = new AlertInstance(); + } + + return alertInstances[id]; + }; +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts new file mode 100644 index 0000000000000..9ed3ec58dc042 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { createFireHandler } from './create_fire_handler'; + +const createFireHandlerParams = { + basePath: '/s/default', + fireAction: jest.fn(), + alertSavedObject: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, +}; + +beforeEach(() => jest.resetAllMocks()); + +test('calls fireAction per selected action', async () => { + const fireHandler = createFireHandler(createFireHandlerParams); + await fireHandler('default', {}, {}); + expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); + expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "basePath": "/s/default", + "id": "1", + "params": Object { + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + }, +] +`); +}); + +test('limits fireAction per action group', async () => { + const fireHandler = createFireHandler(createFireHandlerParams); + await fireHandler('other-group', {}, {}); + expect(createFireHandlerParams.fireAction).toMatchInlineSnapshot(`[MockFunction]`); +}); + +test('context attribute gets parameterized', async () => { + const fireHandler = createFireHandler(createFireHandlerParams); + await fireHandler('default', { value: 'context-val' }, {}); + expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); + expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "basePath": "/s/default", + "id": "1", + "params": Object { + "contextVal": "My context-val goes here", + "foo": true, + "stateVal": "My goes here", + }, + }, +] +`); +}); + +test('state attribute gets parameterized', async () => { + const fireHandler = createFireHandler(createFireHandlerParams); + await fireHandler('default', {}, { value: 'state-val' }); + expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); + expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "basePath": "/s/default", + "id": "1", + "params": Object { + "contextVal": "My goes here", + "foo": true, + "stateVal": "My state-val goes here", + }, + }, +] +`); +}); + +test('throws error if reference not found', async () => { + const params = { + basePath: '/s/default', + fireAction: jest.fn(), + alertSavedObject: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + }, + }, + ], + }, + references: [], + }, + }; + const fireHandler = createFireHandler(params); + await expect( + fireHandler('default', {}, { value: 'state-val' }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action reference \\"action_0\\" not found in alert id: 1"` + ); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts new file mode 100644 index 0000000000000..3a271365105c7 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts @@ -0,0 +1,50 @@ +/* + * 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 { SavedObject } from 'src/core/server'; +import { RawAlertAction, State, Context } from '../types'; +import { ActionsPlugin } from '../../../actions'; +import { transformActionParams } from './transform_action_params'; + +interface CreateFireHandlerOptions { + fireAction: ActionsPlugin['fire']; + alertSavedObject: SavedObject; + basePath: string; +} + +export function createFireHandler({ + fireAction, + alertSavedObject, + basePath, +}: CreateFireHandlerOptions) { + return async (actionGroup: string, context: Context, state: State) => { + const alertActions: RawAlertAction[] = alertSavedObject.attributes.actions; + const actions = alertActions + .filter(({ group }) => group === actionGroup) + .map(action => { + const actionReference = alertSavedObject.references.find( + obj => obj.name === action.actionRef + ); + if (!actionReference) { + throw new Error( + `Action reference "${action.actionRef}" not found in alert id: ${alertSavedObject.id}` + ); + } + return { + ...action, + id: actionReference.id, + params: transformActionParams(action.params, state, context), + }; + }); + for (const action of actions) { + await fireAction({ + id: action.id, + params: action.params, + basePath, + }); + } + }; +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts new file mode 100644 index 0000000000000..5a7cebddfcdf1 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { AlertExecuteOptions } from '../types'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; + +const mockedNow = new Date('2019-06-03T18:55:25.982Z'); +const mockedLastRunAt = new Date('2019-06-03T18:55:20.982Z'); +(global as any).Date = class Date extends global.Date { + static now() { + return mockedNow.getTime(); + } +}; + +const savedObjectsClient = SavedObjectsClientMock.create(); + +const getCreateTaskRunnerFunctionParams = { + getServices() { + return { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), + }; + }, + alertType: { + id: 'test', + name: 'My test alert', + execute: jest.fn(), + }, + fireAction: jest.fn(), + internalSavedObjectsRepository: savedObjectsClient, +}; + +const mockedTaskInstance = { + runAt: mockedLastRunAt, + state: { + scheduledRunAt: mockedLastRunAt, + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, +}; + +const mockedAlertTypeSavedObject = { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], +}; + +beforeEach(() => jest.resetAllMocks()); + +test('successfully executes the task', async () => { + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + const runnerResult = await runner.run(); + expect(runnerResult).toMatchInlineSnapshot(` +Object { + "runAt": 2019-06-03T18:55:30.982Z, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousScheduledRunAt": 2019-06-03T18:55:20.982Z, + "scheduledRunAt": 2019-06-03T18:55:30.982Z, + }, +} +`); + expect(getCreateTaskRunnerFunctionParams.alertType.execute).toHaveBeenCalledTimes(1); + const call = getCreateTaskRunnerFunctionParams.alertType.execute.mock.calls[0][0]; + expect(call.params).toMatchInlineSnapshot(` +Object { + "bar": true, +} +`); + expect(call.scheduledRunAt).toMatchInlineSnapshot(`2019-06-03T18:55:20.982Z`); + expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.services.alertInstanceFactory).toBeTruthy(); + expect(call.services.callCluster).toBeTruthy(); + expect(call.services).toBeTruthy(); +}); + +test('fireAction is called per alert instance that fired', async () => { + getCreateTaskRunnerFunctionParams.alertType.execute.mockImplementation( + ({ services }: AlertExecuteOptions) => { + services.alertInstanceFactory('1').fire('default'); + } + ); + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + await runner.run(); + expect(getCreateTaskRunnerFunctionParams.fireAction).toHaveBeenCalledTimes(1); + expect(getCreateTaskRunnerFunctionParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "basePath": undefined, + "id": "1", + "params": Object { + "foo": true, + }, + }, +] +`); +}); + +test('persists alertInstances passed in from state, only if they fire', async () => { + getCreateTaskRunnerFunctionParams.alertType.execute.mockImplementation( + ({ services }: AlertExecuteOptions) => { + services.alertInstanceFactory('1').fire('default'); + } + ); + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + const runner = createTaskRunner({ + taskInstance: { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + }, + }); + const runnerResult = await runner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` +Object { + "1": Object { + "meta": Object { + "lastFired": 1559588125982, + }, + "state": Object { + "bar": false, + }, + }, +} +`); +}); + +test('validates params before executing the alert type', async () => { + const createTaskRunner = getCreateTaskRunnerFunction({ + ...getCreateTaskRunnerFunctionParams, + alertType: { + ...getCreateTaskRunnerFunctionParams.alertType, + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + }, + }); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); + await expect(runner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"alertTypeParams invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts new file mode 100644 index 0000000000000..311b8b5dd0564 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts @@ -0,0 +1,108 @@ +/* + * 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 { SavedObjectsClientContract } from 'src/core/server'; +import { ActionsPlugin } from '../../../actions'; +import { AlertType, Services, AlertServices } from '../types'; +import { TaskInstance } from '../../../task_manager'; +import { createFireHandler } from './create_fire_handler'; +import { createAlertInstanceFactory } from './create_alert_instance_factory'; +import { AlertInstance } from './alert_instance'; +import { getNextRunAt } from './get_next_run_at'; +import { validateAlertTypeParams } from './validate_alert_type_params'; + +interface CreateTaskRunnerFunctionOptions { + getServices: (basePath: string) => Services; + alertType: AlertType; + fireAction: ActionsPlugin['fire']; + internalSavedObjectsRepository: SavedObjectsClientContract; +} + +interface TaskRunnerOptions { + taskInstance: TaskInstance; +} + +export function getCreateTaskRunnerFunction({ + getServices, + alertType, + fireAction, + internalSavedObjectsRepository, +}: CreateTaskRunnerFunctionOptions) { + return ({ taskInstance }: TaskRunnerOptions) => { + return { + run: async () => { + const alertSavedObject = await internalSavedObjectsRepository.get( + 'alert', + taskInstance.params.alertId + ); + + // Validate + const validatedAlertTypeParams = validateAlertTypeParams( + alertType, + alertSavedObject.attributes.alertTypeParams + ); + + const fireHandler = createFireHandler({ + alertSavedObject, + fireAction, + basePath: taskInstance.params.basePath, + }); + const alertInstances: Record = {}; + const alertInstancesData = taskInstance.state.alertInstances || {}; + for (const id of Object.keys(alertInstancesData)) { + alertInstances[id] = new AlertInstance(alertInstancesData[id]); + } + const alertInstanceFactory = createAlertInstanceFactory(alertInstances); + + const alertTypeServices: AlertServices = { + ...getServices(taskInstance.params.basePath), + alertInstanceFactory, + }; + + const alertTypeState = await alertType.execute({ + services: alertTypeServices, + params: validatedAlertTypeParams, + state: taskInstance.state.alertTypeState || {}, + scheduledRunAt: taskInstance.state.scheduledRunAt, + previousScheduledRunAt: taskInstance.state.previousScheduledRunAt, + }); + + await Promise.all( + Object.keys(alertInstances).map(alertInstanceId => { + const alertInstance = alertInstances[alertInstanceId]; + + // Unpersist any alert instances that were not explicitly fired in this alert execution + if (!alertInstance.shouldFire()) { + delete alertInstances[alertInstanceId]; + return; + } + + const { actionGroup, context, state } = alertInstance.getFireOptions()!; + alertInstance.replaceMeta({ lastFired: Date.now() }); + alertInstance.resetFire(); + return fireHandler(actionGroup, context, state); + }) + ); + + const nextRunAt = getNextRunAt( + new Date(taskInstance.state.scheduledRunAt), + alertSavedObject.attributes.interval + ); + + return { + state: { + alertTypeState, + alertInstances, + // We store nextRunAt ourselves since task manager changes runAt when executing a task + scheduledRunAt: nextRunAt, + previousScheduledRunAt: taskInstance.state.scheduledRunAt, + }, + runAt: nextRunAt, + }; + }, + }; + }; +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts b/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts new file mode 100644 index 0000000000000..c24559b6c801f --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts @@ -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 { getNextRunAt } from './get_next_run_at'; + +const mockedNow = new Date('2019-06-03T18:55:25.982Z'); +(global as any).Date = class Date extends global.Date { + static now() { + return mockedNow.getTime(); + } +}; + +test('Adds interface to given date when result is > Date.now()', () => { + const currentRunAt = new Date('2019-06-03T18:55:23.982Z'); + const result = getNextRunAt(currentRunAt, 10000); + expect(result).toEqual(new Date('2019-06-03T18:55:33.982Z')); +}); + +test('Uses Date.now() when the result would of been a date in the past', () => { + const currentRunAt = new Date('2019-06-03T18:55:13.982Z'); + const result = getNextRunAt(currentRunAt, 10000); + expect(result).toEqual(mockedNow); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts b/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts new file mode 100644 index 0000000000000..87f521fccad25 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export function getNextRunAt(currentRunAt: Date, interval: number) { + let nextRunAt = currentRunAt.getTime() + interval; + if (nextRunAt < Date.now()) { + // To prevent returning dates in the past, we'll return now instead + nextRunAt = Date.now(); + } + return new Date(nextRunAt); +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/index.ts b/x-pack/legacy/plugins/alerting/server/lib/index.ts new file mode 100644 index 0000000000000..c847fc6f5d1ef --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { AlertInstance } from './alert_instance'; +export { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; +export { validateAlertTypeParams } from './validate_alert_type_params'; diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts b/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts new file mode 100644 index 0000000000000..57498416b9612 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { transformActionParams } from './transform_action_params'; + +test('skips non string parameters', () => { + const params = { + boolean: true, + number: 1, + empty1: null, + empty2: undefined, + date: '2019-02-12T21:01:22.479Z', + }; + const result = transformActionParams(params, {}, {}); + expect(result).toMatchInlineSnapshot(` +Object { + "boolean": true, + "date": "2019-02-12T21:01:22.479Z", + "empty1": null, + "empty2": undefined, + "number": 1, +} +`); +}); + +test('missing parameters get emptied out', () => { + const params = { + message1: '{{context.value}}', + message2: 'This message "{{context.value2}}" is missing', + }; + const result = transformActionParams(params, {}, {}); + expect(result).toMatchInlineSnapshot(` +Object { + "message1": "", + "message2": "This message \\"\\" is missing", +} +`); +}); + +test('context parameters are passed to templates', () => { + const params = { + message: 'Value "{{context.foo}}" exists', + }; + const result = transformActionParams(params, {}, { foo: 'fooVal' }); + expect(result).toMatchInlineSnapshot(` +Object { + "message": "Value \\"fooVal\\" exists", +} +`); +}); + +test('state parameters are passed to templates', () => { + const params = { + message: 'Value "{{state.bar}}" exists', + }; + const result = transformActionParams(params, { bar: 'barVal' }, {}); + expect(result).toMatchInlineSnapshot(` +Object { + "message": "Value \\"barVal\\" exists", +} +`); +}); + +test('works recursively', () => { + const params = { + body: { + message: 'State: "{{state.value}}", Context: "{{context.value}}"', + }, + }; + const result = transformActionParams(params, { value: 'state' }, { value: 'context' }); + expect(result).toMatchInlineSnapshot(` +Object { + "body": Object { + "message": "State: \\"state\\", Context: \\"context\\"", + }, +} +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts b/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts new file mode 100644 index 0000000000000..985715146375d --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts @@ -0,0 +1,23 @@ +/* + * 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 Mustache from 'mustache'; +import { isPlainObject } from 'lodash'; +import { AlertActionParams, State, Context } from '../types'; + +export function transformActionParams(params: AlertActionParams, state: State, context: Context) { + const result: AlertActionParams = {}; + for (const [key, value] of Object.entries(params)) { + if (isPlainObject(value)) { + result[key] = transformActionParams(value as AlertActionParams, state, context); + } else if (typeof value !== 'string') { + result[key] = value; + } else { + result[key] = Mustache.render(value, { context, state }); + } + } + return result; +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.test.ts b/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.test.ts new file mode 100644 index 0000000000000..0ee1b7de319d6 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { validateAlertTypeParams } from './validate_alert_type_params'; + +test('should return passed in params when validation not defined', () => { + const result = validateAlertTypeParams( + { + id: 'my-alert-type', + name: 'My description', + async execute() {}, + }, + { + foo: true, + } + ); + expect(result).toEqual({ foo: true }); +}); + +test('should validate and apply defaults when params is valid', () => { + const result = validateAlertTypeParams( + { + id: 'my-alert-type', + name: 'My description', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + param2: Joi.string().default('default-value'), + }) + .required(), + }, + async execute() {}, + }, + { param1: 'value' } + ); + expect(result).toEqual({ + param1: 'value', + param2: 'default-value', + }); +}); + +test('should validate and throw error when params is invalid', () => { + expect(() => + validateAlertTypeParams( + { + id: 'my-alert-type', + name: 'My description', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async execute() {}, + }, + {} + ) + ).toThrowErrorMatchingInlineSnapshot( + `"alertTypeParams invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + ); +}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.ts b/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.ts new file mode 100644 index 0000000000000..fc7b10936fe2b --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/validate_alert_type_params.ts @@ -0,0 +1,23 @@ +/* + * 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 Boom from 'boom'; +import { AlertType } from '../types'; + +export function validateAlertTypeParams>( + alertType: AlertType, + params: T +): T { + const validator = alertType.validate && alertType.validate.params; + if (!validator) { + return params; + } + const { error, value } = validator.validate(params); + if (error) { + throw Boom.badRequest(`alertTypeParams invalid: ${error.message}`); + } + return value; +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/_mock_server.ts b/x-pack/legacy/plugins/alerting/server/routes/_mock_server.ts new file mode 100644 index 0000000000000..b5defca9319a9 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/_mock_server.ts @@ -0,0 +1,46 @@ +/* + * 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 Hapi from 'hapi'; +import { alertsClientMock } from '../alerts_client.mock'; +import { alertTypeRegistryMock } from '../alert_type_registry.mock'; + +const defaultConfig = { + 'kibana.index': '.kibana', +}; + +export function createMockServer(config: Record = defaultConfig) { + const server = new Hapi.Server({ + port: 0, + }); + + const alertsClient = alertsClientMock.create(); + const alertTypeRegistry = alertTypeRegistryMock.create(); + + server.config = () => { + return { + get(key: string) { + return config[key]; + }, + has(key: string) { + return config.hasOwnProperty(key); + }, + }; + }; + + server.register({ + name: 'alerting', + register(pluginServer: Hapi.Server) { + pluginServer.expose('registerType', alertTypeRegistry.register); + pluginServer.expose('listTypes', alertTypeRegistry.list); + }, + }); + + server.decorate('request', 'getAlertsClient', () => alertsClient); + server.decorate('request', 'getBasePath', () => '/s/default'); + + return { server, alertsClient, alertTypeRegistry }; +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/create.test.ts b/x-pack/legacy/plugins/alerting/server/routes/create.test.ts new file mode 100644 index 0000000000000..bc48f00c53b52 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/create.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { createAlertRoute } from './create'; + +const { server, alertsClient } = createMockServer(); +createAlertRoute(server); + +const mockedAlert = { + alertTypeId: '1', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], +}; + +beforeEach(() => jest.resetAllMocks()); + +test('creates an alert with proper parameters', async () => { + const request = { + method: 'POST', + url: '/api/alert', + payload: mockedAlert, + }; + + alertsClient.create.mockResolvedValueOnce({ + ...mockedAlert, + id: '123', + }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toMatchInlineSnapshot(` +Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "1", + "alertTypeParams": Object { + "bar": true, + }, + "id": "123", + "interval": 10000, +} +`); + expect(alertsClient.create).toHaveBeenCalledTimes(1); + expect(alertsClient.create.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "1", + "alertTypeParams": Object { + "bar": true, + }, + "interval": 10000, + }, + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/create.ts b/x-pack/legacy/plugins/alerting/server/routes/create.ts new file mode 100644 index 0000000000000..93b2906b26ff0 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/create.ts @@ -0,0 +1,52 @@ +/* + * 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 Hapi from 'hapi'; +import Joi from 'joi'; +import { AlertAction } from '../types'; + +interface ScheduleRequest extends Hapi.Request { + payload: { + alertTypeId: string; + interval: number; + actions: AlertAction[]; + alertTypeParams: Record; + }; +} + +export function createAlertRoute(server: Hapi.Server) { + server.route({ + method: 'POST', + path: '/api/alert', + options: { + validate: { + options: { + abortEarly: false, + }, + payload: Joi.object() + .keys({ + alertTypeId: Joi.string().required(), + interval: Joi.number().required(), + alertTypeParams: Joi.object().required(), + actions: Joi.array() + .items( + Joi.object().keys({ + group: Joi.string().required(), + id: Joi.string().required(), + params: Joi.object().required(), + }) + ) + .required(), + }) + .required(), + }, + }, + async handler(request: ScheduleRequest) { + const alertsClient = request.getAlertsClient!(); + return await alertsClient.create({ data: request.payload }); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts b/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts new file mode 100644 index 0000000000000..80cddf4b56ff5 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { deleteAlertRoute } from './delete'; + +const { server, alertsClient } = createMockServer(); +deleteAlertRoute(server); + +beforeEach(() => jest.resetAllMocks()); + +test('deletes an alert with proper parameters', async () => { + const request = { + method: 'DELETE', + url: '/api/alert/1', + }; + + alertsClient.delete.mockResolvedValueOnce({}); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({}); + expect(alertsClient.delete).toHaveBeenCalledTimes(1); + expect(alertsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "id": "1", + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/delete.ts b/x-pack/legacy/plugins/alerting/server/routes/delete.ts new file mode 100644 index 0000000000000..9352bd3726832 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/delete.ts @@ -0,0 +1,35 @@ +/* + * 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 Hapi from 'hapi'; +import Joi from 'joi'; + +interface DeleteRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function deleteAlertRoute(server: Hapi.Server) { + server.route({ + method: 'DELETE', + path: '/api/alert/{id}', + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: DeleteRequest) { + const { id } = request.params; + const alertsClient = request.getAlertsClient!(); + return await alertsClient.delete({ id }); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/find.test.ts b/x-pack/legacy/plugins/alerting/server/routes/find.test.ts new file mode 100644 index 0000000000000..8bc41a51dc7a1 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/find.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { findRoute } from './find'; + +const { server, alertsClient } = createMockServer(); +findRoute(server); + +beforeEach(() => jest.resetAllMocks()); + +test('sends proper arguments to alert find function', async () => { + const request = { + method: 'GET', + url: + '/api/alert/_find?' + + 'per_page=1&' + + 'page=1&' + + 'search=text*&' + + 'default_search_operator=AND&' + + 'search_fields=description&' + + 'sort_field=description&' + + 'fields=description', + }; + + alertsClient.find.mockResolvedValueOnce([]); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual([]); + expect(alertsClient.find).toHaveBeenCalledTimes(1); + expect(alertsClient.find.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "options": Object { + "defaultSearchOperator": "AND", + "fields": Array [ + "description", + ], + "hasReference": undefined, + "page": 1, + "perPage": 1, + "search": "text*", + "searchFields": Array [ + "description", + ], + "sortField": "description", + }, + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/find.ts b/x-pack/legacy/plugins/alerting/server/routes/find.ts new file mode 100644 index 0000000000000..e8f691c89f8fb --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/find.ts @@ -0,0 +1,82 @@ +/* + * 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'; + +import { WithoutQueryAndParams } from '../types'; + +interface FindRequest extends WithoutQueryAndParams { + query: { + per_page: number; + page: number; + search?: string; + default_search_operator: 'AND' | 'OR'; + search_fields?: string[]; + sort_field?: string; + has_reference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +export function findRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: '/api/alert/_find', + options: { + validate: { + query: Joi.object() + .keys({ + per_page: Joi.number() + .min(0) + .default(20), + page: Joi.number() + .min(1) + .default(1), + search: Joi.string() + .allow('') + .optional(), + default_search_operator: Joi.string() + .valid('OR', 'AND') + .default('OR'), + search_fields: Joi.array() + .items(Joi.string()) + .single(), + sort_field: Joi.string(), + has_reference: Joi.object() + .keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }) + .optional(), + fields: Joi.array() + .items(Joi.string()) + .single(), + }) + .default(), + }, + }, + async handler(request: FindRequest) { + const { query } = request; + const alertsClient = request.getAlertsClient!(); + return await alertsClient.find({ + options: { + perPage: query.per_page, + page: query.page, + search: query.search, + defaultSearchOperator: query.default_search_operator, + searchFields: query.search_fields, + sortField: query.sort_field, + hasReference: query.has_reference, + fields: query.fields, + }, + }); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/get.test.ts b/x-pack/legacy/plugins/alerting/server/routes/get.test.ts new file mode 100644 index 0000000000000..ab9b0dca9f9b1 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/get.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { getRoute } from './get'; + +const { server, alertsClient } = createMockServer(); +getRoute(server); + +const mockedAlert = { + id: '1', + alertTypeId: '1', + interval: 10000, + alertTypeParams: { + bar: true, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], +}; + +beforeEach(() => jest.resetAllMocks()); + +test('calls get with proper parameters', async () => { + const request = { + method: 'GET', + url: '/api/alert/1', + }; + + alertsClient.get.mockResolvedValueOnce(mockedAlert); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(mockedAlert); + expect(alertsClient.get).toHaveBeenCalledTimes(1); + expect(alertsClient.get.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "id": "1", + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/get.ts b/x-pack/legacy/plugins/alerting/server/routes/get.ts new file mode 100644 index 0000000000000..87cdec17db99c --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/get.ts @@ -0,0 +1,35 @@ +/* + * 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 GetRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function getRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/alert/{id}`, + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: GetRequest) { + const { id } = request.params; + const alertsClient = request.getAlertsClient!(); + return await alertsClient.get({ id }); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/index.ts b/x-pack/legacy/plugins/alerting/server/routes/index.ts new file mode 100644 index 0000000000000..8f92d3c912805 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { createAlertRoute } from './create'; +export { deleteAlertRoute } from './delete'; +export { findRoute } from './find'; +export { getRoute } from './get'; +export { listAlertTypesRoute } from './list_alert_types'; +export { updateAlertRoute } from './update'; diff --git a/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.test.ts b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.test.ts new file mode 100644 index 0000000000000..d9d4bf48a971b --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { listAlertTypesRoute } from './list_alert_types'; + +const { server, alertTypeRegistry } = createMockServer(); +listAlertTypesRoute(server); + +beforeEach(() => jest.resetAllMocks()); + +test('calls the list function', async () => { + const request = { + method: 'GET', + url: '/api/alert/types', + }; + + alertTypeRegistry.list.mockReturnValueOnce([]); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual([]); + expect(alertTypeRegistry.list).toHaveBeenCalledTimes(1); + expect(alertTypeRegistry.list.mock.calls[0]).toMatchInlineSnapshot(`Array []`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts new file mode 100644 index 0000000000000..1c342e8cd6a25 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/list_alert_types.ts @@ -0,0 +1,17 @@ +/* + * 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 Hapi from 'hapi'; + +export function listAlertTypesRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/alert/types`, + async handler(request: Hapi.Request) { + return request.server.plugins.alerting!.listTypes(); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/routes/update.test.ts b/x-pack/legacy/plugins/alerting/server/routes/update.test.ts new file mode 100644 index 0000000000000..37edc6a03880c --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/update.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { updateAlertRoute } from './update'; + +const { server, alertsClient } = createMockServer(); +updateAlertRoute(server); + +beforeEach(() => jest.resetAllMocks()); + +const mockedResponse = { + id: '1', + alertTypeId: '1', + interval: 12000, + alertTypeParams: { + otherField: false, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + baz: true, + }, + }, + ], +}; + +test('calls the update function with proper parameters', async () => { + const request = { + method: 'PUT', + url: '/api/alert/1', + payload: { + interval: 12000, + alertTypeParams: { + otherField: false, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + baz: true, + }, + }, + ], + }, + }; + + alertsClient.update.mockResolvedValueOnce(mockedResponse); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual(mockedResponse); + expect(alertsClient.update).toHaveBeenCalledTimes(1); + expect(alertsClient.update.mock.calls[0]).toMatchInlineSnapshot(` +Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "baz": true, + }, + }, + ], + "alertTypeParams": Object { + "otherField": false, + }, + "interval": 12000, + }, + "id": "1", + }, +] +`); +}); diff --git a/x-pack/legacy/plugins/alerting/server/routes/update.ts b/x-pack/legacy/plugins/alerting/server/routes/update.ts new file mode 100644 index 0000000000000..134aed52925d6 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/routes/update.ts @@ -0,0 +1,55 @@ +/* + * 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'; +import { AlertAction } from '../types'; + +interface UpdateRequest extends Hapi.Request { + params: { + id: string; + }; + payload: { + alertTypeId: string; + interval: number; + actions: AlertAction[]; + alertTypeParams: Record; + }; +} + +export function updateAlertRoute(server: Hapi.Server) { + server.route({ + method: 'PUT', + path: '/api/alert/{id}', + options: { + validate: { + options: { + abortEarly: false, + }, + payload: Joi.object() + .keys({ + interval: Joi.number().required(), + alertTypeParams: Joi.object().required(), + actions: Joi.array() + .items( + Joi.object().keys({ + group: Joi.string().required(), + id: Joi.string().required(), + params: Joi.object().required(), + }) + ) + .required(), + }) + .required(), + }, + }, + async handler(request: UpdateRequest) { + const { id } = request.params; + const alertsClient = request.getAlertsClient!(); + return await alertsClient.update({ id, data: request.payload }); + }, + }); +} diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts new file mode 100644 index 0000000000000..83724280553ee --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -0,0 +1,83 @@ +/* + * 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 { SavedObjectAttributes, SavedObjectsClientContract } from 'src/core/server'; +import { AlertInstance } from './lib'; +import { AlertTypeRegistry } from './alert_type_registry'; + +export type State = Record; +export type Context = Record; +export type WithoutQueryAndParams = Pick>; + +export type Log = ( + tags: string | string[], + data?: string | object | (() => any), + timestamp?: number +) => void; + +export interface Services { + log: Log; + callCluster(path: string, opts: any): Promise; + savedObjectsClient: SavedObjectsClientContract; +} + +export interface AlertServices extends Services { + alertInstanceFactory: (id: string) => AlertInstance; +} + +export interface AlertExecuteOptions { + scheduledRunAt: Date; + previousScheduledRunAt?: Date; + services: AlertServices; + params: Record; + state: State; +} + +export interface AlertType { + id: string; + name: string; + validate?: { + params?: any; + }; + execute: ({ services, params, state }: AlertExecuteOptions) => Promise; +} + +export type AlertActionParams = SavedObjectAttributes; + +export interface AlertAction { + group: string; + id: string; + params: AlertActionParams; +} + +export interface RawAlertAction extends SavedObjectAttributes { + group: string; + actionRef: string; + params: AlertActionParams; +} + +export interface Alert { + alertTypeId: string; + interval: number; + actions: AlertAction[]; + alertTypeParams: Record; + scheduledTaskId?: string; +} + +export interface RawAlert extends SavedObjectAttributes { + alertTypeId: string; + interval: number; + actions: RawAlertAction[]; + alertTypeParams: SavedObjectAttributes; + scheduledTaskId?: string; +} + +export interface AlertingPlugin { + registerType: AlertTypeRegistry['register']; + listTypes: AlertTypeRegistry['list']; +} + +export type AlertTypeRegistry = PublicMethodsOf; diff --git a/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.mock.ts b/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.mock.ts new file mode 100644 index 0000000000000..7c6e37c7e5d4c --- /dev/null +++ b/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.mock.ts @@ -0,0 +1,21 @@ +/* + * 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 { Plugin } from './plugin'; +type EncryptedSavedObjectsPlugin = ReturnType; + +const createEncryptedSavedObjectsMock = () => { + const mocked: jest.Mocked = { + isEncryptionError: jest.fn(), + registerType: jest.fn(), + getDecryptedAsInternalUser: jest.fn(), + }; + return mocked; +}; + +export const encryptedSavedObjectsMock = { + create: createEncryptedSavedObjectsMock, +}; diff --git a/x-pack/legacy/plugins/task_manager/index.d.ts b/x-pack/legacy/plugins/task_manager/index.d.ts new file mode 100644 index 0000000000000..3b85a3e9d73fe --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/index.d.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { TaskManager } from './types'; +export { TaskInstance, ConcreteTaskInstance } from './task'; diff --git a/x-pack/legacy/plugins/task_manager/task.ts b/x-pack/legacy/plugins/task_manager/task.ts index 393c0a8fb28cb..9b7191491a27e 100644 --- a/x-pack/legacy/plugins/task_manager/task.ts +++ b/x-pack/legacy/plugins/task_manager/task.ts @@ -53,7 +53,7 @@ export interface RunResult { * The state which will be passed to the next run of this task (if this is a * recurring task). See the RunContext type definition for more details. */ - state: object; + state: Record; } export const validateRunResult = Joi.object({ @@ -180,14 +180,14 @@ export interface TaskInstance { * A task-specific set of parameters, used by the task's run function to tailor * its work. This is generally user-input, such as { sms: '333-444-2222' }. */ - params: object; + params: Record; /** * The state passed into the task's run function, and returned by the previous * run. If there was no previous run, or if the previous run did not return * any state, this will be the empy object: {} */ - state: object; + state: Record; /** * The id of the user who scheduled this task. @@ -249,5 +249,5 @@ export interface ConcreteTaskInstance extends TaskInstance { * run. If there was no previous run, or if the previous run did not return * any state, this will be the empy object: {} */ - state: object; + state: Record; } diff --git a/x-pack/legacy/plugins/task_manager/task_manager.mock.ts b/x-pack/legacy/plugins/task_manager/task_manager.mock.ts new file mode 100644 index 0000000000000..7b3b2671cdd35 --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/task_manager.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { TaskManager } from './task_manager'; + +type Schema = PublicMethodsOf; + +const createTaskManagerMock = () => { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + remove: jest.fn(), + }; + return mocked; +}; + +export const taskManagerMock = { + create: createTaskManagerMock, +}; diff --git a/x-pack/legacy/plugins/task_manager/types.ts b/x-pack/legacy/plugins/task_manager/types.ts new file mode 100644 index 0000000000000..c86ae1c3fd98d --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/types.ts @@ -0,0 +1,9 @@ +/* + * 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 { TaskManager as TaskManagerClass } from './task_manager'; + +export type TaskManager = PublicMethodsOf; diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/server_log.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/server_log.ts new file mode 100644 index 0000000000000..9e044b2db873a --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/server_log.ts @@ -0,0 +1,45 @@ +/* + * 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'; + +// eslint-disable-next-line import/no-default-export +export default function serverLogTest({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('create server-log action', () => { + it('should return 200 when creating a builtin server-log action', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'A server.log action', + actionTypeId: 'kibana.server-log', + actionTypeConfig: {}, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + type: 'action', + id: resp.body.id, + attributes: { + description: 'A server.log action', + actionTypeId: 'kibana.server-log', + actionTypeConfig: {}, + }, + references: [], + updated_at: resp.body.updated_at, + version: resp.body.version, + }); + expect(typeof resp.body.id).to.be('string'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/constants.ts b/x-pack/test/api_integration/apis/actions/constants.ts new file mode 100644 index 0000000000000..d73977c26ca94 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/constants.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const ES_ARCHIVER_ACTION_ID = '19cfba7c-711a-4170-8590-9a99a281e85c'; diff --git a/x-pack/test/api_integration/apis/actions/create.ts b/x-pack/test/api_integration/apis/actions/create.ts new file mode 100644 index 0000000000000..afaa3d3adbbd1 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/create.ts @@ -0,0 +1,142 @@ +/* + * 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'; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('create', () => { + after(() => esArchiver.unload('empty_kibana')); + + it('should return 200 when creating an action and not return encrypted attributes', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action', + actionTypeId: 'test.index-record', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + type: 'action', + id: resp.body.id, + attributes: { + description: 'My action', + actionTypeId: 'test.index-record', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + references: [], + updated_at: resp.body.updated_at, + version: resp.body.version, + }); + expect(typeof resp.body.id).to.be('string'); + }); + }); + + it(`should return 400 when action type isn't registered`, async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action', + actionTypeId: 'test.unregistered-action-type', + actionTypeConfig: {}, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Action type "test.unregistered-action-type" is not registered.', + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "attributes" fails because ["attributes" is required]', + validation: { + source: 'payload', + keys: ['attributes'], + }, + }); + }); + }); + + it('should return 400 when payload attributes are empty and invalid', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "description" fails because ["description" is required], child "actionTypeId" fails because ["actionTypeId" is required], child "actionTypeConfig" fails because ["actionTypeConfig" is required]]', + validation: { + source: 'payload', + keys: [ + 'attributes.description', + 'attributes.actionTypeId', + 'attributes.actionTypeConfig', + ], + }, + }); + }); + }); + + it(`should return 400 when actionTypeConfig isn't valid`, async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'my description', + actionTypeId: 'test.index-record', + actionTypeConfig: { + unencrypted: 'my unencrypted text', + }, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'The following actionTypeConfig attributes are invalid: encrypted [any.required]', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/delete.ts b/x-pack/test/api_integration/apis/actions/delete.ts new file mode 100644 index 0000000000000..2c4a56f3e0fae --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/delete.ts @@ -0,0 +1,42 @@ +/* + * 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 { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function deleteActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + beforeEach(() => esArchiver.load('actions/basic')); + afterEach(() => esArchiver.unload('actions/basic')); + + it('should return 200 when deleting an action', async () => { + await supertest + .delete(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .expect(200, {}); + }); + + it(`should return 404 when action doesn't exist`, async () => { + await supertest + .delete('/api/action/2') + .set('kbn-xsrf', 'foo') + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/find.ts b/x-pack/test/api_integration/apis/actions/find.ts new file mode 100644 index 0000000000000..04cef99a642f7 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/find.ts @@ -0,0 +1,74 @@ +/* + * 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 { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function findActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + before(() => esArchiver.load('actions/basic')); + after(() => esArchiver.unload('actions/basic')); + + it('should return 200 with individual responses', async () => { + await supertest + .get( + '/api/action/_find?search=test.index-record&search_fields=actionTypeId&fields=description' + ) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + version: resp.body.saved_objects[0].version, + references: [], + attributes: { + description: 'My action', + }, + }, + ], + }); + }); + }); + + it('should not return encrypted attributes', async () => { + await supertest + .get('/api/action/_find?search=test.index-record&search_fields=actionTypeId') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + version: resp.body.saved_objects[0].version, + references: [], + attributes: { + description: 'My action', + actionTypeId: 'test.index-record', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }, + ], + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/get.ts b/x-pack/test/api_integration/apis/actions/get.ts new file mode 100644 index 0000000000000..001c52a6c5294 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/get.ts @@ -0,0 +1,54 @@ +/* + * 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 { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function getActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + before(() => esArchiver.load('actions/basic')); + after(() => esArchiver.unload('actions/basic')); + + it('should return 200 when finding a record and not return encrypted attributes', async () => { + await supertest + .get(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + references: [], + version: resp.body.version, + attributes: { + actionTypeId: 'test.index-record', + description: 'My action', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }); + }); + }); + + it('should return 404 when not finding a record', async () => { + await supertest + .get('/api/action/2') + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/index.ts b/x-pack/test/api_integration/apis/actions/index.ts new file mode 100644 index 0000000000000..86a19f2f749fe --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function actionsTests({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('Actions', () => { + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./list_action_types')); + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./builtin_action_types/server_log')); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/list_action_types.ts b/x-pack/test/api_integration/apis/actions/list_action_types.ts new file mode 100644 index 0000000000000..b1ce39d788c24 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/list_action_types.ts @@ -0,0 +1,33 @@ +/* + * 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'; + +// eslint-disable-next-line import/no-default-export +export default function listActionTypesTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('list_action_types', () => { + it('should return 200 with list of action types containing defaults', async () => { + await supertest + .get('/api/action/types') + .expect(200) + .then((resp: any) => { + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect( + resp.body.some(createActionTypeMatcher('test.index-record', 'Test: Index Record')) + ).to.be(true); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/update.ts b/x-pack/test/api_integration/apis/actions/update.ts new file mode 100644 index 0000000000000..4cae681701111 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/update.ts @@ -0,0 +1,193 @@ +/* + * 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 { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function updateActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + beforeEach(() => esArchiver.load('actions/basic')); + afterEach(() => esArchiver.unload('actions/basic')); + + it('should return 200 when updating a document', async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + references: [], + version: resp.body.version, + updated_at: resp.body.updated_at, + attributes: { + actionTypeId: 'test.index-record', + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }); + }); + }); + + it('should not be able to pass null to actionTypeConfig', async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: null, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "actionTypeConfig" fails because ["actionTypeConfig" must be an object]]', + validation: { + source: 'payload', + keys: ['attributes.actionTypeConfig'], + }, + }); + }); + }); + + it('should not return encrypted attributes', async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + references: [], + version: resp.body.version, + updated_at: resp.body.updated_at, + attributes: { + actionTypeId: 'test.index-record', + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }); + }); + }); + + it('should return 404 when updating a non existing document', async () => { + await supertest + .put('/api/action/2') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + }, + }) + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "attributes" fails because ["attributes" is required]', + validation: { source: 'payload', keys: ['attributes'] }, + }); + }); + }); + + it('should return 400 when payload attributes are empty and invalid', async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "description" fails because ["description" is required], child "actionTypeConfig" fails because ["actionTypeConfig" is required]]', + validation: { + source: 'payload', + keys: ['attributes.description', 'attributes.actionTypeConfig'], + }, + }); + }); + }); + + it(`should return 400 when actionTypeConfig isn't valid`, async () => { + await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'The following actionTypeConfig attributes are invalid: encrypted [any.required]', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/alerts.ts b/x-pack/test/api_integration/apis/alerting/alerts.ts new file mode 100644 index 0000000000000..7f94dcfddef1c --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/alerts.ts @@ -0,0 +1,144 @@ +/* + * 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 { getTestAlertData, setupEsTestIndex, destroyEsTestIndex } from './utils'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; +import { ES_ARCHIVER_ACTION_ID } from './constants'; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const retry = getService('retry'); + + describe('alerts', () => { + let esTestIndexName: string; + const createdAlertIds: string[] = []; + + before(async () => { + ({ name: esTestIndexName } = await setupEsTestIndex(es)); + await esArchiver.load('actions/basic'); + }); + after(async () => { + await Promise.all( + createdAlertIds.map(id => { + return supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + }) + ); + await esArchiver.unload('actions/basic'); + await destroyEsTestIndex(es); + }); + + it('should schedule task, run alert and fire actions', async () => { + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + interval: 100, + alertTypeId: 'test.always-firing', + alertTypeParams: { + index: esTestIndexName, + reference: 'create-test-1', + }, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + index: esTestIndexName, + reference: 'create-test-1', + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }) + ) + .expect(200) + .then((resp: any) => { + createdAlertIds.push(resp.body.id); + }); + const alertTestRecord = await retry.tryForTime(5000, async () => { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source: 'alert:test.always-firing', + }, + }, + { + term: { + reference: 'create-test-1', + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + expect(alertTestRecord._source).to.eql({ + source: 'alert:test.always-firing', + reference: 'create-test-1', + state: {}, + params: { + index: esTestIndexName, + reference: 'create-test-1', + }, + }); + const actionTestRecord = await retry.tryForTime(5000, async () => { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source: 'action:test.index-record', + }, + }, + { + term: { + reference: 'create-test-1', + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + expect(actionTestRecord._source).to.eql({ + config: { + encrypted: 'This value should be encrypted', + unencrypted: `This value shouldn't get encrypted`, + }, + params: { + index: esTestIndexName, + reference: 'create-test-1', + message: 'instanceContextValue: true, instanceStateValue: true', + }, + reference: 'create-test-1', + source: 'action:test.index-record', + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/constants.ts b/x-pack/test/api_integration/apis/alerting/constants.ts new file mode 100644 index 0000000000000..d73977c26ca94 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/constants.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const ES_ARCHIVER_ACTION_ID = '19cfba7c-711a-4170-8590-9a99a281e85c'; diff --git a/x-pack/test/api_integration/apis/alerting/create.ts b/x-pack/test/api_integration/apis/alerting/create.ts new file mode 100644 index 0000000000000..f08d279c5b399 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/create.ts @@ -0,0 +1,135 @@ +/* + * 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 { getTestAlertData } from './utils'; +import { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('create', () => { + const createdAlertIds: string[] = []; + + before(() => esArchiver.load('actions/basic')); + after(async () => { + await Promise.all( + createdAlertIds.map(id => { + return supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + }) + ); + await esArchiver.unload('actions/basic'); + }); + + async function getScheduledTask(id: string) { + return await es.get({ + id, + index: '.kibana_task_manager', + }); + } + + it('should return 200 when creating an alert', async () => { + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200) + .then(async (resp: any) => { + createdAlertIds.push(resp.body.id); + expect(resp.body).to.eql({ + id: resp.body.id, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + alertTypeId: 'test.noop', + alertTypeParams: {}, + interval: 10000, + scheduledTaskId: resp.body.scheduledTaskId, + }); + expect(typeof resp.body.scheduledTaskId).to.be('string'); + const { _source: taskRecord } = await getScheduledTask(resp.body.scheduledTaskId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + alertId: resp.body.id, + basePath: '', + }); + }); + }); + + it(`should return 400 when alert type isn't registered`, async () => { + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unregistered-alert-type', + }) + ) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Alert type "test.unregistered-alert-type" is not registered.', + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "alertTypeId" fails because ["alertTypeId" is required]. child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', + validation: { + source: 'payload', + keys: ['alertTypeId', 'interval', 'alertTypeParams', 'actions'], + }, + }); + }); + }); + + it(`should return 400 when alertTypeParams isn't valid`, async () => { + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.validation', + }) + ) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'alertTypeParams invalid: child "param1" fails because ["param1" is required]', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/delete.ts b/x-pack/test/api_integration/apis/alerting/delete.ts new file mode 100644 index 0000000000000..380d4e6bef635 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/delete.ts @@ -0,0 +1,57 @@ +/* + * 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 { getTestAlertData } from './utils'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createDeleteTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('delete', () => { + let alertId: string; + let scheduledTaskId: string; + + before(async () => { + await esArchiver.load('actions/basic'); + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200) + .then((resp: any) => { + alertId = resp.body.id; + scheduledTaskId = resp.body.scheduledTaskId; + }); + }); + after(() => esArchiver.unload('actions/basic')); + + async function getScheduledTask(id: string) { + return await es.get({ + id, + index: '.kibana_task_manager', + }); + } + + it('should return 200 when deleting an alert and removing scheduled task', async () => { + await supertest + .delete(`/api/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + let hasThrownError = false; + try { + await getScheduledTask(scheduledTaskId); + } catch (e) { + hasThrownError = true; + expect(e.status).to.eql(404); + } + expect(hasThrownError).to.eql(true); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/find.ts b/x-pack/test/api_integration/apis/alerting/find.ts new file mode 100644 index 0000000000000..3aa47acd19f9a --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/find.ts @@ -0,0 +1,65 @@ +/* + * 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 { getTestAlertData } from './utils'; +import { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createFindTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + let alertId: string; + + before(async () => { + await esArchiver.load('actions/basic'); + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200) + .then((resp: any) => { + alertId = resp.body.id; + }); + }); + after(async () => { + await supertest + .delete(`/api/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + await esArchiver.unload('actions/basic'); + }); + + it('should return 200 when finding alerts', async () => { + await supertest + .get('/api/alert/_find') + .expect(200) + .then((resp: any) => { + const match = resp.body.find((obj: any) => obj.id === alertId); + expect(match).to.eql({ + id: alertId, + alertTypeId: 'test.noop', + interval: 10000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + alertTypeParams: {}, + scheduledTaskId: match.scheduledTaskId, + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/get.ts b/x-pack/test/api_integration/apis/alerting/get.ts new file mode 100644 index 0000000000000..5999907fe35c9 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/get.ts @@ -0,0 +1,64 @@ +/* + * 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 { getTestAlertData } from './utils'; +import { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createGetTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + let alertId: string; + + before(async () => { + await esArchiver.load('actions/basic'); + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200) + .then((resp: any) => { + alertId = resp.body.id; + }); + }); + after(async () => { + await supertest + .delete(`/api/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .expect(200); + await esArchiver.unload('actions/basic'); + }); + + it('should return 200 when getting an alert', async () => { + await supertest + .get(`/api/alert/${alertId}`) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: alertId, + alertTypeId: 'test.noop', + interval: 10000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + alertTypeParams: {}, + scheduledTaskId: resp.body.scheduledTaskId, + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/index.ts b/x-pack/test/api_integration/apis/alerting/index.ts new file mode 100644 index 0000000000000..ecf1a1b0722e9 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('Alerting', () => { + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./list_alert_types')); + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./alerts')); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/list_alert_types.ts b/x-pack/test/api_integration/apis/alerting/list_alert_types.ts new file mode 100644 index 0000000000000..033f0ec66cf8e --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/list_alert_types.ts @@ -0,0 +1,28 @@ +/* + * 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'; + +// eslint-disable-next-line import/no-default-export +export default function listAlertTypes({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('list_alert_types', () => { + it('should return 200 with list of alert types', async () => { + await supertest + .get('/api/alert/types') + .expect(200) + .then((resp: any) => { + const fixtureAlertType = resp.body.find((alertType: any) => alertType.id === 'test.noop'); + expect(fixtureAlertType).to.eql({ + id: 'test.noop', + name: 'Test: Noop', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/update.ts b/x-pack/test/api_integration/apis/alerting/update.ts new file mode 100644 index 0000000000000..b287f77a6e9f9 --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/update.ts @@ -0,0 +1,167 @@ +/* + * 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 { getTestAlertData } from './utils'; +import { ES_ARCHIVER_ACTION_ID } from './constants'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + let createdAlert: any; + + before(async () => { + await esArchiver.load('actions/basic'); + await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200) + .then((resp: any) => { + createdAlert = resp.body; + }); + }); + after(async () => { + await supertest + .delete(`/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + await esArchiver.unload('actions/basic'); + }); + + it('should return 200 when updating an alert', async () => { + await supertest + .put(`/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .send({ + alertTypeParams: { + foo: true, + }, + interval: 12000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: createdAlert.id, + alertTypeParams: { + foo: true, + }, + interval: 12000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }); + }); + }); + + it('should return 400 when attempting to change alert type', async () => { + await supertest + .put(`/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .send({ + alertTypeId: '1', + alertTypeParams: { + foo: true, + }, + interval: 12000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'UPDATED: instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '"alertTypeId" is not allowed', + validation: { + source: 'payload', + keys: ['alertTypeId'], + }, + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .put(`/api/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "interval" fails because ["interval" is required]. child "alertTypeParams" fails because ["alertTypeParams" is required]. child "actions" fails because ["actions" is required]', + validation: { + source: 'payload', + keys: ['interval', 'alertTypeParams', 'actions'], + }, + }); + }); + }); + + it(`should return 400 when alertTypeConfig isn't valid`, async () => { + const { body: customAlert } = await supertest + .post('/api/alert') + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.validation', + alertTypeParams: { + param1: 'test', + }, + }) + ) + .expect(200); + await supertest + .put(`/api/alert/${customAlert.id}`) + .set('kbn-xsrf', 'foo') + .send({ + interval: 10000, + alertTypeParams: {}, + actions: [], + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'alertTypeParams invalid: child "param1" fails because ["param1" is required]', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/alerting/utils.ts b/x-pack/test/api_integration/apis/alerting/utils.ts new file mode 100644 index 0000000000000..2c78f4ee88afa --- /dev/null +++ b/x-pack/test/api_integration/apis/alerting/utils.ts @@ -0,0 +1,65 @@ +/* + * 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 { ES_ARCHIVER_ACTION_ID } from './constants'; + +const esTestIndexName = '.kibaka-alerting-test-data'; + +export function getTestAlertData(attributeOverwrites = {}) { + return { + alertTypeId: 'test.noop', + interval: 10 * 1000, + actions: [ + { + group: 'default', + id: ES_ARCHIVER_ACTION_ID, + params: { + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + alertTypeParams: {}, + ...attributeOverwrites, + }; +} + +export async function setupEsTestIndex(es: any) { + await es.indices.create({ + index: esTestIndexName, + body: { + mappings: { + properties: { + source: { + type: 'keyword', + }, + reference: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + config: { + enabled: false, + type: 'object', + }, + state: { + enabled: false, + type: 'object', + }, + }, + }, + }, + }); + return { + name: esTestIndexName, + }; +} + +export async function destroyEsTestIndex(es: any) { + await es.indices.delete({ index: esTestIndexName }); +} diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 9378a47aee2de..4dd380a15c63a 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -8,6 +8,8 @@ export default function ({ loadTestFile }) { describe('apis', function () { this.tags('ciGroup6'); + loadTestFile(require.resolve('./actions')); + loadTestFile(require.resolve('./alerting')); loadTestFile(require.resolve('./es')); loadTestFile(require.resolve('./security')); loadTestFile(require.resolve('./monitoring')); diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index bce6cbc61260f..7f9dcacd0a748 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import path from 'path'; import { EsProvider, EsSupertestWithoutAuthProvider, @@ -47,6 +48,7 @@ export async function getApiIntegrationConfig({ readConfigFile }) { chance: kibanaAPITestsConfig.get('services.chance'), security: SecurityServiceProvider, spaces: SpacesServiceProvider, + retry: xPackFunctionalTestsConfig.get('services.retry'), }, esArchiver: xPackFunctionalTestsConfig.get('esArchiver'), junit: { @@ -57,6 +59,7 @@ export async function getApiIntegrationConfig({ readConfigFile }) { serverArgs: [ ...xPackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, ], }, esTestCluster: { diff --git a/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts new file mode 100644 index 0000000000000..a810551463eef --- /dev/null +++ b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts @@ -0,0 +1,165 @@ +/* + * 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 { AlertExecuteOptions, AlertType } from '../../../../../legacy/plugins/alerting'; +import { ActionTypeExecutorOptions, ActionType } from '../../../../../legacy/plugins/actions'; + +// eslint-disable-next-line import/no-default-export +export default function(kibana: any) { + return new kibana.Plugin({ + require: ['actions', 'alerting', 'elasticsearch'], + name: 'alerts', + init(server: any) { + // Action types + const indexRecordActionType: ActionType = { + id: 'test.index-record', + name: 'Test: Index Record', + unencryptedAttributes: ['unencrypted'], + validate: { + params: Joi.object() + .keys({ + index: Joi.string().required(), + reference: Joi.string().required(), + message: Joi.string().required(), + }) + .required(), + config: Joi.object() + .keys({ + encrypted: Joi.string().required(), + unencrypted: Joi.string().required(), + }) + .required(), + }, + async executor({ config, params, services }: ActionTypeExecutorOptions) { + return await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + params, + config, + reference: params.reference, + source: 'action:test.index-record', + }, + }); + }, + }; + const failingActionType: ActionType = { + id: 'test.failing', + name: 'Test: Failing', + validate: { + params: Joi.object() + .keys({ + index: Joi.string().required(), + reference: Joi.string().required(), + }) + .required(), + }, + async executor({ config, params, services }: ActionTypeExecutorOptions) { + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + params, + config, + reference: params.reference, + source: 'action:test.failing', + }, + }); + throw new Error('Failed to execute action type'); + }, + }; + server.plugins.actions.registerType(indexRecordActionType); + server.plugins.actions.registerType(failingActionType); + + // Alert types + const alwaysFiringAlertType: AlertType = { + id: 'test.always-firing', + name: 'Test: Always Firing', + async execute({ services, params, state }: AlertExecuteOptions) { + const actionGroupToFire = params.actionGroupToFire || 'default'; + services + .alertInstanceFactory('1') + .replaceState({ instanceStateValue: true }) + .fire(actionGroupToFire, { + instanceContextValue: true, + }); + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + state, + params, + reference: params.reference, + source: 'alert:test.always-firing', + }, + }); + return { + globalStateValue: true, + }; + }, + }; + const neverFiringAlertType: AlertType = { + id: 'test.never-firing', + name: 'Test: Never firing', + async execute({ services, params, state }: AlertExecuteOptions) { + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + state, + params, + reference: params.reference, + source: 'alert:test.never-firing', + }, + }); + return { + globalStateValue: true, + }; + }, + }; + const failingAlertType: AlertType = { + id: 'test.failing', + name: 'Test: Failing', + async execute({ services, params, state }: AlertExecuteOptions) { + await services.callCluster('index', { + index: params.index, + refresh: 'wait_for', + body: { + state, + params, + reference: params.reference, + source: 'alert:test.failing', + }, + }); + throw new Error('Failed to execute alert type'); + }, + }; + const validationAlertType: AlertType = { + id: 'test.validation', + name: 'Test: Validation', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async execute({ services, params, state }: AlertExecuteOptions) {}, + }; + const noopAlertType: AlertType = { + id: 'test.noop', + name: 'Test: Noop', + async execute({ services, params, state }: AlertExecuteOptions) {}, + }; + server.plugins.alerting.registerType(alwaysFiringAlertType); + server.plugins.alerting.registerType(neverFiringAlertType); + server.plugins.alerting.registerType(failingAlertType); + server.plugins.alerting.registerType(validationAlertType); + server.plugins.alerting.registerType(noopAlertType); + }, + }); +} diff --git a/x-pack/test/api_integration/fixtures/plugins/alerts/package.json b/x-pack/test/api_integration/fixtures/plugins/alerts/package.json new file mode 100644 index 0000000000000..836fa09855d8f --- /dev/null +++ b/x-pack/test/api_integration/fixtures/plugins/alerts/package.json @@ -0,0 +1,7 @@ +{ + "name": "alerts", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 79532c369831f..cd6e37f1b0b54 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -196,6 +196,7 @@ export default async function ({ readConfigFile }) { '--xpack.reporting.csv.maxSizeBytes=2850', // small-ish limit for cutting off a 1999 byte report '--stats.maximumWaitTimeForAllCollectorsInS=0', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--xpack.encrypted_saved_objects.encryptionKey="DkdXazszSCYexXqz4YktBGHCRkV6hyNK"', '--xpack.code.security.enableGitCertCheck=false', // Disable git certificate check '--timelion.ui.enabled=true', ], diff --git a/x-pack/test/functional/es_archives/actions/basic/data.json b/x-pack/test/functional/es_archives/actions/basic/data.json new file mode 100644 index 0000000000000..e490e2b89aa7d --- /dev/null +++ b/x-pack/test/functional/es_archives/actions/basic/data.json @@ -0,0 +1,34 @@ +{ + "value": { + "id": "action:19cfba7c-711a-4170-8590-9a99a281e85c", + "index": ".kibana", + "source": { + "type": "action", + "migrationVersion": {}, + "action": { + "description": "My action", + "actionTypeId": "test.index-record", + "actionTypeConfig": { + "unencrypted" : "This value shouldn't get encrypted" + }, + "actionTypeConfigSecrets": "iQw4fXsPBWItxabHCqQOZhPgkvDzvB7RMEMeWjg7WbcWbhi05KWp7xAmfnUaUZ0wDgHHcrujMiVTYdfXRnL4IZdIbiFDoDsOTicF2PucUGlpspSSFI2KJQIr+bLiFz+LqULMouIkhtPYEZSQ1oU6OECxn54aNExZ+NLbC+TMewp7+C6lWZ+BVqHA" + } + } + } +} + +{ + "value": { + "id": "action:08cca6da-60ed-49ca-85f6-641240300a3f", + "index": ".kibana", + "source": { + "type": "action", + "migrationVersion": {}, + "action": { + "description": "My failing action", + "actionTypeId": "test.failing", + "actionTypeConfig": {} + } + } + } +} diff --git a/x-pack/test/functional/es_archives/actions/basic/mappings.json b/x-pack/test/functional/es_archives/actions/basic/mappings.json new file mode 100644 index 0000000000000..0113e064db3ab --- /dev/null +++ b/x-pack/test/functional/es_archives/actions/basic/mappings.json @@ -0,0 +1,100 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "doc": { + "dynamic": "strict", + "properties": { + "spaceId": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "action": { + "properties": { + "description": { + "type": "text" + }, + "actionTypeId": { + "type": "keyword" + }, + "actionTypeConfig": { + "dynamic": "true", + "type": "object" + }, + "actionTypeConfigSecrets": { + "type": "binary" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/test/plugin_api_integration/config.js b/x-pack/test/plugin_api_integration/config.js index 1af76e5d134d2..9d96fdf9d7e47 100644 --- a/x-pack/test/plugin_api_integration/config.js +++ b/x-pack/test/plugin_api_integration/config.js @@ -19,6 +19,7 @@ export default async function ({ readConfigFile }) { testFiles: [ require.resolve('./test_suites/task_manager'), require.resolve('./test_suites/encrypted_saved_objects'), + require.resolve('./test_suites/actions'), ], services: { retry: kibanaFunctionalConfig.get('services.retry'), @@ -29,7 +30,7 @@ export default async function ({ readConfigFile }) { esTestCluster: integrationConfig.get('esTestCluster'), apps: integrationConfig.get('apps'), esArchiver: { - directory: path.resolve(__dirname, '../es_archives') + directory: path.resolve(__dirname, '../functional/es_archives') }, screenshots: integrationConfig.get('screenshots'), junit: { diff --git a/x-pack/test/plugin_api_integration/plugins/actions/index.ts b/x-pack/test/plugin_api_integration/plugins/actions/index.ts new file mode 100644 index 0000000000000..7e34011ebc987 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/actions/index.ts @@ -0,0 +1,43 @@ +/* + * 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 { Legacy } from 'kibana'; + +// eslint-disable-next-line import/no-default-export +export default function actionsPlugin(kibana: any) { + return new kibana.Plugin({ + id: 'actions-test', + require: ['actions'], + init(server: Legacy.Server) { + server.route({ + method: 'POST', + path: '/api/action/{id}/fire', + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + payload: Joi.object() + .keys({ + params: Joi.object(), + }) + .required(), + }, + }, + async handler(request: any) { + await request.server.plugins.actions.fire({ + id: request.params.id, + params: request.payload.params, + }); + return { success: true }; + }, + }); + }, + }); +} diff --git a/x-pack/test/plugin_api_integration/plugins/actions/package.json b/x-pack/test/plugin_api_integration/plugins/actions/package.json new file mode 100644 index 0000000000000..d6dbd425a8026 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/actions/package.json @@ -0,0 +1,4 @@ +{ + "name": "actions-test", + "version": "kibana" + } \ No newline at end of file diff --git a/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts b/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts new file mode 100644 index 0000000000000..4f5dde2456ef4 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts @@ -0,0 +1,197 @@ +/* + * 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'; + +export const ES_ARCHIVER_ACTION_ID = '19cfba7c-711a-4170-8590-9a99a281e85c'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + const retry = getService('retry'); + + const esTestIndexName = '.kibaka-alerting-test-data'; + + describe('actions', () => { + beforeEach(() => esArchiver.load('actions/basic')); + afterEach(() => esArchiver.unload('actions/basic')); + + before(async () => { + await es.indices.create({ + index: esTestIndexName, + body: { + mappings: { + properties: { + source: { + type: 'keyword', + }, + reference: { + type: 'keyword', + }, + params: { + enabled: false, + type: 'object', + }, + config: { + enabled: false, + type: 'object', + }, + state: { + enabled: false, + type: 'object', + }, + }, + }, + }, + }); + }); + after(() => es.indices.delete({ index: esTestIndexName })); + + it('decrypts attributes and joins on actionTypeConfig when firing', async () => { + await supertest + .post(`/api/action/${ES_ARCHIVER_ACTION_ID}/fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: esTestIndexName, + reference: 'actions-fire-1', + message: 'Testing 123', + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + success: true, + }); + }); + const indexedRecord = await retry.tryForTime(5000, async () => { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source: 'action:test.index-record', + }, + }, + { + term: { + reference: 'actions-fire-1', + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + expect(indexedRecord._source).to.eql({ + params: { + index: esTestIndexName, + reference: 'actions-fire-1', + message: 'Testing 123', + }, + config: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + reference: 'actions-fire-1', + source: 'action:test.index-record', + }); + }); + + it('encrypted attributes still available after update', async () => { + const { body: updatedAction } = await supertest + .put(`/api/action/${ES_ARCHIVER_ACTION_ID}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action updated', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + }, + }) + .expect(200); + expect(updatedAction).to.eql({ + id: ES_ARCHIVER_ACTION_ID, + type: 'action', + updated_at: updatedAction.updated_at, + version: updatedAction.version, + references: [], + attributes: { + description: 'My action updated', + actionTypeId: 'test.index-record', + actionTypeConfig: { + unencrypted: `This value shouldn't get encrypted`, + }, + }, + }); + await supertest + .post(`/api/action/${ES_ARCHIVER_ACTION_ID}/fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + index: esTestIndexName, + reference: 'actions-fire-2', + message: 'Testing 123', + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + success: true, + }); + }); + const indexedRecord = await retry.tryForTime(5000, async () => { + const searchResult = await es.search({ + index: esTestIndexName, + body: { + query: { + bool: { + must: [ + { + term: { + source: 'action:test.index-record', + }, + }, + { + term: { + reference: 'actions-fire-2', + }, + }, + ], + }, + }, + }, + }); + expect(searchResult.hits.total.value).to.eql(1); + return searchResult.hits.hits[0]; + }); + expect(indexedRecord._source).to.eql({ + params: { + index: esTestIndexName, + reference: 'actions-fire-2', + message: 'Testing 123', + }, + config: { + unencrypted: `This value shouldn't get encrypted`, + encrypted: 'This value should be encrypted', + }, + reference: 'actions-fire-2', + source: 'action:test.index-record', + }); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/actions/index.ts b/x-pack/test/plugin_api_integration/test_suites/actions/index.ts new file mode 100644 index 0000000000000..f123cb366cec5 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/actions/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('actions', function actionsSuite() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./actions')); + }); +} diff --git a/x-pack/test/typings/hapi.d.ts b/x-pack/test/typings/hapi.d.ts new file mode 100644 index 0000000000000..f84d97ed6fc07 --- /dev/null +++ b/x-pack/test/typings/hapi.d.ts @@ -0,0 +1,33 @@ +/* + * 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 'hapi'; + +import { CloudPlugin } from '../../legacy/plugins/cloud'; +import { EncryptedSavedObjectsPlugin } from '../../legacy/plugins/encrypted_saved_objects'; +import { XPackMainPlugin } from '../../legacy/plugins/xpack_main/xpack_main'; +import { SecurityPlugin } from '../../legacy/plugins/security'; +import { ActionsPlugin, ActionsClient } from '../../legacy/plugins/actions'; +import { TaskManager } from '../../legacy/plugins/task_manager'; +import { AlertingPlugin, AlertsClient } from '../../legacy/plugins/alerting'; + +declare module 'hapi' { + interface Server { + taskManager?: TaskManager; + } + interface Request { + getActionsClient?: () => ActionsClient; + getAlertsClient?: () => AlertsClient; + } + interface PluginProperties { + cloud?: CloudPlugin; + xpack_main: XPackMainPlugin; + security?: SecurityPlugin; + encrypted_saved_objects?: EncryptedSavedObjectsPlugin; + actions?: ActionsPlugin; + alerting?: AlertingPlugin; + } +} diff --git a/x-pack/test/typings/index.d.ts b/x-pack/test/typings/index.d.ts index b365d09392edd..c6a46632139ea 100644 --- a/x-pack/test/typings/index.d.ts +++ b/x-pack/test/typings/index.d.ts @@ -14,3 +14,15 @@ declare module 'lodash/internal/toPath' { function toPath(value: string | string[]): string[]; export = toPath; } + +declare module '*.json' { + const json: any; + // eslint-disable-next-line import/no-default-export + export default json; +} + +type MethodKeysOf = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T]; + +type PublicMethodsOf = Pick>; diff --git a/x-pack/typings/hapi.d.ts b/x-pack/typings/hapi.d.ts index cbf6a19612aba..41445ce035094 100644 --- a/x-pack/typings/hapi.d.ts +++ b/x-pack/typings/hapi.d.ts @@ -10,12 +10,24 @@ import { CloudPlugin } from '../legacy/plugins/cloud'; import { EncryptedSavedObjectsPlugin } from '../legacy/plugins/encrypted_saved_objects'; import { XPackMainPlugin } from '../legacy/plugins/xpack_main/xpack_main'; import { SecurityPlugin } from '../legacy/plugins/security'; +import { ActionsPlugin, ActionsClient } from '../legacy/plugins/actions'; +import { TaskManager } from '../legacy/plugins/task_manager'; +import { AlertingPlugin, AlertsClient } from '../legacy/plugins/alerting'; declare module 'hapi' { + interface Server { + taskManager?: TaskManager; + } + interface Request { + getActionsClient?: () => ActionsClient; + getAlertsClient?: () => AlertsClient; + } interface PluginProperties { cloud?: CloudPlugin; xpack_main: XPackMainPlugin; security?: SecurityPlugin; encrypted_saved_objects?: EncryptedSavedObjectsPlugin; + actions?: ActionsPlugin; + alerting?: AlertingPlugin; } }