Add circuit breaker for max number of actions by connector type (#128319)

* connectorTypeOverrides key in kibana.yml can create a connector type specific action config.

* Update docs and docker allowed keys
This commit is contained in:
Ersin Erdal 2022-04-26 03:35:34 +02:00 committed by GitHub
parent 3ad6452166
commit f44e198784
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 827 additions and 362 deletions

View file

@ -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'
```
--
--
`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
--

View file

@ -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

View file

@ -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<typeof ruleExecutionMetricsSchema>;
export type RuleTaskState = t.TypeOf<typeof ruleStateSchema>;
export type RuleExecutionState = RuleTaskState & {
metrics: RuleExecutionMetrics;
alertExecutionStore: t.TypeOf<typeof alertExecutionStore>;
alertExecutionMetrics: t.TypeOf<typeof alertExecutionMetrics>;
};
export const ruleParamsSchema = t.intersection([

View file

@ -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<typeof configSchema>;
export type RulesConfig = TypeOf<typeof rulesSchema>;
export type RuleTypeConfig = Omit<RulesConfig, 'ruleTypeOverrides' | 'minimumScheduleInterval'>;
export type AlertingRulesConfig = Pick<AlertingConfig['rules'], 'minimumScheduleInterval'>;
export type ActionsConfig = RulesConfig['run']['actions'];
export type ActionTypeConfig = Omit<ActionsConfig, 'connectorTypeOverrides'>;

View file

@ -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);
});
});

View file

@ -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);
};
}

View file

@ -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,
},
});
});
});

View file

@ -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,
};
};

View file

@ -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,
});
});
});

View file

@ -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,
},
};
};

View file

@ -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,
},

View file

@ -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 && {

View file

@ -50,13 +50,6 @@ const sampleRuleType: RuleType<never, never, never, never, never, 'default'> = {
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<AlertingConfig>({
...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<AlertingConfig>({
...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 () => {

View file

@ -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,
});

View file

@ -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<ActionGroupIds extends string>(
minimumLicenseRequired: 'basic',
async executor() {},
producer: 'alerts',
config: {
run: {
actions: { max: 1000 },
},
},
};
if (!context && !state) return baseAlert;

View file

@ -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' } });

View file

@ -91,11 +91,6 @@ export function getBeforeSetup(
isExportable: true,
async executor() {},
producer: 'alerts',
config: {
run: {
actions: { max: 1000 },
},
},
}));
rulesClientParams.getEventLogClient.mockResolvedValue(
eventLogClient ?? eventLogClientMock.create()

View file

@ -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);
});
});

View file

@ -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,

View file

@ -108,11 +108,6 @@ export const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
producer: 'alerts',
config: {
run: {
actions: { max: 1000 },
},
},
};
export const mockRunNowResponse = {

View file

@ -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<unknown>);
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<unknown>);
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,

View file

@ -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<string, Alert<InstanceState, InstanceContext>>,
@ -874,7 +874,7 @@ export class TaskRunner<
executionState: RuleExecutionState
): RuleTaskState => {
return {
...omit(executionState, ['alertExecutionStore', 'metrics']),
...omit(executionState, ['alertExecutionMetrics', 'metrics']),
previousStartedAt: startedAt,
};
};

View file

@ -57,11 +57,6 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
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');

View file

@ -101,6 +101,11 @@ describe('Task Runner Factory', () => {
cancelAlertsOnRuleTimeout: true,
executionContext,
usageCounter: mockUsageCounter,
actionsConfigMap: {
default: {
max: 1000,
},
},
};
beforeEach(() => {

View file

@ -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;
}

View file

@ -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<ActionGroupIds extends string> {
@ -160,9 +163,3 @@ export enum ActionsCompletion {
COMPLETE = 'complete',
PARTIAL = 'partial',
}
export interface AlertExecutionStore {
numberOfTriggeredActions: number;
numberOfGeneratedActions: number;
triggeredActionsStatus: ActionsCompletion;
}

View file

@ -41,7 +41,6 @@ import {
RuleMonitoring,
MappedParams,
} from '../common';
import { RuleTypeConfig } from './config';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
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,

View file

@ -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`,

View file

@ -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());

View file

@ -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;
}

View file

@ -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