diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 3d15c3cc5e368..384c4b696521d 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -211,13 +211,26 @@ For example, `20m`, `24h`, `7d`, `1w`. Default: `5m`. `xpack.alerting.rules.run.ruleTypeOverrides`:: Overrides the configs under `xpack.alerting.rules.run` for the rule type with the given ID. List the rule identifier and its settings in an array of objects. + --- For example: -``` +[source,yaml] +-- xpack.alerting.rules.run: timeout: '5m' ruleTypeOverrides: - id: '.index-threshold' timeout: '15m' -``` --- \ No newline at end of file +-- + +`xpack.alerting.rules.run.actions.connectorTypeOverrides`:: +Overrides the configs under `xpack.alerting.rules.run.actions` for the connector type with the given ID. List the connector type identifier and its settings in an array of objects. ++ +For example: +[source,yaml] +-- +xpack.alerting.rules.run: + actions: + max: 10 + connectorTypeOverrides: + - id: '.server-log' + max: 5 +-- diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index ca81383ba639f..6ec12aab3c002 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -211,6 +211,7 @@ kibana_vars=( xpack.alerting.rules.minimumScheduleInterval.value xpack.alerting.rules.minimumScheduleInterval.enforce xpack.alerting.rules.run.actions.max + xpack.alerting.rules.run.actions.connectorTypeOverrides xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval xpack.alerts.invalidateApiKeysTask.removalDelay diff --git a/x-pack/plugins/alerting/common/rule_task_instance.ts b/x-pack/plugins/alerting/common/rule_task_instance.ts index 8465222c0ff62..529b65e719ddd 100644 --- a/x-pack/plugins/alerting/common/rule_task_instance.ts +++ b/x-pack/plugins/alerting/common/rule_task_instance.ts @@ -22,7 +22,7 @@ const ruleExecutionMetricsSchema = t.partial({ esSearchDurationMs: t.number, }); -const alertExecutionStore = t.partial({ +const alertExecutionMetrics = t.partial({ numberOfTriggeredActions: t.number, numberOfGeneratedActions: t.number, triggeredActionsStatus: t.string, @@ -32,7 +32,7 @@ export type RuleExecutionMetrics = t.TypeOf; export type RuleTaskState = t.TypeOf; export type RuleExecutionState = RuleTaskState & { metrics: RuleExecutionMetrics; - alertExecutionStore: t.TypeOf; + alertExecutionMetrics: t.TypeOf; }; export const ruleParamsSchema = t.intersection([ diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index 64c09a9b4bb09..c07d5bbf0f3f2 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -14,6 +14,11 @@ const ruleTypeSchema = schema.object({ timeout: schema.maybe(schema.string({ validate: validateDurationSchema })), }); +const connectorTypeSchema = schema.object({ + id: schema.string(), + max: schema.maybe(schema.number({ max: 100000 })), +}); + const rulesSchema = schema.object({ minimumScheduleInterval: schema.object({ value: schema.string({ @@ -36,6 +41,7 @@ const rulesSchema = schema.object({ timeout: schema.maybe(schema.string({ validate: validateDurationSchema })), actions: schema.object({ max: schema.number({ defaultValue: 100000, max: 100000 }), + connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)), }), ruleTypeOverrides: schema.maybe(schema.arrayOf(ruleTypeSchema)), }), @@ -59,5 +65,6 @@ export const configSchema = schema.object({ export type AlertingConfig = TypeOf; export type RulesConfig = TypeOf; -export type RuleTypeConfig = Omit; export type AlertingRulesConfig = Pick; +export type ActionsConfig = RulesConfig['run']['actions']; +export type ActionTypeConfig = Omit; diff --git a/x-pack/plugins/alerting/server/lib/alert_execution_store.test.ts b/x-pack/plugins/alerting/server/lib/alert_execution_store.test.ts new file mode 100644 index 0000000000000..905ef53cda9db --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alert_execution_store.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertExecutionStore } from './alert_execution_store'; +import { ActionsCompletion } from '../task_runner/types'; + +describe('AlertExecutionStore', () => { + const alertExecutionStore = new AlertExecutionStore(); + const testConnectorId = 'test-connector-id'; + + // Getter Setter + test('returns the default values if there is no change', () => { + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(0); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(0); + expect(alertExecutionStore.getStatusByConnectorType('any')).toBe(undefined); + }); + + test('sets and returns numberOfTriggeredActions', () => { + alertExecutionStore.setNumberOfTriggeredActions(5); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(5); + }); + + test('sets and returns numberOfGeneratedActions', () => { + alertExecutionStore.setNumberOfGeneratedActions(15); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(15); + }); + + test('sets and returns triggeredActionsStatusByConnectorType', () => { + alertExecutionStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: testConnectorId, + status: ActionsCompletion.PARTIAL, + }); + expect( + alertExecutionStore.getStatusByConnectorType(testConnectorId).triggeredActionsStatus + ).toBe(ActionsCompletion.PARTIAL); + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + }); + + // increment + test('increments numberOfTriggeredActions by 1', () => { + alertExecutionStore.incrementNumberOfTriggeredActions(); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(6); + }); + + test('increments incrementNumberOfGeneratedActions by x', () => { + alertExecutionStore.incrementNumberOfGeneratedActions(2); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(17); + }); + + test('increments numberOfTriggeredActionsByConnectorType by 1', () => { + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + expect( + alertExecutionStore.getStatusByConnectorType(testConnectorId).numberOfTriggeredActions + ).toBe(1); + }); + + test('increments NumberOfGeneratedActionsByConnectorType by 1', () => { + alertExecutionStore.incrementNumberOfGeneratedActionsByConnectorType(testConnectorId); + expect( + alertExecutionStore.getStatusByConnectorType(testConnectorId).numberOfGeneratedActions + ).toBe(1); + }); + + // Checker + test('checks if it has reached the executable actions limit', () => { + expect(alertExecutionStore.hasReachedTheExecutableActionsLimit({ default: { max: 10 } })).toBe( + false + ); + + expect(alertExecutionStore.hasReachedTheExecutableActionsLimit({ default: { max: 5 } })).toBe( + true + ); + }); + + test('checks if it has reached the executable actions limit by connector type', () => { + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(testConnectorId); + + expect( + alertExecutionStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionsConfigMap: { + default: { max: 20 }, + [testConnectorId]: { + max: 5, + }, + }, + actionTypeId: testConnectorId, + }) + ).toBe(true); + + expect( + alertExecutionStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionsConfigMap: { + default: { max: 20 }, + [testConnectorId]: { + max: 8, + }, + }, + actionTypeId: testConnectorId, + }) + ).toBe(false); + }); + + test('checks if a connector type it has already reached the executable actions limit', () => { + expect(alertExecutionStore.hasConnectorTypeReachedTheLimit(testConnectorId)).toBe(true); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/alert_execution_store.ts b/x-pack/plugins/alerting/server/lib/alert_execution_store.ts new file mode 100644 index 0000000000000..b601a76b809a4 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alert_execution_store.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { set } from 'lodash'; +import { ActionsConfigMap } from './get_actions_config_map'; +import { ActionsCompletion } from '../task_runner/types'; + +interface State { + numberOfTriggeredActions: number; + numberOfGeneratedActions: number; + connectorTypes: { + [key: string]: { + triggeredActionsStatus: ActionsCompletion; + numberOfTriggeredActions: number; + numberOfGeneratedActions: number; + }; + }; +} + +export class AlertExecutionStore { + private state: State = { + numberOfTriggeredActions: 0, + numberOfGeneratedActions: 0, + connectorTypes: {}, + }; + + // Getters + public getTriggeredActionsStatus = () => { + const hasPartial = Object.values(this.state.connectorTypes).some( + (connectorType) => connectorType?.triggeredActionsStatus === ActionsCompletion.PARTIAL + ); + return hasPartial ? ActionsCompletion.PARTIAL : ActionsCompletion.COMPLETE; + }; + public getNumberOfTriggeredActions = () => { + return this.state.numberOfTriggeredActions; + }; + public getNumberOfGeneratedActions = () => { + return this.state.numberOfGeneratedActions; + }; + public getStatusByConnectorType = (actionTypeId: string) => { + return this.state.connectorTypes[actionTypeId]; + }; + + // Setters + public setNumberOfTriggeredActions = (numberOfTriggeredActions: number) => { + this.state.numberOfTriggeredActions = numberOfTriggeredActions; + }; + + public setNumberOfGeneratedActions = (numberOfGeneratedActions: number) => { + this.state.numberOfGeneratedActions = numberOfGeneratedActions; + }; + + public setTriggeredActionsStatusByConnectorType = ({ + actionTypeId, + status, + }: { + actionTypeId: string; + status: ActionsCompletion; + }) => { + set(this.state, `connectorTypes["${actionTypeId}"].triggeredActionsStatus`, status); + }; + + // Checkers + public hasReachedTheExecutableActionsLimit = (actionsConfigMap: ActionsConfigMap): boolean => + this.state.numberOfTriggeredActions >= actionsConfigMap.default.max; + + public hasReachedTheExecutableActionsLimitByConnectorType = ({ + actionsConfigMap, + actionTypeId, + }: { + actionsConfigMap: ActionsConfigMap; + actionTypeId: string; + }): boolean => { + const numberOfTriggeredActionsByConnectorType = + this.state.connectorTypes[actionTypeId]?.numberOfTriggeredActions || 0; + const executableActionsLimitByConnectorType = + actionsConfigMap[actionTypeId]?.max || actionsConfigMap.default.max; + + return numberOfTriggeredActionsByConnectorType >= executableActionsLimitByConnectorType; + }; + + public hasConnectorTypeReachedTheLimit = (actionTypeId: string) => + this.state.connectorTypes[actionTypeId]?.triggeredActionsStatus === ActionsCompletion.PARTIAL; + + // Incrementer + public incrementNumberOfTriggeredActions = () => { + this.state.numberOfTriggeredActions++; + }; + + public incrementNumberOfGeneratedActions = (incrementBy: number) => { + this.state.numberOfGeneratedActions += incrementBy; + }; + + public incrementNumberOfTriggeredActionsByConnectorType = (actionTypeId: string) => { + const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfTriggeredActions || 0; + set(this.state, `connectorTypes["${actionTypeId}"].numberOfTriggeredActions`, currentVal + 1); + }; + public incrementNumberOfGeneratedActionsByConnectorType = (actionTypeId: string) => { + const currentVal = this.state.connectorTypes[actionTypeId]?.numberOfGeneratedActions || 0; + set(this.state, `connectorTypes["${actionTypeId}"].numberOfGeneratedActions`, currentVal + 1); + }; +} diff --git a/x-pack/plugins/alerting/server/lib/get_actions_config_map.test.ts b/x-pack/plugins/alerting/server/lib/get_actions_config_map.test.ts new file mode 100644 index 0000000000000..db67925e3c61f --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_actions_config_map.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getActionsConfigMap } from './get_actions_config_map'; + +const connectorTypeId = 'test-connector-type-id'; +const actionsConfig = { + max: 1000, +}; + +const actionsConfigWithConnectorType = { + ...actionsConfig, + connectorTypeOverrides: [ + { + id: connectorTypeId, + max: 20, + }, + ], +}; + +describe('get actions config map', () => { + test('returns the default actions config', () => { + expect(getActionsConfigMap(actionsConfig)).toEqual({ + default: { + max: 1000, + }, + }); + }); + + test('applies the connector type specific config', () => { + expect(getActionsConfigMap(actionsConfigWithConnectorType)).toEqual({ + default: { + max: 1000, + }, + [connectorTypeId]: { + max: 20, + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_actions_config_map.ts b/x-pack/plugins/alerting/server/lib/get_actions_config_map.ts new file mode 100644 index 0000000000000..966f25e30f824 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_actions_config_map.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash'; +import { ActionsConfig, ActionTypeConfig } from '../config'; + +export interface ActionsConfigMap { + default: ActionTypeConfig; + [key: string]: ActionTypeConfig; +} + +export const getActionsConfigMap = (actionsConfig: ActionsConfig): ActionsConfigMap => { + const configsByConnectorType = actionsConfig.connectorTypeOverrides?.reduce( + (config, configByConnectorType) => { + return { ...config, [configByConnectorType.id]: omit(configByConnectorType, 'id') }; + }, + {} + ); + return { + default: omit(actionsConfig, 'connectorTypeOverrides'), + ...configsByConnectorType, + }; +}; diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts deleted file mode 100644 index e6477945eebda..0000000000000 --- a/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getExecutionConfigForRuleType } from './get_rules_config'; -import { RulesConfig } from '../config'; - -const ruleTypeId = 'test-rule-type-id'; -const config = { - minimumScheduleInterval: { - value: '2m', - enforce: false, - }, - run: { - timeout: '1m', - actions: { max: 1000 }, - }, -} as RulesConfig; - -const configWithRuleType = { - ...config, - run: { - ...config.run, - ruleTypeOverrides: [ - { - id: ruleTypeId, - actions: { max: 20 }, - }, - ], - }, -}; - -describe('get rules config', () => { - test('returns the rule type specific config and keeps the default values that are not overwritten', () => { - expect(getExecutionConfigForRuleType({ config: configWithRuleType, ruleTypeId })).toEqual({ - run: { - id: ruleTypeId, - timeout: '1m', - actions: { max: 20 }, - }, - }); - }); - - test('returns the default config when there is no rule type specific config', () => { - expect(getExecutionConfigForRuleType({ config, ruleTypeId })).toEqual({ - run: config.run, - }); - }); -}); diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.ts deleted file mode 100644 index 14d3bdc36285f..0000000000000 --- a/x-pack/plugins/alerting/server/lib/get_rules_config.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { omit } from 'lodash'; -import { RulesConfig, RuleTypeConfig } from '../config'; - -export const getExecutionConfigForRuleType = ({ - config, - ruleTypeId, -}: { - config: RulesConfig; - ruleTypeId: string; -}): RuleTypeConfig => { - const ruleTypeExecutionConfig = config.run.ruleTypeOverrides?.find( - (ruleType) => ruleType.id === ruleTypeId - ); - - return { - run: { - ...omit(config.run, 'ruleTypeOverrides'), - ...ruleTypeExecutionConfig, - }, - }; -}; diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts index 19da38e283a11..52de76d4b4dd9 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.test.ts @@ -32,7 +32,7 @@ describe('RuleExecutionStatus', () => { describe('executionStatusFromState()', () => { test('empty task state', () => { const status = executionStatusFromState({ - alertExecutionStore: { + alertExecutionMetrics: { numberOfTriggeredActions: 0, numberOfGeneratedActions: 0, triggeredActionsStatus: ActionsCompletion.COMPLETE, @@ -49,7 +49,7 @@ describe('RuleExecutionStatus', () => { test('task state with no instances', () => { const status = executionStatusFromState({ alertInstances: {}, - alertExecutionStore: { + alertExecutionMetrics: { numberOfTriggeredActions: 0, numberOfGeneratedActions: 0, triggeredActionsStatus: ActionsCompletion.COMPLETE, @@ -68,7 +68,7 @@ describe('RuleExecutionStatus', () => { test('task state with one instance', () => { const status = executionStatusFromState({ alertInstances: { a: {} }, - alertExecutionStore: { + alertExecutionMetrics: { numberOfTriggeredActions: 0, numberOfGeneratedActions: 0, triggeredActionsStatus: ActionsCompletion.COMPLETE, @@ -86,7 +86,7 @@ describe('RuleExecutionStatus', () => { test('task state with numberOfTriggeredActions', () => { const status = executionStatusFromState({ - alertExecutionStore: { + alertExecutionMetrics: { numberOfTriggeredActions: 1, numberOfGeneratedActions: 2, triggeredActionsStatus: ActionsCompletion.COMPLETE, @@ -106,7 +106,7 @@ describe('RuleExecutionStatus', () => { test('task state with warning', () => { const status = executionStatusFromState({ alertInstances: { a: {} }, - alertExecutionStore: { + alertExecutionMetrics: { numberOfTriggeredActions: 3, triggeredActionsStatus: ActionsCompletion.PARTIAL, }, diff --git a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts index f4161022e9544..0b9d2520ae455 100644 --- a/x-pack/plugins/alerting/server/lib/rule_execution_status.ts +++ b/x-pack/plugins/alerting/server/lib/rule_execution_status.ts @@ -23,7 +23,7 @@ export function executionStatusFromState(state: RuleExecutionState): RuleExecuti const alertIds = Object.keys(state.alertInstances ?? {}); const hasIncompleteAlertExecution = - state.alertExecutionStore.triggeredActionsStatus === ActionsCompletion.PARTIAL; + state.alertExecutionMetrics.triggeredActionsStatus === ActionsCompletion.PARTIAL; let status: RuleExecutionStatuses = alertIds.length === 0 ? RuleExecutionStatusValues[0] : RuleExecutionStatusValues[1]; @@ -34,8 +34,8 @@ export function executionStatusFromState(state: RuleExecutionState): RuleExecuti return { metrics: state.metrics, - numberOfTriggeredActions: state.alertExecutionStore.numberOfTriggeredActions, - numberOfGeneratedActions: state.alertExecutionStore.numberOfGeneratedActions, + numberOfTriggeredActions: state.alertExecutionMetrics.numberOfTriggeredActions, + numberOfGeneratedActions: state.alertExecutionMetrics.numberOfGeneratedActions, lastExecutionDate: new Date(), status, ...(hasIncompleteAlertExecution && { diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 949328d065536..29739d2ca53d9 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -50,13 +50,6 @@ const sampleRuleType: RuleType = { actionGroups: [], defaultActionGroupId: 'default', producer: 'test', - config: { - run: { - actions: { - max: 1000, - }, - }, - }, async executor() {}, }; @@ -122,61 +115,6 @@ describe('Alerting Plugin', () => { }); }); - it(`applies the default config if there is no rule type specific config `, async () => { - const context = coreMock.createPluginInitializerContext({ - ...generateAlertingConfig(), - rules: { - minimumScheduleInterval: { value: '1m', enforce: false }, - run: { - actions: { - max: 123, - }, - }, - }, - }); - plugin = new AlertingPlugin(context); - - const setupContract = await plugin.setup(setupMocks, mockPlugins); - - const ruleType = { ...sampleRuleType }; - setupContract.registerType(ruleType); - - expect(ruleType.config).toEqual({ - run: { - actions: { max: 123 }, - }, - }); - }); - - it(`applies rule type specific config if defined in config`, async () => { - const context = coreMock.createPluginInitializerContext({ - ...generateAlertingConfig(), - rules: { - minimumScheduleInterval: { value: '1m', enforce: false }, - run: { - actions: { max: 123 }, - ruleTypeOverrides: [{ id: sampleRuleType.id, timeout: '1d' }], - }, - }, - }); - plugin = new AlertingPlugin(context); - - const setupContract = await plugin.setup(setupMocks, mockPlugins); - - const ruleType = { ...sampleRuleType }; - setupContract.registerType(ruleType); - - expect(ruleType.config).toEqual({ - run: { - id: sampleRuleType.id, - actions: { - max: 123, - }, - timeout: '1d', - }, - }); - }); - describe('registerType()', () => { let setup: PluginSetupContract; beforeEach(async () => { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 9696c523b095b..cfd2701e1f885 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -77,8 +77,8 @@ import { AlertingAuthorizationClientFactory } from './alerting_authorization_cli import { AlertingAuthorization } from './authorization'; import { getSecurityHealth, SecurityHealth } from './lib/get_security_health'; import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring'; -import { getExecutionConfigForRuleType } from './lib/get_rules_config'; import { getRuleTaskTimeout } from './lib/get_rule_task_timeout'; +import { getActionsConfigMap } from './lib/get_actions_config_map'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -319,10 +319,6 @@ export class AlertingPlugin { if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { throw new Error(`"${ruleType.minimumLicenseRequired}" is not a valid license type`); } - ruleType.config = getExecutionConfigForRuleType({ - config: this.config.rules, - ruleTypeId: ruleType.id, - }); ruleType.ruleTaskTimeout = getRuleTaskTimeout({ config: this.config.rules, ruleTaskTimeout: ruleType.ruleTaskTimeout, @@ -437,6 +433,7 @@ export class AlertingPlugin { supportsEphemeralTasks: plugins.taskManager.supportsEphemeralTasks(), maxEphemeralActionsPerRule: this.config.maxEphemeralActionsPerAlert, cancelAlertsOnRuleTimeout: this.config.cancelAlertsOnRuleTimeout, + actionsConfigMap: getActionsConfigMap(this.config.rules.run.actions), usageCounter: this.usageCounter, }); diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index abaeee60759e9..9a6b2232c47d4 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -60,11 +60,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }); expect(registry.has('foo')).toEqual(true); }); @@ -86,11 +81,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -124,11 +114,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -153,11 +138,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -185,11 +165,6 @@ describe('Create Lifecycle', () => { executor: jest.fn(), producer: 'alerts', defaultScheduleInterval: 'foobar', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -278,11 +253,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -312,11 +282,6 @@ describe('Create Lifecycle', () => { producer: 'alerts', minimumLicenseRequired: 'basic', isExportable: true, - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); @@ -350,11 +315,6 @@ describe('Create Lifecycle', () => { producer: 'alerts', minimumLicenseRequired: 'basic', isExportable: true, - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); @@ -392,11 +352,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); @@ -423,11 +378,6 @@ describe('Create Lifecycle', () => { executor: jest.fn(), producer: 'alerts', ruleTaskTimeout: '20m', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); @@ -460,11 +410,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); @@ -488,11 +433,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }); expect(() => registry.register({ @@ -509,11 +449,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }) ).toThrowErrorMatchingInlineSnapshot(`"Rule type \\"test\\" is already registered."`); }); @@ -536,11 +471,6 @@ describe('Create Lifecycle', () => { isExportable: true, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }); const ruleType = registry.get('test'); expect(ruleType).toMatchInlineSnapshot(` @@ -560,13 +490,6 @@ describe('Create Lifecycle', () => { "params": Array [], "state": Array [], }, - "config": Object { - "run": Object { - "actions": Object { - "max": 1000, - }, - }, - }, "defaultActionGroupId": "default", "executor": [MockFunction], "id": "test", @@ -615,11 +538,6 @@ describe('Create Lifecycle', () => { minimumLicenseRequired: 'basic', executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }); const result = registry.list(); expect(result).toMatchInlineSnapshot(` @@ -714,11 +632,6 @@ describe('Create Lifecycle', () => { isExportable: true, minimumLicenseRequired: 'basic', recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, - config: { - run: { - actions: { max: 1000 }, - }, - }, }); }); @@ -752,11 +665,6 @@ function ruleTypeWithVariables( minimumLicenseRequired: 'basic', async executor() {}, producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; if (!context && !state) return baseAlert; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index d695acf574aeb..72e74f058bb90 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -1166,11 +1166,6 @@ describe('create()', () => { extractReferences: extractReferencesFn, injectReferences: injectReferencesFn, }, - config: { - run: { - actions: { max: 1000 }, - }, - }, })); const data = getMockData({ params: ruleParams, @@ -1339,11 +1334,6 @@ describe('create()', () => { extractReferences: extractReferencesFn, injectReferences: injectReferencesFn, }, - config: { - run: { - actions: { max: 1000 }, - }, - }, })); const data = getMockData({ params: ruleParams, @@ -2098,11 +2088,6 @@ describe('create()', () => { isExportable: true, async executor() {}, producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"params invalid: [param1]: expected value of type [string] but got [undefined]"` @@ -2628,11 +2613,6 @@ describe('create()', () => { extractReferences: jest.fn(), injectReferences: jest.fn(), }, - config: { - run: { - actions: { max: 1000 }, - }, - }, })); const data = getMockData({ schedule: { interval: '1s' } }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts index b25fd2ab2c489..194ca6c8279a7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts @@ -91,11 +91,6 @@ export function getBeforeSetup( isExportable: true, async executor() {}, producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, })); rulesClientParams.getEventLogClient.mockResolvedValue( eventLogClient ?? eventLogClientMock.create() diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index db84953291c73..0a51bc06b6e43 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -6,7 +6,7 @@ */ import { createExecutionHandler } from './create_execution_handler'; -import { ActionsCompletion, AlertExecutionStore, CreateExecutionHandlerOptions } from './types'; +import { ActionsCompletion, CreateExecutionHandlerOptions } from './types'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsClientMock, @@ -19,6 +19,7 @@ import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { InjectActionParamsOpts } from './inject_action_params'; import { NormalizedRuleType } from '../rule_type_registry'; import { AlertInstanceContext, AlertInstanceState, RuleTypeParams, RuleTypeState } from '../types'; +import { AlertExecutionStore } from '../lib/alert_execution_store'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), @@ -48,11 +49,6 @@ const ruleType: NormalizedRuleType< }, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; const actionsClient = actionsClientMock.create(); @@ -103,6 +99,11 @@ const createExecutionHandlerParams: jest.Mocked< }, supportsEphemeralTasks: false, maxEphemeralActionsPerRule: 10, + actionsConfigMap: { + default: { + max: 1000, + }, + }, }; let alertExecutionStore: AlertExecutionStore; @@ -120,11 +121,7 @@ describe('Create Execution Handler', () => { mockActionsPlugin.renderActionParameterTemplates.mockImplementation( renderActionParameterTemplatesDefault ); - alertExecutionStore = { - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - triggeredActionsStatus: ActionsCompletion.COMPLETE, - }; + alertExecutionStore = new AlertExecutionStore(); }); test('enqueues execution per selected action', async () => { @@ -136,7 +133,8 @@ describe('Create Execution Handler', () => { alertId: '2', alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(1); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(1); expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith( createExecutionHandlerParams.request ); @@ -244,7 +242,7 @@ describe('Create Execution Handler', () => { }, }); - expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.COMPLETE); + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); }); test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { @@ -255,7 +253,16 @@ describe('Create Execution Handler', () => { const executionHandler = createExecutionHandler({ ...createExecutionHandlerParams, actions: [ - ...createExecutionHandlerParams.actions, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, { id: '2', group: 'default', @@ -275,7 +282,8 @@ describe('Create Execution Handler', () => { alertId: '2', alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(1); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(2); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({ consumer: 'rule-consumer', @@ -310,7 +318,14 @@ describe('Create Execution Handler', () => { const executionHandler = createExecutionHandler({ ...createExecutionHandlerParams, actions: [ - ...createExecutionHandlerParams.actions, + { + id: '1', + group: 'default', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, { id: '2', group: 'default', @@ -331,7 +346,8 @@ describe('Create Execution Handler', () => { alertId: '2', alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(0); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(2); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); mockActionsPlugin.isActionExecutable.mockImplementation(() => true); @@ -358,7 +374,8 @@ describe('Create Execution Handler', () => { alertId: '2', alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(0); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(0); expect(actionsClient.enqueueExecution).not.toHaveBeenCalled(); }); @@ -371,7 +388,8 @@ describe('Create Execution Handler', () => { alertId: '2', alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(1); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(1); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -466,23 +484,30 @@ describe('Create Execution Handler', () => { 'Invalid action group "invalid-group" for rule "test".' ); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(0); - expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.COMPLETE); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(0); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(0); + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); }); test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { const executionHandler = createExecutionHandler({ ...createExecutionHandlerParams, - ruleType: { - ...ruleType, - config: { - run: { - actions: { max: 2 }, - }, + actionsConfigMap: { + default: { + max: 2, }, }, actions: [ - ...createExecutionHandlerParams.actions, + { + id: '1', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, { id: '2', group: 'default', @@ -506,11 +531,7 @@ describe('Create Execution Handler', () => { ], }); - alertExecutionStore = { - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - triggeredActionsStatus: ActionsCompletion.COMPLETE, - }; + alertExecutionStore = new AlertExecutionStore(); await executionHandler({ actionGroup: 'default', @@ -520,9 +541,90 @@ describe('Create Execution Handler', () => { alertExecutionStore, }); - expect(alertExecutionStore.numberOfTriggeredActions).toBe(2); - expect(alertExecutionStore.numberOfGeneratedActions).toBe(3); - expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.PARTIAL); + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(2); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(3); + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(createExecutionHandlerParams.logger.debug).toHaveBeenCalledTimes(1); expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); }); + + test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { + const executionHandler = createExecutionHandler({ + ...createExecutionHandlerParams, + actionsConfigMap: { + default: { + max: 4, + }, + 'test-action-type-id': { + max: 1, + }, + }, + actions: [ + ...createExecutionHandlerParams.actions, + { + id: '2', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '4', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '5', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }); + + alertExecutionStore = new AlertExecutionStore(); + + await executionHandler({ + actionGroup: 'default', + context: {}, + state: { value: 'state-val' }, + alertId: '2', + alertExecutionStore, + }); + + expect(alertExecutionStore.getNumberOfTriggeredActions()).toBe(4); + expect(alertExecutionStore.getNumberOfGeneratedActions()).toBe(5); + expect(alertExecutionStore.getStatusByConnectorType('test').numberOfTriggeredActions).toBe(1); + expect( + alertExecutionStore.getStatusByConnectorType('test-action-type-id').numberOfTriggeredActions + ).toBe(1); + expect( + alertExecutionStore.getStatusByConnectorType('another-action-type-id') + .numberOfTriggeredActions + ).toBe(2); + expect(alertExecutionStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(4); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index b6b6eb0eaf880..bc0e92c954f23 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -46,6 +46,7 @@ export function createExecutionHandler< ruleParams, supportsEphemeralTasks, maxEphemeralActionsPerRule, + actionsConfigMap, }: CreateExecutionHandlerOptions< Params, ExtractedParams, @@ -58,6 +59,7 @@ export function createExecutionHandler< const ruleTypeActionGroups = new Map( ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) ); + return async ({ actionGroup, actionSubgroup, @@ -107,7 +109,7 @@ export function createExecutionHandler< }), })); - alertExecutionStore.numberOfGeneratedActions += actions.length; + alertExecutionStore.incrementNumberOfGeneratedActions(actions.length); const ruleLabel = `${ruleType.id}:${ruleId}: '${ruleName}'`; @@ -115,21 +117,48 @@ export function createExecutionHandler< let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; for (const action of actions) { - if (alertExecutionStore.numberOfTriggeredActions >= ruleType.config!.run.actions.max) { - alertExecutionStore.triggeredActionsStatus = ActionsCompletion.PARTIAL; + const { actionTypeId } = action; + + alertExecutionStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + + if (alertExecutionStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + alertExecutionStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); break; } if ( - !actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true }) + alertExecutionStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId, + actionsConfigMap, + }) ) { + if (!alertExecutionStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { + logger.debug( + `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` + ); + } + alertExecutionStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + continue; + } + + if (!actionsPlugin.isActionExecutable(action.id, actionTypeId, { notifyUsage: true })) { logger.warn( `Rule "${ruleId}" skipped scheduling action "${action.id}" because it is disabled` ); continue; } - alertExecutionStore.numberOfTriggeredActions++; + alertExecutionStore.incrementNumberOfTriggeredActions(); + alertExecutionStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; @@ -155,7 +184,7 @@ export function createExecutionHandler< }; // TODO would be nice to add the action name here, but it's not available - const actionLabel = `${action.actionTypeId}:${action.id}`; + const actionLabel = `${actionTypeId}:${action.id}`; if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { ephemeralActionsToSchedule--; try { @@ -190,7 +219,7 @@ export function createExecutionHandler< { type: 'action', id: action.id, - typeId: action.actionTypeId, + typeId: actionTypeId, }, ], ...namespace, diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 7a851a713c961..fae450ac7e60d 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -108,11 +108,6 @@ export const ruleType: jest.Mocked = { recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; export const mockRunNowResponse = { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 5ed33093cd8bc..4a5be740949ad 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -134,6 +134,11 @@ describe('Task Runner', () => { maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, usageCounter: mockUsageCounter, + actionsConfigMap: { + default: { + max: 10000, + }, + }, }; const ephemeralTestParams: Array< @@ -2641,13 +2646,11 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult.monitoring?.execution.history.length).toBe(200); }); + test('Actions circuit breaker kicked in, should set status as warning and log a message in event log', async () => { - const ruleTypeWithConfig = { - ...ruleType, - config: { - run: { - actions: { max: 3 }, - }, + const actionsConfigMap = { + default: { + max: 3, }, }; @@ -2705,21 +2708,22 @@ describe('Task Runner', () => { ...mockedRuleTypeSavedObject, actions: mockActions, } as jest.ResolvedValue); - ruleTypeRegistry.get.mockReturnValue(ruleTypeWithConfig); + ruleTypeRegistry.get.mockReturnValue(ruleType); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); const taskRunner = new TaskRunner( - ruleTypeWithConfig, + ruleType, mockedTaskInstance, - taskRunnerFactoryInitializerParams, + { + ...taskRunnerFactoryInitializerParams, + actionsConfigMap, + }, inMemoryMetrics ); const runnerResult = await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes( - ruleTypeWithConfig.config.run.actions.max - ); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(actionsConfigMap.default.max); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -2745,6 +2749,15 @@ describe('Task Runner', () => { }, }) ); + + const logger = taskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(5); + + expect(logger.debug).nthCalledWith( + 3, + 'Rule "1" skipped scheduling action "4" because the maximum number of allowed actions has been reached.' + ); + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(7); @@ -2818,7 +2831,7 @@ describe('Task Runner', () => { action: EVENT_LOG_ACTIONS.execute, outcome: 'success', status: 'warning', - numberOfTriggeredActions: ruleTypeWithConfig.config.run.actions.max, + numberOfTriggeredActions: actionsConfigMap.default.max, numberOfGeneratedActions: mockActions.length, reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, task: true, @@ -2827,6 +2840,138 @@ describe('Task Runner', () => { ); }); + test('Actions circuit breaker kicked in with connectorType specific config and multiple alerts', async () => { + const actionsConfigMap = { + default: { + max: 30, + }, + '.server-log': { + max: 1, + }, + }; + + const warning = { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: translations.taskRunner.warning.maxExecutableActions, + }; + + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + ruleType.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertFactory.create('1').scheduleActions('default'); + executorServices.alertFactory.create('2').scheduleActions('default'); + } + ); + + rulesClient.get.mockResolvedValue({ + ...mockedRuleTypeSavedObject, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '.server-log', + }, + { + group: 'default', + id: '2', + actionTypeId: '.server-log', + }, + { + group: 'default', + id: '3', + actionTypeId: '.server-log', + }, + { + group: 'default', + id: '4', + actionTypeId: 'any-action', + }, + { + group: 'default', + id: '5', + actionTypeId: 'any-action', + }, + ], + } as jest.ResolvedValue); + + ruleTypeRegistry.get.mockReturnValue(ruleType); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); + + const taskRunner = new TaskRunner( + ruleType, + mockedTaskInstance, + { + ...taskRunnerFactoryInitializerParams, + actionsConfigMap, + }, + inMemoryMetrics + ); + + const runnerResult = await taskRunner.run(); + + // 1x(.server-log) and 2x(any-action) per alert + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(5); + + expect( + taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update + ).toHaveBeenCalledWith(...generateSavedObjectParams({ status: 'warning', warning })); + + expect(runnerResult).toEqual( + generateRunnerResult({ + state: true, + history: [true], + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + date: new Date(DATE_1970), + group: 'default', + }, + }, + state: { + duration: 0, + start: '1970-01-01T00:00:00.000Z', + }, + }, + '2': { + meta: { + lastScheduledActions: { + date: new Date(DATE_1970), + group: 'default', + }, + }, + state: { + duration: 0, + start: '1970-01-01T00:00:00.000Z', + }, + }, + }, + }) + ); + + const logger = taskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(5); + + expect(logger.debug).nthCalledWith( + 3, + 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' + ); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(11); + }); + test('increments monitoring metrics after execution', async () => { const taskRunner = new TaskRunner( ruleType, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 36703e7c28dd7..00c62af65f382 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -65,8 +65,6 @@ import { } from '../lib/create_alert_event_log_record_object'; import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring'; import { - ActionsCompletion, - AlertExecutionStore, GenerateNewAndRecoveredAlertEventsParams, LogActiveAndRecoveredAlertsParams, RuleTaskInstance, @@ -74,6 +72,7 @@ import { ScheduleActionsForRecoveredAlertsParams, TrackAlertDurationsParams, } from './types'; +import { AlertExecutionStore } from '../lib/alert_execution_store'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -231,6 +230,7 @@ export class TaskRunner< ruleParams, supportsEphemeralTasks: this.context.supportsEphemeralTasks, maxEphemeralActionsPerRule: this.context.maxEphemeralActionsPerRule, + actionsConfigMap: this.context.actionsConfigMap, }); } @@ -491,11 +491,7 @@ export class TaskRunner< }); } - const alertExecutionStore: AlertExecutionStore = { - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - triggeredActionsStatus: ActionsCompletion.COMPLETE, - }; + const alertExecutionStore = new AlertExecutionStore(); const ruleIsSnoozed = this.isRuleSnoozed(rule); if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) { @@ -567,7 +563,11 @@ export class TaskRunner< return { metrics: searchMetrics, - alertExecutionStore, + alertExecutionMetrics: { + numberOfTriggeredActions: alertExecutionStore.getNumberOfTriggeredActions(), + numberOfGeneratedActions: alertExecutionStore.getNumberOfGeneratedActions(), + triggeredActionsStatus: alertExecutionStore.getTriggeredActionsStatus(), + }, alertTypeState: updatedRuleTypeState || undefined, alertInstances: mapValues< Record>, @@ -874,7 +874,7 @@ export class TaskRunner< executionState: RuleExecutionState ): RuleTaskState => { return { - ...omit(executionState, ['alertExecutionStore', 'metrics']), + ...omit(executionState, ['alertExecutionMetrics', 'metrics']), previousStartedAt: startedAt, }; }; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 66a7b85a94259..e0b7449d09b41 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -57,11 +57,6 @@ const ruleType: jest.Mocked = { producer: 'alerts', cancelAlertsOnRuleTimeout: true, ruleTaskTimeout: '5m', - config: { - run: { - actions: { max: 1000 }, - }, - }, }; let fakeTimer: sinon.SinonFakeTimers; @@ -134,6 +129,11 @@ describe('Task Runner Cancel', () => { maxEphemeralActionsPerRule: 10, cancelAlertsOnRuleTimeout: true, usageCounter: mockUsageCounter, + actionsConfigMap: { + default: { + max: 1000, + }, + }, }; const mockDate = new Date('2019-02-12T21:01:22.479Z'); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 0fe747ab7a93e..e787617800356 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -101,6 +101,11 @@ describe('Task Runner Factory', () => { cancelAlertsOnRuleTimeout: true, executionContext, usageCounter: mockUsageCounter, + actionsConfigMap: { + default: { + max: 1000, + }, + }, }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index 5a32e86593fa4..e7c483b944ed1 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -34,6 +34,7 @@ import { TaskRunner } from './task_runner'; import { RulesClient } from '../rules_client'; import { NormalizedRuleType } from '../rule_type_registry'; import { InMemoryMetrics } from '../monitoring'; +import { ActionsConfigMap } from '../lib/get_actions_config_map'; export interface TaskRunnerContext { logger: Logger; @@ -53,6 +54,7 @@ export interface TaskRunnerContext { kibanaBaseUrl: string | undefined; supportsEphemeralTasks: boolean; maxEphemeralActionsPerRule: number; + actionsConfigMap: ActionsConfigMap; cancelAlertsOnRuleTimeout: boolean; usageCounter?: UsageCounter; } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 735470cf7220b..246e7721d6ed4 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -27,6 +27,8 @@ import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; import { ExecutionHandler } from './create_execution_handler'; import { RawRule } from '../types'; +import { ActionsConfigMap } from '../lib/get_actions_config_map'; +import { AlertExecutionStore } from '../lib/alert_execution_store'; export interface RuleTaskRunResultWithActions { state: RuleExecutionState; @@ -145,6 +147,7 @@ export interface CreateExecutionHandlerOptions< ruleParams: RuleTypeParams; supportsEphemeralTasks: boolean; maxEphemeralActionsPerRule: number; + actionsConfigMap: ActionsConfigMap; } export interface ExecutionHandlerOptions { @@ -160,9 +163,3 @@ export enum ActionsCompletion { COMPLETE = 'complete', PARTIAL = 'partial', } - -export interface AlertExecutionStore { - numberOfTriggeredActions: number; - numberOfGeneratedActions: number; - triggeredActionsStatus: ActionsCompletion; -} diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 32c6e41c63268..4a2290d0bde33 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -41,7 +41,6 @@ import { RuleMonitoring, MappedParams, } from '../common'; -import { RuleTypeConfig } from './config'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -170,7 +169,6 @@ export interface RuleType< ruleTaskTimeout?: string; cancelAlertsOnRuleTimeout?: boolean; doesSetRecoveryContext?: boolean; - config?: RuleTypeConfig; } export type UntypedRuleType = RuleType< RuleTypeParams, diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 8c103ef8ce52c..93b1ba7d76a47 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -50,6 +50,7 @@ const enabledActionTypes = [ 'test.no-attempts-rate-limit', 'test.throw', 'test.excluded', + 'test.capped', ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { @@ -163,6 +164,9 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.alerting.invalidateApiKeysTask.interval="15s"', '--xpack.alerting.healthCheck.interval="1s"', '--xpack.alerting.rules.minimumScheduleInterval.value="1s"', + `--xpack.alerting.rules.run.actions.connectorTypeOverrides=${JSON.stringify([ + { id: 'test.capped', max: '1' }, + ])}`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, `--xpack.actions.microsoftGraphApiUrl=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}/api/_actions-FTS-external-service-simulators/exchange/users/test@/sendMail`, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts index 357458cc38e41..c83a1c543b5a7 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/action_types.ts @@ -31,8 +31,17 @@ export function defineActionTypes( throw new Error('this action is intended to fail'); }, }; + const cappedActionType: ActionType = { + id: 'test.capped', + name: 'Test: Capped', + minimumLicenseRequired: 'gold', + async executor() { + return { status: 'ok', actionId: '' }; + }, + }; actions.registerType(noopActionType); actions.registerType(throwActionType); + actions.registerType(cappedActionType); actions.registerType(getIndexRecordActionType()); actions.registerType(getDelayedActionType()); actions.registerType(getFailingActionType()); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/capped_action_type.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/capped_action_type.ts new file mode 100644 index 0000000000000..374dbddfc17b4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/capped_action_type.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getEventLog, getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createCappedActionsTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('Capped action type', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + it('should not trigger actions more than connector types limit', async () => { + const { body: createdAction01 } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.capped', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAction02 } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.capped', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAction03 } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.capped', + config: {}, + secrets: {}, + }) + .expect(200); + + objectRemover.add(Spaces.space1.id, createdAction01.id, 'action', 'actions'); + objectRemover.add(Spaces.space1.id, createdAction02.id, 'action', 'actions'); + objectRemover.add(Spaces.space1.id, createdAction03.id, 'action', 'actions'); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + name: 'should not trigger actions when connector type limit is reached', + rule_type_id: 'test.patternFiring', + schedule: { interval: '1s' }, + throttle: null, + notify_when: 'onActiveAlert', + params: { + pattern: { instance: arrayOfTrues(100) }, + }, + actions: [ + { + id: createdAction01.id, + group: 'default', + params: {}, + }, + { + id: createdAction02.id, + group: 'default', + params: {}, + }, + { + id: createdAction03.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await getRuleEvents(createdRule.id); + const [executionEvent] = await getRuleEvents(createdRule.id, 1); + + expect( + executionEvent?.kibana?.alert?.rule?.execution?.metrics?.number_of_generated_actions + ).to.be.eql(3, 'all the generated actions'); + expect( + executionEvent?.kibana?.alert?.rule?.execution?.metrics?.number_of_triggered_actions + ).to.be.eql(1, 'only 1 action was triggered'); + }); + }); + + async function getRuleEvents(id: string, minActions: number = 1) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions: new Map([['execute', { gte: minActions }]]), + }); + }); + } +} + +function arrayOfTrues(length: number) { + const result = []; + for (let i = 0; i < length; i++) { + result.push(true); + } + return result; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index bdc5a6c5ef646..4975207c02391 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -45,6 +45,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./ephemeral')); loadTestFile(require.resolve('./event_log_alerts')); loadTestFile(require.resolve('./snooze')); + loadTestFile(require.resolve('./capped_action_type')); loadTestFile(require.resolve('./scheduled_task_id')); // Do not place test files here, due to https://github.com/elastic/kibana/issues/123059