[Alerting] Limit the executable actions per Rule execution (#128079)

This commit is contained in:
Ersin Erdal 2022-03-18 21:19:26 +01:00 committed by GitHub
parent 4920ace1d5
commit 45c4e7dac1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1630 additions and 758 deletions

View file

@ -200,4 +200,7 @@ Specifies the minimum interval allowed for the all rules. This minimum is enforc
+
`<count>[s,m,h,d]`
+
For example, `20m`, `24h`, `7d`. Default: `1m`.
For example, `20m`, `24h`, `7d`. Default: `1m`.
`xpack.alerting.rules.execution.actions.max`
Specifies the maximum number of actions that a rule can trigger each time detection checks run.

View file

@ -200,6 +200,7 @@ kibana_vars=(
xpack.alerting.invalidateApiKeysTask.removalDelay
xpack.alerting.defaultRuleTaskTimeout
xpack.alerting.cancelAlertsOnRuleTimeout
xpack.alerting.rules.execution.actions.max
xpack.alerts.healthCheck.interval
xpack.alerts.invalidateApiKeysTask.interval
xpack.alerts.invalidateApiKeysTask.removalDelay

View file

@ -22,7 +22,14 @@ export interface IntervalSchedule extends SavedObjectAttributes {
// for the `typeof ThingValues[number]` types below, become string types that
// only accept the values in the associated string arrays
export const AlertExecutionStatusValues = ['ok', 'active', 'error', 'pending', 'unknown'] as const;
export const AlertExecutionStatusValues = [
'ok',
'active',
'error',
'pending',
'unknown',
'warning',
] as const;
export type AlertExecutionStatuses = typeof AlertExecutionStatusValues[number];
export enum AlertExecutionStatusErrorReasons {
@ -35,6 +42,10 @@ export enum AlertExecutionStatusErrorReasons {
Disabled = 'disabled',
}
export enum AlertExecutionStatusWarningReasons {
MAX_EXECUTABLE_ACTIONS = 'maxExecutableActions',
}
export interface AlertExecutionStatus {
status: AlertExecutionStatuses;
numberOfTriggeredActions?: number;
@ -45,6 +56,10 @@ export interface AlertExecutionStatus {
reason: AlertExecutionStatusErrorReasons;
message: string;
};
warning?: {
reason: AlertExecutionStatusWarningReasons;
message: string;
};
}
export type AlertActionParams = SavedObjectAttributes;

View file

@ -10,13 +10,6 @@ import { rawAlertInstance } from './alert_instance';
import { DateFromString } from './date_from_string';
import { IntervalSchedule, RuleMonitoring } from './alert';
const actionSchema = t.partial({
group: t.string,
id: t.string,
actionTypeId: t.string,
params: t.record(t.string, t.unknown),
});
export const ruleStateSchema = t.partial({
alertTypeState: t.record(t.string, t.unknown),
alertInstances: t.record(t.string, rawAlertInstance),
@ -29,11 +22,16 @@ const ruleExecutionMetricsSchema = t.partial({
esSearchDurationMs: t.number,
});
const alertExecutionStore = t.partial({
numberOfTriggeredActions: t.number,
triggeredActionsStatus: t.string,
});
export type RuleExecutionMetrics = t.TypeOf<typeof ruleExecutionMetricsSchema>;
export type RuleTaskState = t.TypeOf<typeof ruleStateSchema>;
export type RuleExecutionState = RuleTaskState & {
metrics: RuleExecutionMetrics;
triggeredActions: Array<t.TypeOf<typeof actionSchema>>;
alertExecutionStore: t.TypeOf<typeof alertExecutionStore>;
};
export const ruleParamsSchema = t.intersection([

View file

@ -23,6 +23,13 @@ describe('config validation', () => {
},
"maxEphemeralActionsPerAlert": 10,
"minimumScheduleInterval": "1m",
"rules": Object {
"execution": Object {
"actions": Object {
"max": 100000,
},
},
},
}
`);
});

View file

@ -8,6 +8,21 @@
import { schema, TypeOf } from '@kbn/config-schema';
import { validateDurationSchema } from './lib';
const ruleTypeSchema = schema.object({
id: schema.string(),
timeout: schema.maybe(schema.string({ validate: validateDurationSchema })),
});
const rulesSchema = schema.object({
execution: schema.object({
timeout: schema.maybe(schema.string({ validate: validateDurationSchema })),
actions: schema.object({
max: schema.number({ defaultValue: 100000 }),
}),
ruleTypeOverrides: schema.maybe(schema.arrayOf(ruleTypeSchema)),
}),
});
export const DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT = 10;
export const configSchema = schema.object({
healthCheck: schema.object({
@ -23,7 +38,10 @@ export const configSchema = schema.object({
defaultRuleTaskTimeout: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }),
cancelAlertsOnRuleTimeout: schema.boolean({ defaultValue: true }),
minimumScheduleInterval: schema.string({ validate: validateDurationSchema, defaultValue: '1m' }),
rules: rulesSchema,
});
export type AlertingConfig = TypeOf<typeof configSchema>;
export type PublicAlertingConfig = Pick<AlertingConfig, 'minimumScheduleInterval'>;
export type RulesConfig = TypeOf<typeof rulesSchema>;
export type RuleTypeConfig = Omit<RulesConfig, 'ruleTypeOverrides'>;

View file

@ -0,0 +1,22 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const translations = {
taskRunner: {
warning: {
maxExecutableActions: i18n.translate(
'xpack.alerting.taskRunner.warning.maxExecutableActions',
{
defaultMessage:
'The maximum number of actions for this rule type was reached; excess actions were not triggered.',
}
),
},
},
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getRulesConfig } from './get_rules_config';
import { RulesConfig } from '../config';
const ruleTypeId = 'test-rule-type-id';
const config = {
execution: {
timeout: '1m',
actions: { max: 1000 },
},
} as RulesConfig;
const configWithRuleType = {
execution: {
...config.execution,
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(getRulesConfig({ config: configWithRuleType, ruleTypeId })).toEqual({
execution: {
id: ruleTypeId,
timeout: '1m',
actions: { max: 20 },
},
});
});
test('returns the default config when there is no rule type specific config', () => {
expect(getRulesConfig({ config, ruleTypeId })).toEqual(config);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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 getRulesConfig = ({
config,
ruleTypeId,
}: {
config: RulesConfig;
ruleTypeId: string;
}): RuleTypeConfig => {
const ruleTypeConfig = config.execution.ruleTypeOverrides?.find(
(ruleType) => ruleType.id === ruleTypeId
);
return {
execution: {
...omit(config.execution, 'ruleTypeOverrides'),
...ruleTypeConfig,
},
};
};

View file

@ -6,7 +6,11 @@
*/
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { AlertAction, AlertExecutionStatusErrorReasons, RuleExecutionState } from '../types';
import {
AlertExecutionStatusErrorReasons,
AlertExecutionStatusWarningReasons,
RuleExecutionState,
} from '../types';
import {
executionStatusFromState,
executionStatusFromError,
@ -14,6 +18,8 @@ import {
ruleExecutionStatusFromRaw,
} from './rule_execution_status';
import { ErrorWithReason } from './error_with_reason';
import { translations } from '../constants/translations';
import { ActionsCompletion } from '../task_runner/types';
const MockLogger = loggingSystemMock.create().get();
const metrics = { numSearches: 1, esSearchDurationMs: 10, totalSearchDurationMs: 20 };
@ -25,42 +31,59 @@ describe('RuleExecutionStatus', () => {
describe('executionStatusFromState()', () => {
test('empty task state', () => {
const status = executionStatusFromState({} as RuleExecutionState);
const status = executionStatusFromState({
alertExecutionStore: {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
},
} as RuleExecutionState);
checkDateIsNearNow(status.lastExecutionDate);
expect(status.numberOfTriggeredActions).toBe(0);
expect(status.status).toBe('ok');
expect(status.error).toBe(undefined);
expect(status.warning).toBe(undefined);
});
test('task state with no instances', () => {
const status = executionStatusFromState({
alertInstances: {},
triggeredActions: [],
alertExecutionStore: {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
},
metrics,
});
checkDateIsNearNow(status.lastExecutionDate);
expect(status.numberOfTriggeredActions).toBe(0);
expect(status.status).toBe('ok');
expect(status.error).toBe(undefined);
expect(status.warning).toBe(undefined);
expect(status.metrics).toBe(metrics);
});
test('task state with one instance', () => {
const status = executionStatusFromState({
alertInstances: { a: {} },
triggeredActions: [],
alertExecutionStore: {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
},
metrics,
});
checkDateIsNearNow(status.lastExecutionDate);
expect(status.numberOfTriggeredActions).toBe(0);
expect(status.status).toBe('active');
expect(status.error).toBe(undefined);
expect(status.warning).toBe(undefined);
expect(status.metrics).toBe(metrics);
});
test('task state with numberOfTriggeredActions', () => {
const status = executionStatusFromState({
triggeredActions: [{ group: '1' } as AlertAction],
alertExecutionStore: {
numberOfTriggeredActions: 1,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
},
alertInstances: { a: {} },
metrics,
});
@ -68,8 +91,27 @@ describe('RuleExecutionStatus', () => {
expect(status.numberOfTriggeredActions).toBe(1);
expect(status.status).toBe('active');
expect(status.error).toBe(undefined);
expect(status.warning).toBe(undefined);
expect(status.metrics).toBe(metrics);
});
test('task state with warning', () => {
const status = executionStatusFromState({
alertInstances: { a: {} },
alertExecutionStore: {
numberOfTriggeredActions: 3,
triggeredActionsStatus: ActionsCompletion.PARTIAL,
},
metrics,
});
checkDateIsNearNow(status.lastExecutionDate);
expect(status.warning).toEqual({
message: translations.taskRunner.warning.maxExecutableActions,
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
});
expect(status.status).toBe('warning');
expect(status.error).toBe(undefined);
});
});
describe('executionStatusFromError()', () => {
@ -111,6 +153,7 @@ describe('RuleExecutionStatus', () => {
"lastDuration": 0,
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
"status": "ok",
"warning": null,
}
`);
});
@ -126,6 +169,7 @@ describe('RuleExecutionStatus', () => {
"lastDuration": 0,
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
"status": "ok",
"warning": null,
}
`);
});
@ -138,6 +182,7 @@ describe('RuleExecutionStatus', () => {
"lastDuration": 1234,
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
"status": "ok",
"warning": null,
}
`);
});
@ -151,6 +196,7 @@ describe('RuleExecutionStatus', () => {
"lastDuration": 0,
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
"status": "ok",
"warning": null,
}
`);
});
@ -184,6 +230,7 @@ describe('RuleExecutionStatus', () => {
checkDateIsNearNow(result.lastExecutionDate);
expect(result.status).toBe('unknown');
expect(result.error).toBe(undefined);
expect(result.warning).toBe(undefined);
expect(MockLogger.debug).toBeCalledWith(
'invalid ruleExecutionStatus lastExecutionDate "an invalid date" in raw rule rule-id'
);

View file

@ -6,18 +6,43 @@
*/
import { Logger } from 'src/core/server';
import { AlertExecutionStatus, RawRuleExecutionStatus, RuleExecutionState } from '../types';
import {
AlertExecutionStatus,
AlertExecutionStatusValues,
AlertExecutionStatusWarningReasons,
RawRuleExecutionStatus,
RuleExecutionState,
} from '../types';
import { getReasonFromError } from './error_with_reason';
import { getEsErrorMessage } from './errors';
import { AlertExecutionStatuses } from '../../common';
import { translations } from '../constants/translations';
import { ActionsCompletion } from '../task_runner/types';
export function executionStatusFromState(state: RuleExecutionState): AlertExecutionStatus {
const alertIds = Object.keys(state.alertInstances ?? {});
const hasIncompleteAlertExecution =
state.alertExecutionStore.triggeredActionsStatus === ActionsCompletion.PARTIAL;
let status: AlertExecutionStatuses =
alertIds.length === 0 ? AlertExecutionStatusValues[0] : AlertExecutionStatusValues[1];
if (hasIncompleteAlertExecution) {
status = AlertExecutionStatusValues[5];
}
return {
metrics: state.metrics,
numberOfTriggeredActions: state.triggeredActions?.length ?? 0,
numberOfTriggeredActions: state.alertExecutionStore.numberOfTriggeredActions,
lastExecutionDate: new Date(),
status: alertIds.length === 0 ? 'ok' : 'active',
status,
...(hasIncompleteAlertExecution && {
warning: {
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: translations.taskRunner.warning.maxExecutableActions,
},
}),
};
}
@ -37,6 +62,7 @@ export function ruleExecutionStatusToRaw({
lastDuration,
status,
error,
warning,
}: AlertExecutionStatus): RawRuleExecutionStatus {
return {
lastExecutionDate: lastExecutionDate.toISOString(),
@ -44,6 +70,7 @@ export function ruleExecutionStatusToRaw({
status,
// explicitly setting to null (in case undefined) due to partial update concerns
error: error ?? null,
warning: warning ?? null,
};
}
@ -60,6 +87,7 @@ export function ruleExecutionStatusFromRaw(
numberOfTriggeredActions,
status = 'unknown',
error,
warning,
} = rawRuleExecutionStatus;
let parsedDateMillis = lastExecutionDate ? Date.parse(lastExecutionDate) : Date.now();
@ -87,6 +115,10 @@ export function ruleExecutionStatusFromRaw(
executionStatus.error = error;
}
if (warning) {
executionStatus.warning = warning;
}
return executionStatus;
}
@ -94,4 +126,5 @@ export const getRuleExecutionStatusPending = (lastExecutionDate: string) => ({
status: 'pending' as AlertExecutionStatuses,
lastExecutionDate,
error: null,
warning: null,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AlertingPlugin, AlertingPluginsSetup, PluginSetupContract } from './plugin';
import { AlertingPlugin, PluginSetupContract } from './plugin';
import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/mocks';
import { coreMock, statusServiceMock } from '../../../../src/core/server/mocks';
import { licensingMock } from '../../licensing/server/mocks';
@ -20,42 +20,70 @@ import { RuleType } from './types';
import { eventLogMock } from '../../event_log/server/mocks';
import { actionsMock } from '../../actions/server/mocks';
const generateAlertingConfig = (): AlertingConfig => ({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 10,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
rules: {
execution: {
actions: {
max: 1000,
},
},
},
});
const sampleRuleType: RuleType<never, never, never, never, never, 'default'> = {
id: 'test',
name: 'test',
minimumLicenseRequired: 'basic',
isExportable: true,
actionGroups: [],
defaultActionGroupId: 'default',
producer: 'test',
config: {
execution: {
actions: {
max: 1000,
},
},
},
async executor() {},
};
describe('Alerting Plugin', () => {
describe('setup()', () => {
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
const setupMocks = coreMock.createSetup();
const mockPlugins = {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
};
let plugin: AlertingPlugin;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
let pluginsSetup: jest.Mocked<AlertingPluginsSetup>;
beforeEach(() => jest.clearAllMocks());
it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 10,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
const setupMocks = coreMock.createSetup();
// need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices()
await plugin.setup(setupMocks, {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
});
await plugin.setup(setupMocks, mockPlugins);
expect(setupMocks.status.set).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false);
@ -65,93 +93,88 @@ describe('Alerting Plugin', () => {
});
it('should create usage counter if usageCollection plugin is defined', async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 10,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
const usageCollectionSetup = createUsageCollectionSetupMock();
const setupMocks = coreMock.createSetup();
// need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices()
await plugin.setup(setupMocks, {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
usageCollection: usageCollectionSetup,
});
await plugin.setup(setupMocks, { ...mockPlugins, usageCollection: usageCollectionSetup });
expect(usageCollectionSetup.createUsageCounter).toHaveBeenCalled();
expect(usageCollectionSetup.registerCollector).toHaveBeenCalled();
});
it(`exposes configured minimumScheduleInterval()`, async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 100,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
const setupContract = plugin.setup(coreMock.createSetup(), {
licensing: licensingMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsSetup,
taskManager: taskManagerMock.createSetup(),
eventLog: eventLogServiceMock.create(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
});
const setupContract = await plugin.setup(setupMocks, mockPlugins);
expect(setupContract.getConfig()).toEqual({ minimumScheduleInterval: '1m' });
});
it(`applies the default config if there is no rule type specific config `, async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
...generateAlertingConfig(),
rules: {
execution: {
actions: {
max: 123,
},
},
},
});
plugin = new AlertingPlugin(context);
const setupContract = await plugin.setup(setupMocks, mockPlugins);
const ruleType = { ...sampleRuleType };
setupContract.registerType(ruleType);
expect(ruleType.config).toEqual({
execution: {
actions: { max: 123 },
},
});
});
it(`applies rule type specific config if defined in config`, async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
...generateAlertingConfig(),
rules: {
execution: {
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({
execution: {
id: sampleRuleType.id,
actions: {
max: 123,
},
timeout: '1d',
},
});
});
describe('registerType()', () => {
let setup: PluginSetupContract;
const sampleRuleType: RuleType<never, never, never, never, never, 'default'> = {
id: 'test',
name: 'test',
minimumLicenseRequired: 'basic',
isExportable: true,
actionGroups: [],
defaultActionGroupId: 'default',
producer: 'test',
async executor() {},
};
beforeEach(async () => {
coreSetup = coreMock.createSetup();
pluginsSetup = {
taskManager: taskManagerMock.createSetup(),
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
licensing: licensingMock.createSetup(),
eventLog: eventLogMock.createSetup(),
actions: actionsMock.createSetup(),
statusService: statusServiceMock.createSetupContract(),
};
setup = plugin.setup(coreSetup, pluginsSetup);
setup = await plugin.setup(setupMocks, mockPlugins);
});
it('should throw error when license type is invalid', async () => {
@ -221,19 +244,9 @@ describe('Alerting Plugin', () => {
describe('start()', () => {
describe('getRulesClientWithRequest()', () => {
it('throws error when encryptedSavedObjects plugin is missing encryption key', async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 10,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
const plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
@ -264,19 +277,9 @@ describe('Alerting Plugin', () => {
});
it(`doesn't throw error when encryptedSavedObjects plugin has encryption key`, async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 10,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
const plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = {
@ -321,19 +324,9 @@ describe('Alerting Plugin', () => {
});
test(`exposes getAlertingAuthorizationWithRequest()`, async () => {
const context = coreMock.createPluginInitializerContext<AlertingConfig>({
healthCheck: {
interval: '5m',
},
invalidateApiKeysTask: {
interval: '5m',
removalDelay: '1h',
},
maxEphemeralActionsPerAlert: 100,
defaultRuleTaskTimeout: '5m',
cancelAlertsOnRuleTimeout: true,
minimumScheduleInterval: '1m',
});
const context = coreMock.createPluginInitializerContext<AlertingConfig>(
generateAlertingConfig()
);
const plugin = new AlertingPlugin(context);
const encryptedSavedObjectsSetup = {

View file

@ -62,6 +62,7 @@ import { getHealth } from './health/get_health';
import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory';
import { AlertingAuthorization } from './authorization';
import { getSecurityHealth, SecurityHealth } from './lib/get_security_health';
import { getRulesConfig } from './lib/get_rules_config';
export const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = {
@ -262,7 +263,8 @@ export class AlertingPlugin {
encryptedSavedObjects: plugins.encryptedSavedObjects,
});
const alertingConfig = this.config;
const alertingConfig: AlertingConfig = this.config;
return {
registerType<
Params extends AlertTypeParams = AlertTypeParams,
@ -286,7 +288,10 @@ export class AlertingPlugin {
if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) {
throw new Error(`"${ruleType.minimumLicenseRequired}" is not a valid license type`);
}
ruleType.config = getRulesConfig({
config: alertingConfig.rules,
ruleTypeId: ruleType.id,
});
ruleType.ruleTaskTimeout =
ruleType.ruleTaskTimeout ?? alertingConfig.defaultRuleTaskTimeout;
ruleType.cancelAlertsOnRuleTimeout =

File diff suppressed because it is too large Load diff

View file

@ -1314,6 +1314,7 @@ export class RulesClient {
lastDuration: 0,
lastExecutionDate: new Date().toISOString(),
error: null,
warning: null,
},
});
try {

View file

@ -86,6 +86,7 @@ describe('aggregate()', () => {
{ key: 'ok', doc_count: 10 },
{ key: 'pending', doc_count: 4 },
{ key: 'unknown', doc_count: 2 },
{ key: 'warning', doc_count: 1 },
],
},
enabled: {
@ -135,6 +136,7 @@ describe('aggregate()', () => {
"ok": 10,
"pending": 4,
"unknown": 2,
"warning": 1,
},
"ruleEnabledStatus": Object {
"disabled": 2,

View file

@ -404,6 +404,7 @@ describe('create()', () => {
"error": null,
"lastExecutionDate": "2019-02-12T21:01:22.479Z",
"status": "pending",
"warning": null,
},
"legacyId": null,
"meta": Object {
@ -608,6 +609,7 @@ describe('create()', () => {
"error": null,
"lastExecutionDate": "2019-02-12T21:01:22.479Z",
"status": "pending",
"warning": null,
},
"legacyId": "123",
"meta": Object {
@ -1035,6 +1037,7 @@ describe('create()', () => {
error: null,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
@ -1162,6 +1165,11 @@ describe('create()', () => {
extractReferences: extractReferencesFn,
injectReferences: injectReferencesFn,
},
config: {
execution: {
actions: { max: 1000 },
},
},
}));
const data = getMockData({
params: ruleParams,
@ -1233,6 +1241,7 @@ describe('create()', () => {
error: null,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
@ -1329,6 +1338,11 @@ describe('create()', () => {
extractReferences: extractReferencesFn,
injectReferences: injectReferencesFn,
},
config: {
execution: {
actions: { max: 1000 },
},
},
}));
const data = getMockData({
params: ruleParams,
@ -1400,6 +1414,7 @@ describe('create()', () => {
error: null,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
meta: { versionApiKeyLastmodified: kibanaVersion },
@ -1577,6 +1592,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
error: null,
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
},
@ -1708,6 +1724,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
error: null,
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
},
@ -1839,6 +1856,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
error: null,
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
},
@ -1985,6 +2003,7 @@ describe('create()', () => {
status: 'pending',
lastExecutionDate: '2019-02-12T21:01:22.479Z',
error: null,
warning: null,
},
monitoring: {
execution: {
@ -2078,6 +2097,11 @@ describe('create()', () => {
isExportable: true,
async executor() {},
producer: 'alerts',
config: {
execution: {
actions: { max: 1000 },
},
},
});
await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"params invalid: [param1]: expected value of type [string] but got [undefined]"`
@ -2355,6 +2379,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
error: null,
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
},
@ -2456,6 +2481,7 @@ describe('create()', () => {
lastExecutionDate: '2019-02-12T21:01:22.479Z',
status: 'pending',
error: null,
warning: null,
},
monitoring: getDefaultRuleMonitoring(),
},
@ -2535,6 +2561,11 @@ describe('create()', () => {
extractReferences: jest.fn(),
injectReferences: jest.fn(),
},
config: {
execution: {
actions: { max: 1000 },
},
},
}));
const data = getMockData({ schedule: { interval: '1s' } });

View file

@ -242,6 +242,7 @@ describe('enable()', () => {
lastDuration: 0,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
error: null,
warning: null,
},
},
{
@ -353,6 +354,7 @@ describe('enable()', () => {
lastDuration: 0,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
error: null,
warning: null,
},
},
{
@ -521,6 +523,7 @@ describe('enable()', () => {
lastDuration: 0,
lastExecutionDate: '2019-02-12T21:01:22.479Z',
error: null,
warning: null,
},
},
{

View file

@ -92,6 +92,7 @@ const BaseRuleSavedObject: SavedObject<RawRule> = {
status: 'unknown',
lastExecutionDate: '2020-08-20T19:23:38Z',
error: null,
warning: null,
},
},
references: [],

View file

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

View file

@ -295,7 +295,7 @@ function setupRawAlertMocks(
// splitting this out as it's easier to set a breakpoint :-)
// eslint-disable-next-line prettier/prettier
unsecuredSavedObjectsClient.get.mockImplementation(async () =>
unsecuredSavedObjectsClient.get.mockImplementation(async () =>
cloneDeep(rawAlert)
);

View file

@ -163,6 +163,16 @@
"type": "keyword"
}
}
},
"warning": {
"properties": {
"reason": {
"type": "keyword"
},
"message": {
"type": "keyword"
}
}
}
}
},

View file

@ -17,7 +17,7 @@ import {
SavedObjectAttribute,
SavedObjectReference,
} from '../../../../../src/core/server';
import { RawRule, RawAlertAction } from '../types';
import { RawRule, RawAlertAction, RawRuleExecutionStatus } from '../types';
import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server';
import type { IsMigrationNeededPredicate } from '../../../encrypted_saved_objects/server';
import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations';
@ -280,7 +280,7 @@ function initializeExecutionStatus(
status: 'pending',
lastExecutionDate: new Date().toISOString(),
error: null,
},
} as RawRuleExecutionStatus,
},
};
}

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { createExecutionHandler, CreateExecutionHandlerOptions } from './create_execution_handler';
import { createExecutionHandler } from './create_execution_handler';
import { ActionsCompletion, AlertExecutionStore, CreateExecutionHandlerOptions } from './types';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import {
actionsMock,
actionsClientMock,
actionsMock,
renderActionParameterTemplatesDefault,
} from '../../../actions/server/mocks';
import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
@ -18,10 +19,10 @@ import { asSavedObjectExecutionSource } from '../../../actions/server';
import { InjectActionParamsOpts } from './inject_action_params';
import { NormalizedRuleType } from '../rule_type_registry';
import {
AlertInstanceContext,
AlertInstanceState,
AlertTypeParams,
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
} from '../types';
jest.mock('./inject_action_params', () => ({
@ -52,6 +53,11 @@ const ruleType: NormalizedRuleType<
},
executor: jest.fn(),
producer: 'alerts',
config: {
execution: {
actions: { max: 1000 },
},
},
};
const actionsClient = actionsClientMock.create();
@ -102,6 +108,7 @@ const createExecutionHandlerParams: jest.Mocked<
supportsEphemeralTasks: false,
maxEphemeralActionsPerRule: 10,
};
let alertExecutionStore: AlertExecutionStore;
describe('Create Execution Handler', () => {
beforeEach(() => {
@ -117,17 +124,22 @@ describe('Create Execution Handler', () => {
mockActionsPlugin.renderActionParameterTemplates.mockImplementation(
renderActionParameterTemplatesDefault
);
alertExecutionStore = {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
};
});
test('enqueues execution per selected action', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
const result = await executionHandler({
await executionHandler({
actionGroup: 'default',
state: {},
context: {},
alertId: '2',
alertExecutionStore,
});
expect(result).toHaveLength(1);
expect(alertExecutionStore.numberOfTriggeredActions).toBe(1);
expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith(
createExecutionHandlerParams.request
);
@ -228,6 +240,8 @@ describe('Create Execution Handler', () => {
stateVal: 'My goes here',
},
});
expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.COMPLETE);
});
test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => {
@ -256,7 +270,9 @@ describe('Create Execution Handler', () => {
state: {},
context: {},
alertId: '2',
alertExecutionStore,
});
expect(alertExecutionStore.numberOfTriggeredActions).toBe(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledWith({
id: '2',
@ -304,13 +320,14 @@ describe('Create Execution Handler', () => {
],
});
const result = await executionHandler({
await executionHandler({
actionGroup: 'default',
state: {},
context: {},
alertId: '2',
alertExecutionStore,
});
expect(result).toEqual([]);
expect(alertExecutionStore.numberOfTriggeredActions).toBe(0);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0);
mockActionsPlugin.isActionExecutable.mockImplementation(() => true);
@ -323,31 +340,34 @@ describe('Create Execution Handler', () => {
state: {},
context: {},
alertId: '2',
alertExecutionStore,
});
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1);
});
test('limits actionsPlugin.execute per action group', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
const result = await executionHandler({
await executionHandler({
actionGroup: 'other-group',
state: {},
context: {},
alertId: '2',
alertExecutionStore,
});
expect(result).toEqual([]);
expect(alertExecutionStore.numberOfTriggeredActions).toBe(0);
expect(actionsClient.enqueueExecution).not.toHaveBeenCalled();
});
test('context attribute gets parameterized', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
const result = await executionHandler({
await executionHandler({
actionGroup: 'default',
context: { value: 'context-val' },
state: {},
alertId: '2',
alertExecutionStore,
});
expect(result).toHaveLength(1);
expect(alertExecutionStore.numberOfTriggeredActions).toBe(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(`
Array [
@ -384,13 +404,13 @@ describe('Create Execution Handler', () => {
test('state attribute gets parameterized', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
const result = await executionHandler({
await executionHandler({
actionGroup: 'default',
context: {},
state: { value: 'state-val' },
alertId: '2',
alertExecutionStore,
});
expect(result).toHaveLength(1);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(`
Array [
@ -427,17 +447,74 @@ describe('Create Execution Handler', () => {
test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
const result = await executionHandler({
await executionHandler({
// we have to trick the compiler as this is an invalid type and this test checks whether we
// enforce this at runtime as well as compile time
actionGroup: 'invalid-group' as 'default' | 'other-group',
context: {},
state: {},
alertId: '2',
alertExecutionStore,
});
expect(result).toEqual([]);
expect(createExecutionHandlerParams.logger.error).toHaveBeenCalledWith(
'Invalid action group "invalid-group" for rule "test".'
);
expect(alertExecutionStore.numberOfTriggeredActions).toBe(0);
expect(alertExecutionStore.triggeredActionsStatus).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: {
execution: {
actions: { max: 2 },
},
},
},
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: '3',
group: 'default',
actionTypeId: 'test3',
params: {
foo: true,
contextVal: '{{context.value}} goes here',
stateVal: '{{state.value}} goes here',
},
},
],
});
alertExecutionStore = {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
};
await executionHandler({
actionGroup: 'default',
context: {},
state: { value: 'state-val' },
alertId: '2',
alertExecutionStore,
});
expect(alertExecutionStore.numberOfTriggeredActions).toBe(2);
expect(alertExecutionStore.triggeredActionsStatus).toBe(ActionsCompletion.PARTIAL);
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2);
});
});

View file

@ -4,73 +4,26 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger, KibanaRequest } from '../../../../../src/core/server';
import { transformActionParams } from './transform_action_params';
import {
asSavedObjectExecutionSource,
PluginStartContract as ActionsPluginStartContract,
} from '../../../actions/server';
import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { asSavedObjectExecutionSource } from '../../../actions/server';
import { SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { EVENT_LOG_ACTIONS } from '../plugin';
import { injectActionParams } from './inject_action_params';
import {
AlertAction,
AlertInstanceContext,
AlertInstanceState,
AlertTypeParams,
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
RawRule,
} from '../types';
import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry';
import { UntypedNormalizedRuleType } from '../rule_type_registry';
import { isEphemeralTaskRejectedDueToCapacityError } from '../../../task_manager/server';
import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object';
export interface CreateExecutionHandlerOptions<
Params extends AlertTypeParams,
ExtractedParams extends AlertTypeParams,
State extends AlertTypeState,
InstanceState extends AlertInstanceState,
InstanceContext extends AlertInstanceContext,
ActionGroupIds extends string,
RecoveryActionGroupId extends string
> {
ruleId: string;
ruleName: string;
executionId: string;
tags?: string[];
actionsPlugin: ActionsPluginStartContract;
actions: AlertAction[];
spaceId: string;
apiKey: RawRule['apiKey'];
kibanaBaseUrl: string | undefined;
ruleType: NormalizedRuleType<
Params,
ExtractedParams,
State,
InstanceState,
InstanceContext,
ActionGroupIds,
RecoveryActionGroupId
>;
logger: Logger;
eventLogger: IEventLogger;
request: KibanaRequest;
ruleParams: AlertTypeParams;
supportsEphemeralTasks: boolean;
maxEphemeralActionsPerRule: number;
}
interface ExecutionHandlerOptions<ActionGroupIds extends string> {
actionGroup: ActionGroupIds;
actionSubgroup?: string;
alertId: string;
context: AlertInstanceContext;
state: AlertInstanceState;
}
import { ActionsCompletion, CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types';
export type ExecutionHandler<ActionGroupIds extends string> = (
options: ExecutionHandlerOptions<ActionGroupIds>
) => Promise<AlertAction[]>;
) => Promise<void>;
export function createExecutionHandler<
Params extends AlertTypeParams,
@ -114,13 +67,14 @@ export function createExecutionHandler<
actionSubgroup,
context,
state,
alertExecutionStore,
alertId,
}: ExecutionHandlerOptions<ActionGroupIds | RecoveryActionGroupId>) => {
const triggeredActions: AlertAction[] = [];
if (!ruleTypeActionGroups.has(actionGroup)) {
logger.error(`Invalid action group "${actionGroup}" for rule "${ruleType.id}".`);
return triggeredActions;
return;
}
const actions = ruleActions
.filter(({ group }) => group === actionGroup)
.map((action) => {
@ -163,6 +117,11 @@ export function createExecutionHandler<
let ephemeralActionsToSchedule = maxEphemeralActionsPerRule;
for (const action of actions) {
if (alertExecutionStore.numberOfTriggeredActions >= ruleType.config!.execution.actions.max) {
alertExecutionStore.triggeredActionsStatus = ActionsCompletion.PARTIAL;
break;
}
if (
!actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true })
) {
@ -205,11 +164,11 @@ export function createExecutionHandler<
await actionsClient.enqueueExecution(enqueueOptions);
}
} finally {
triggeredActions.push(action);
alertExecutionStore.numberOfTriggeredActions++;
}
} else {
await actionsClient.enqueueExecution(enqueueOptions);
triggeredActions.push(action);
alertExecutionStore.numberOfTriggeredActions++;
}
const event = createAlertEventLogRecordObject({
@ -244,6 +203,5 @@ export function createExecutionHandler<
eventLogger.logEvent(event);
}
return triggeredActions;
};
}

View file

@ -53,7 +53,15 @@ export const RULE_ACTIONS = [
},
];
export const SAVED_OBJECT_UPDATE_PARAMS = [
export const generateSavedObjectParams = ({
error = null,
warning = null,
status = 'ok',
}: {
error?: null | { reason: string; message: string };
warning?: null | { reason: string; message: string };
status?: string;
}) => [
'alert',
'1',
{
@ -71,10 +79,11 @@ export const SAVED_OBJECT_UPDATE_PARAMS = [
},
},
executionStatus: {
error: null,
error,
lastDuration: 0,
lastExecutionDate: '1970-01-01T00:00:00.000Z',
status: 'ok',
status,
warning,
},
},
{ refresh: false, namespace: undefined },
@ -92,6 +101,11 @@ export const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
producer: 'alerts',
config: {
execution: {
actions: { max: 1000 },
},
},
};
export const mockRunNowResponse = {
@ -189,6 +203,7 @@ export const generateEventLog = ({
instanceId,
actionSubgroup,
actionGroupId,
actionId,
status,
numberOfTriggeredActions,
savedObjects = [generateAlertSO('1')],
@ -236,11 +251,19 @@ export const generateEventLog = ({
...(task && {
task: {
schedule_delay: 0,
scheduled: '1970-01-01T00:00:00.000Z',
scheduled: DATE_1970,
},
}),
},
message: generateMessage({ action, instanceId, actionGroupId, actionSubgroup, reason, status }),
message: generateMessage({
action,
instanceId,
actionGroupId,
actionSubgroup,
reason,
status,
actionId,
}),
rule: {
category: 'test',
id: '1',
@ -255,6 +278,7 @@ const generateMessage = ({
instanceId,
actionGroupId,
actionSubgroup,
actionId,
reason,
status,
}: GeneratorParams) => {
@ -279,9 +303,9 @@ const generateMessage = ({
if (action === EVENT_LOG_ACTIONS.executeAction) {
if (actionSubgroup) {
return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${instanceId}`;
return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${actionId}`;
}
return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${instanceId}`;
return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`;
}
if (action === EVENT_LOG_ACTIONS.execute) {
@ -292,7 +316,7 @@ const generateMessage = ({
return `${RULE_TYPE_ID}:${RULE_ID}: execution failed`;
}
if (actionGroupId === 'recovered') {
return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${instanceId}`;
return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`;
}
return `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`;
}
@ -310,6 +334,7 @@ export const generateRunnerResult = ({
history = Array(false),
state = false,
interval = '10s',
alertInstances = {},
}: GeneratorParams = {}) => {
return {
monitoring: {
@ -325,7 +350,7 @@ export const generateRunnerResult = ({
interval,
},
state: {
...(state && { alertInstances: {} }),
...(state && { alertInstances }),
...(state && { alertTypeState: undefined }),
...(state && { previousStartedAt: new Date('1970-01-01T00:00:00.000Z') }),
},

View file

@ -14,6 +14,7 @@ import {
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
AlertExecutionStatusWarningReasons,
} from '../types';
import {
ConcreteTaskInstance,
@ -55,7 +56,7 @@ import {
generateRunnerResult,
RULE_ACTIONS,
generateEnqueueFunctionInput,
SAVED_OBJECT_UPDATE_PARAMS,
generateSavedObjectParams,
mockTaskInstance,
GENERIC_ERROR_MESSAGE,
generateAlertInstance,
@ -65,6 +66,7 @@ import {
DATE_1970_5_MIN,
} from './fixtures';
import { EVENT_LOG_ACTIONS } from '../plugin';
import { translations } from '../constants/translations';
jest.mock('uuid', () => ({
v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
@ -240,7 +242,7 @@ describe('Task Runner', () => {
expect(
taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update
).toHaveBeenCalledWith(...SAVED_OBJECT_UPDATE_PARAMS);
).toHaveBeenCalledWith(...generateSavedObjectParams({}));
expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1);
expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toHaveBeenCalledWith(
@ -345,6 +347,7 @@ describe('Task Runner', () => {
instanceId: '1',
actionSubgroup: 'subDefault',
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
actionId: '1',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@ -916,6 +919,7 @@ describe('Task Runner', () => {
action: EVENT_LOG_ACTIONS.executeAction,
actionGroupId: 'default',
instanceId: '1',
actionId: '1',
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
})
);
@ -1043,9 +1047,10 @@ describe('Task Runner', () => {
4,
generateEventLog({
action: EVENT_LOG_ACTIONS.executeAction,
savedObjects: [generateAlertSO('1'), generateActionSO('2')],
actionGroupId: 'recovered',
instanceId: '2',
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
actionGroupId: 'default',
instanceId: '1',
actionId: '1',
})
);
@ -1053,9 +1058,10 @@ describe('Task Runner', () => {
5,
generateEventLog({
action: EVENT_LOG_ACTIONS.executeAction,
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
actionGroupId: 'default',
instanceId: '1',
savedObjects: [generateAlertSO('1'), generateActionSO('2')],
actionGroupId: 'recovered',
instanceId: '2',
actionId: '2',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
@ -1142,8 +1148,8 @@ describe('Task Runner', () => {
const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger;
expect(eventLogger.logEvent).toHaveBeenCalledTimes(6);
expect(enqueueFunction).toHaveBeenCalledTimes(2);
expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('1');
expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('2');
expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('2');
expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('1');
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
}
);
@ -2315,7 +2321,7 @@ describe('Task Runner', () => {
);
expect(
taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update
).toHaveBeenCalledWith(...SAVED_OBJECT_UPDATE_PARAMS);
).toHaveBeenCalledWith(...generateSavedObjectParams({}));
expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled();
});
@ -2449,4 +2455,179 @@ 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: {
execution: {
actions: { max: 3 },
},
},
};
const warning = {
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: translations.taskRunner.warning.maxExecutableActions,
};
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
ruleType.executor.mockImplementation(
async ({
services: executorServices,
}: AlertExecutorOptions<
AlertTypeParams,
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
string
>) => {
executorServices.alertFactory.create('1').scheduleActions('default');
}
);
rulesClient.get.mockResolvedValue({
...mockedRuleTypeSavedObject,
actions: [
{
group: 'default',
id: '1',
actionTypeId: 'action',
},
{
group: 'default',
id: '2',
actionTypeId: 'action',
},
{
group: 'default',
id: '3',
actionTypeId: 'action',
},
{
group: 'default',
id: '4',
actionTypeId: 'action',
},
{
group: 'default',
id: '5',
actionTypeId: 'action',
},
],
} as jest.ResolvedValue<unknown>);
ruleTypeRegistry.get.mockReturnValue(ruleTypeWithConfig);
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT);
const taskRunner = new TaskRunner(
ruleTypeWithConfig,
mockedTaskInstance,
taskRunnerFactoryInitializerParams
);
const runnerResult = await taskRunner.run();
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(
ruleTypeWithConfig.config.execution.actions.max
);
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',
},
},
},
})
);
const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
expect(eventLogger.logEvent).toHaveBeenCalledTimes(7);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
1,
generateEventLog({
task: true,
action: EVENT_LOG_ACTIONS.executeStart,
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
2,
generateEventLog({
duration: 0,
start: DATE_1970,
action: EVENT_LOG_ACTIONS.newInstance,
actionGroupId: 'default',
instanceId: '1',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
3,
generateEventLog({
duration: 0,
start: DATE_1970,
action: EVENT_LOG_ACTIONS.activeInstance,
actionGroupId: 'default',
instanceId: '1',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
4,
generateEventLog({
action: EVENT_LOG_ACTIONS.executeAction,
savedObjects: [generateAlertSO('1'), generateActionSO('1')],
actionGroupId: 'default',
instanceId: '1',
actionId: '1',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
5,
generateEventLog({
action: EVENT_LOG_ACTIONS.executeAction,
savedObjects: [generateAlertSO('1'), generateActionSO('2')],
actionGroupId: 'default',
instanceId: '1',
actionId: '2',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
6,
generateEventLog({
action: EVENT_LOG_ACTIONS.executeAction,
savedObjects: [generateAlertSO('1'), generateActionSO('3')],
actionGroupId: 'default',
instanceId: '1',
actionId: '3',
})
);
expect(eventLogger.logEvent).toHaveBeenNthCalledWith(
7,
generateEventLog({
action: EVENT_LOG_ACTIONS.execute,
outcome: 'success',
status: 'warning',
numberOfTriggeredActions: ruleTypeWithConfig.config.execution.actions.max,
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
task: true,
})
);
});
});

View file

@ -5,56 +5,57 @@
* 2.0.
*/
import apm from 'elastic-apm-node';
import { pickBy, mapValues, without, cloneDeep, concat, set, omit } from 'lodash';
import { cloneDeep, mapValues, omit, pickBy, set, without } from 'lodash';
import type { Request } from '@hapi/hapi';
import { UsageCounter } from 'src/plugins/usage_collection/server';
import uuid from 'uuid';
import { addSpaceIdToPath } from '../../../spaces/server';
import { Logger, KibanaRequest } from '../../../../../src/core/server';
import { KibanaRequest, Logger } from '../../../../../src/core/server';
import { TaskRunnerContext } from './task_runner_factory';
import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_manager/server';
import { createExecutionHandler, ExecutionHandler } from './create_execution_handler';
import { Alert as CreatedAlert, createAlertFactory } from '../alert';
import {
validateRuleTypeParams,
executionStatusFromState,
executionStatusFromError,
ruleExecutionStatusToRaw,
ErrorWithReason,
createWrappedScopedClusterClientFactory,
ElasticsearchError,
ErrorWithReason,
executionStatusFromError,
executionStatusFromState,
getRecoveredAlerts,
ruleExecutionStatusToRaw,
validateRuleTypeParams,
} from '../lib';
import {
RawRule,
IntervalSchedule,
RawAlertInstance,
RuleTaskState,
Alert,
SanitizedAlert,
AlertExecutionStatus,
AlertExecutionStatusErrorReasons,
RuleTypeRegistry,
IntervalSchedule,
RawAlertInstance,
RawRule,
RawRuleExecutionStatus,
RuleExecutionRunResult,
RuleExecutionState,
RuleMonitoring,
RuleMonitoringHistory,
RawRuleExecutionStatus,
AlertAction,
RuleExecutionState,
RuleExecutionRunResult,
RuleTaskState,
RuleTypeRegistry,
SanitizedAlert,
} from '../types';
import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type';
import { getExecutionSuccessRatio, getExecutionDurationPercentiles } from '../lib/monitoring';
import { asErr, asOk, map, promiseResult, resolveErr, Resultable } from '../lib/result_type';
import { getExecutionDurationPercentiles, getExecutionSuccessRatio } from '../lib/monitoring';
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
import { EVENT_LOG_ACTIONS } from '../plugin';
import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
import { isAlertSavedObjectNotFoundError, isEsUnavailableError } from '../lib/is_alerting_error';
import { partiallyUpdateAlert } from '../saved_objects';
import {
AlertInstanceContext,
AlertInstanceState,
AlertTypeParams,
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
WithoutReservedActionGroups,
parseDuration,
MONITORING_HISTORY_LIMIT,
parseDuration,
WithoutReservedActionGroups,
} from '../../common';
import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry';
import { getEsErrorMessage } from '../lib/errors';
@ -62,9 +63,9 @@ import {
createAlertEventLogRecordObject,
Event,
} from '../lib/create_alert_event_log_record_object';
import { createWrappedScopedClusterClientFactory } from '../lib';
import { getRecoveredAlerts } from '../lib';
import {
ActionsCompletion,
AlertExecutionStore,
GenerateNewAndRecoveredAlertEventsParams,
LogActiveAndRecoveredAlertsParams,
RuleTaskInstance,
@ -269,7 +270,8 @@ export class TaskRunner<
private async executeAlert(
alertId: string,
alert: CreatedAlert<InstanceState, InstanceContext>,
executionHandler: ExecutionHandler<ActionGroupIds | RecoveryActionGroupId>
executionHandler: ExecutionHandler<ActionGroupIds | RecoveryActionGroupId>,
alertExecutionStore: AlertExecutionStore
) {
const {
actionGroup,
@ -279,7 +281,14 @@ export class TaskRunner<
} = alert.getScheduledActionOptions()!;
alert.updateLastScheduledActions(actionGroup, actionSubgroup);
alert.unscheduleActions();
return executionHandler({ actionGroup, actionSubgroup, context, state, alertId });
return executionHandler({
actionGroup,
actionSubgroup,
context,
state,
alertId,
alertExecutionStore,
});
}
private async executeAlerts(
@ -462,26 +471,15 @@ export class TaskRunner<
});
}
let triggeredActions: AlertAction[] = [];
const alertExecutionStore: AlertExecutionStore = {
numberOfTriggeredActions: 0,
triggeredActionsStatus: ActionsCompletion.COMPLETE,
};
if (!muteAll && this.shouldLogAndScheduleActionsForAlerts()) {
const mutedAlertIdsSet = new Set(mutedInstanceIds);
const scheduledActionsForRecoveredAlerts = await scheduleActionsForRecoveredAlerts<
InstanceState,
InstanceContext,
RecoveryActionGroupId
>({
recoveryActionGroup: this.ruleType.recoveryActionGroup,
recoveredAlerts,
executionHandler,
mutedAlertIdsSet,
logger: this.logger,
ruleLabel,
});
triggeredActions = concat(triggeredActions, scheduledActionsForRecoveredAlerts);
const alertsToExecute = Object.entries(alertsWithScheduledActions).filter(
const alertsWithExecutableActions = Object.entries(alertsWithScheduledActions).filter(
([alertName, alert]: [string, CreatedAlert<InstanceState, InstanceContext>]) => {
const throttled = alert.isThrottled(throttle);
const muted = mutedAlertIdsSet.has(alertName);
@ -508,14 +506,26 @@ export class TaskRunner<
}
);
const allTriggeredActions = await Promise.all(
alertsToExecute.map(
await Promise.all(
alertsWithExecutableActions.map(
([alertId, alert]: [string, CreatedAlert<InstanceState, InstanceContext>]) =>
this.executeAlert(alertId, alert, executionHandler)
this.executeAlert(alertId, alert, executionHandler, alertExecutionStore)
)
);
triggeredActions = concat(triggeredActions, ...allTriggeredActions);
await scheduleActionsForRecoveredAlerts<
InstanceState,
InstanceContext,
RecoveryActionGroupId
>({
recoveryActionGroup: this.ruleType.recoveryActionGroup,
recoveredAlerts,
executionHandler,
mutedAlertIdsSet,
logger: this.logger,
ruleLabel,
alertExecutionStore,
});
} else {
if (muteAll) {
this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is muted.`);
@ -535,7 +545,7 @@ export class TaskRunner<
return {
metrics: searchMetrics,
triggeredActions,
alertExecutionStore,
alertTypeState: updatedRuleTypeState || undefined,
alertInstances: mapValues<
Record<string, CreatedAlert<InstanceState, InstanceContext>>,
@ -639,7 +649,7 @@ export class TaskRunner<
),
schedule: asOk(
// fetch the rule again to ensure we return the correct schedule as it may have
// cahnged during the task execution
// changed during the task execution
(await rulesClient.get({ id: ruleId })).schedule
),
};
@ -714,7 +724,6 @@ export class TaskRunner<
(ruleExecutionState) => executionStatusFromState(ruleExecutionState),
(err: ElasticsearchError) => executionStatusFromError(err)
);
// set the executionStatus date to same as event, if it's set
if (event.event?.start) {
executionStatus.lastExecutionDate = new Date(event.event.start);
@ -757,6 +766,10 @@ export class TaskRunner<
}
monitoringHistory.success = false;
} else {
if (executionStatus.warning) {
set(event, 'event.reason', executionStatus.warning?.reason || 'unknown');
set(event, 'message', event?.message || executionStatus.warning.message);
}
set(
event,
'kibana.alert.rule.execution.metrics.number_of_triggered_actions',
@ -807,7 +820,7 @@ export class TaskRunner<
executionState: RuleExecutionState
): RuleTaskState => {
return {
...omit(executionState, ['triggeredActions', 'metrics']),
...omit(executionState, ['alertExecutionStore', 'metrics']),
previousStartedAt: startedAt,
};
};
@ -1110,7 +1123,7 @@ async function scheduleActionsForRecoveredAlerts<
InstanceContext,
RecoveryActionGroupId
>
): Promise<AlertAction[]> {
): Promise<void> {
const {
logger,
recoveryActionGroup,
@ -1118,9 +1131,10 @@ async function scheduleActionsForRecoveredAlerts<
executionHandler,
mutedAlertIdsSet,
ruleLabel,
alertExecutionStore,
} = params;
const recoveredIds = Object.keys(recoveredAlerts);
let triggeredActions: AlertAction[] = [];
for (const id of recoveredIds) {
if (mutedAlertIdsSet.has(id)) {
logger.debug(
@ -1130,17 +1144,16 @@ async function scheduleActionsForRecoveredAlerts<
const alert = recoveredAlerts[id];
alert.updateLastScheduledActions(recoveryActionGroup.id);
alert.unscheduleActions();
const triggeredActionsForRecoveredAlert = await executionHandler({
await executionHandler({
actionGroup: recoveryActionGroup.id,
context: alert.getContext(),
state: {},
alertId: id,
alertExecutionStore,
});
alert.scheduleActions(recoveryActionGroup.id);
triggeredActions = concat(triggeredActions, triggeredActionsForRecoveredAlert);
}
}
return triggeredActions;
}
function logActiveAndRecoveredAlerts<

View file

@ -55,6 +55,11 @@ const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',
config: {
execution: {
actions: { max: 1000 },
},
},
};
let fakeTimer: sinon.SinonFakeTimers;
@ -362,6 +367,7 @@ describe('Task Runner Cancel', () => {
lastDuration: 0,
lastExecutionDate: '1970-01-01T00:00:00.000Z',
status: 'error',
warning: null,
},
},
{ refresh: false, namespace: undefined }

View file

@ -6,9 +6,10 @@
*/
import { Dictionary } from 'lodash';
import { Logger } from 'kibana/server';
import { KibanaRequest, Logger } from 'kibana/server';
import {
ActionGroup,
AlertAction,
AlertInstanceContext,
AlertInstanceState,
AlertTypeParams,
@ -24,6 +25,8 @@ import { Alert as CreatedAlert } from '../alert';
import { IEventLogger } from '../../../event_log/server';
import { NormalizedRuleType } from '../rule_type_registry';
import { ExecutionHandler } from './create_execution_handler';
import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server';
import { RawRule } from '../types';
export interface RuleTaskRunResultWithActions {
state: RuleExecutionState;
@ -89,6 +92,7 @@ export interface ScheduleActionsForRecoveredAlertsParams<
executionHandler: ExecutionHandler<RecoveryActionGroupId | RecoveryActionGroupId>;
mutedAlertIdsSet: Set<string>;
ruleLabel: string;
alertExecutionStore: AlertExecutionStore;
}
export interface LogActiveAndRecoveredAlertsParams<
@ -103,3 +107,59 @@ export interface LogActiveAndRecoveredAlertsParams<
ruleLabel: string;
canSetRecoveryContext: boolean;
}
// / ExecutionHandler
export interface CreateExecutionHandlerOptions<
Params extends AlertTypeParams,
ExtractedParams extends AlertTypeParams,
State extends AlertTypeState,
InstanceState extends AlertInstanceState,
InstanceContext extends AlertInstanceContext,
ActionGroupIds extends string,
RecoveryActionGroupId extends string
> {
ruleId: string;
ruleName: string;
executionId: string;
tags?: string[];
actionsPlugin: ActionsPluginStartContract;
actions: AlertAction[];
spaceId: string;
apiKey: RawRule['apiKey'];
kibanaBaseUrl: string | undefined;
ruleType: NormalizedRuleType<
Params,
ExtractedParams,
State,
InstanceState,
InstanceContext,
ActionGroupIds,
RecoveryActionGroupId
>;
logger: Logger;
eventLogger: IEventLogger;
request: KibanaRequest;
ruleParams: AlertTypeParams;
supportsEphemeralTasks: boolean;
maxEphemeralActionsPerRule: number;
}
export interface ExecutionHandlerOptions<ActionGroupIds extends string> {
actionGroup: ActionGroupIds;
actionSubgroup?: string;
alertId: string;
context: AlertInstanceContext;
state: AlertInstanceState;
alertExecutionStore: AlertExecutionStore;
}
export enum ActionsCompletion {
COMPLETE = 'complete',
PARTIAL = 'partial',
}
export interface AlertExecutionStore {
numberOfTriggeredActions: number;
triggeredActionsStatus: ActionsCompletion;
}

View file

@ -39,9 +39,10 @@ import {
SanitizedRuleConfig,
RuleMonitoring,
MappedParams,
AlertExecutionStatusWarningReasons,
} from '../common';
import { LicenseType } from '../../licensing/server';
import { RulesConfig } from './config';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
@ -124,6 +125,7 @@ export type ExecutorType<
export interface AlertTypeParamsValidator<Params extends AlertTypeParams> {
validate: (object: unknown) => Params;
}
export interface RuleType<
Params extends AlertTypeParams = never,
ExtractedParams extends AlertTypeParams = never,
@ -168,6 +170,7 @@ export interface RuleType<
ruleTaskTimeout?: string;
cancelAlertsOnRuleTimeout?: boolean;
doesSetRecoveryContext?: boolean;
config?: RulesConfig;
}
export type UntypedRuleType = RuleType<
AlertTypeParams,
@ -199,6 +202,10 @@ export interface RawRuleExecutionStatus extends SavedObjectAttributes {
reason: AlertExecutionStatusErrorReasons;
message: string;
};
warning: null | {
reason: AlertExecutionStatusWarningReasons;
message: string;
};
}
export type PartialAlert<Params extends AlertTypeParams = never> = Pick<Alert<Params>, 'id'> &

View file

@ -12,6 +12,7 @@ import {
RULE_STATUS_ERROR,
RULE_STATUS_PENDING,
RULE_STATUS_UNKNOWN,
RULE_STATUS_WARNING,
} from './translations';
import { AlertExecutionStatuses } from '../../../../alerting/common';
import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public';
@ -50,6 +51,7 @@ export const rulesStatusesTranslationsMapping = {
error: RULE_STATUS_ERROR,
pending: RULE_STATUS_PENDING,
unknown: RULE_STATUS_UNKNOWN,
warning: RULE_STATUS_WARNING,
};
export const OBSERVABILITY_RULE_TYPES = [

View file

@ -46,6 +46,13 @@ export const RULE_STATUS_UNKNOWN = i18n.translate(
}
);
export const RULE_STATUS_WARNING = i18n.translate(
'xpack.observability.rules.rulesTable.ruleStatusWarning',
{
defaultMessage: 'warning',
}
);
export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate(
'xpack.observability.rules.rulesTable.columns.lastResponseTitle',
{

View file

@ -23,6 +23,7 @@ import {
import {
ActionGroup,
AlertExecutionStatusErrorReasons,
AlertExecutionStatusWarningReasons,
ALERTS_FEATURE_ID,
} from '../../../../../../alerting/common';
import { useKibana } from '../../../../common/lib/kibana';
@ -119,6 +120,28 @@ describe('rule_details', () => {
).toBeTruthy();
});
it('renders the rule warning banner with warning message, when rule status is a warning', () => {
const rule = mockRule({
executionStatus: {
status: 'warning',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
warning: {
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: 'warning message',
},
},
});
expect(
shallow(
<RuleDetails rule={rule} ruleType={ruleType} actionTypes={[]} {...mockRuleApis} />
).containsMatchingElement(
<EuiText size="s" color="warning" data-test-subj="ruleWarningMessageText">
{'warning message'}
</EuiText>
)
).toBeTruthy();
});
describe('actions', () => {
it('renders an rule action', () => {
const rule = mockRule({

View file

@ -40,7 +40,10 @@ import { RuleRouteWithApi } from './rule_route';
import { ViewInApp } from './view_in_app';
import { RuleEdit } from '../../rule_form';
import { routeToRuleDetails } from '../../../constants';
import { rulesErrorReasonTranslationsMapping } from '../../rules_list/translations';
import {
rulesErrorReasonTranslationsMapping,
rulesWarningReasonTranslationsMapping,
} from '../../rules_list/translations';
import { useKibana } from '../../../../common/lib/kibana';
import { ruleReducer } from '../../rule_form/rule_reducer';
import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api';
@ -135,7 +138,8 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
const [isMutedUpdating, setIsMutedUpdating] = useState<boolean>(false);
const [isMuted, setIsMuted] = useState<boolean>(rule.muteAll);
const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false);
const [dissmissRuleErrors, setDissmissRuleErrors] = useState<boolean>(false);
const [dismissRuleErrors, setDismissRuleErrors] = useState<boolean>(false);
const [dismissRuleWarning, setDismissRuleWarning] = useState<boolean>(false);
const setRule = async () => {
history.push(routeToRuleDetails.replace(`:ruleId`, rule.id));
@ -149,6 +153,14 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
}
};
const getRuleStatusWarningReasonText = () => {
if (rule.executionStatus.warning && rule.executionStatus.warning.reason) {
return rulesWarningReasonTranslationsMapping[rule.executionStatus.warning.reason];
} else {
return rulesWarningReasonTranslationsMapping.unknown;
}
};
const rightPageHeaderButtons = hasEditButton
? [
<>
@ -294,7 +306,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
setIsEnabled(false);
await disableRule(rule);
// Reset dismiss if previously clicked
setDissmissRuleErrors(false);
setDismissRuleErrors(false);
} else {
setIsEnabled(true);
await enableRule(rule);
@ -357,7 +369,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{rule.enabled && !dissmissRuleErrors && rule.executionStatus.status === 'error' ? (
{rule.enabled && !dismissRuleErrors && rule.executionStatus.status === 'error' ? (
<EuiFlexGroup>
<EuiFlexItem>
<EuiCallOut
@ -376,7 +388,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
<EuiButton
data-test-subj="dismiss-execution-error"
color="danger"
onClick={() => setDissmissRuleErrors(true)}
onClick={() => setDismissRuleErrors(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.dismissButtonTitle"
@ -404,6 +416,39 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
) : null}
{rule.enabled && !dismissRuleWarning && rule.executionStatus.status === 'warning' ? (
<EuiFlexGroup>
<EuiFlexItem>
<EuiCallOut
color="warning"
data-test-subj="ruleWarningBanner"
size="s"
title={getRuleStatusWarningReasonText()}
iconType="alert"
>
<EuiText size="s" color="warning" data-test-subj="ruleWarningMessageText">
{rule.executionStatus.warning?.message}
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s" wrap={true}>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="dismiss-execution-warning"
color="warning"
onClick={() => setDismissRuleWarning(true)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.dismissButtonTitle"
defaultMessage="Dismiss"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
{hasActionsWithBrokenConnector && (
<EuiFlexGroup>
<EuiFlexItem>

View file

@ -102,6 +102,8 @@ export function getHealthColor(status: AlertExecutionStatuses) {
return 'primary';
case 'pending':
return 'accent';
case 'warning':
return 'warning';
default:
return 'subdued';
}

View file

@ -15,6 +15,7 @@ import { RulesList, percentileFields } from './rules_list';
import { RuleTypeModel, ValidationResult, Percentiles } from '../../../../types';
import {
AlertExecutionStatusErrorReasons,
AlertExecutionStatusWarningReasons,
ALERTS_FEATURE_ID,
parseDuration,
} from '../../../../../../alerting/common';
@ -30,6 +31,7 @@ jest.mock('../../../lib/action_connector_api', () => ({
jest.mock('../../../lib/rule_api', () => ({
loadRules: jest.fn(),
loadRuleTypes: jest.fn(),
loadRuleAggregations: jest.fn(),
alertingFrameworkHealth: jest.fn(() => ({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
@ -55,7 +57,8 @@ jest.mock('../../../lib/capabilities', () => ({
hasShowActionsCapability: jest.fn(() => true),
hasExecuteActionsCapability: jest.fn(() => true),
}));
const { loadRules, loadRuleTypes } = jest.requireMock('../../../lib/rule_api');
const { loadRules, loadRuleTypes, loadRuleAggregations } =
jest.requireMock('../../../lib/rule_api');
const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api');
const actionTypeRegistry = actionTypeRegistryMock.create();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
@ -331,6 +334,32 @@ describe('rules_list component with items', () => {
},
},
},
{
id: '6',
name: 'test rule warning',
tags: [],
enabled: true,
ruleTypeId: 'test_rule_type',
schedule: { interval: '5d' },
actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }],
params: { name: 'test rule type name' },
scheduledTaskId: null,
createdBy: null,
updatedBy: null,
apiKeyOwner: null,
throttle: '1m',
muteAll: false,
mutedInstanceIds: [],
executionStatus: {
status: 'warning',
lastDuration: 500,
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
warning: {
reason: AlertExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS,
message: 'test',
},
},
},
];
async function setup(editable: boolean = true) {
@ -352,6 +381,11 @@ describe('rules_list component with items', () => {
]);
loadRuleTypes.mockResolvedValue([ruleTypeFromApi]);
loadAllActions.mockResolvedValue([]);
loadRuleAggregations.mockResolvedValue({
ruleEnabledStatus: { enabled: 2, disabled: 0 },
ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 },
ruleMutedStatus: { muted: 0, unmuted: 2 },
});
const ruleTypeMock: RuleTypeModel = {
id: 'test_rule_type',
@ -381,6 +415,7 @@ describe('rules_list component with items', () => {
expect(loadRules).toHaveBeenCalled();
expect(loadActionTypes).toHaveBeenCalled();
expect(loadRuleAggregations).toHaveBeenCalled();
}
it('renders table of rules', async () => {
@ -471,6 +506,7 @@ describe('rules_list component with items', () => {
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-pending"]').length).toEqual(1);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-unknown"]').length).toEqual(0);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').length).toEqual(2);
expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-warning"]').length).toEqual(1);
expect(wrapper.find('[data-test-subj="ruleStatus-error-tooltip"]').length).toEqual(2);
expect(
wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length
@ -728,6 +764,28 @@ describe('rules_list component with items', () => {
expect(wrapper.find('[data-test-subj="ruleSidebarEditAction"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="ruleSidebarDeleteAction"]').exists()).toBeTruthy();
});
it('renders brief', async () => {
await setup();
// { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }
expect(wrapper.find('EuiHealth[data-test-subj="totalOkRulesCount"]').text()).toEqual('Ok: 1');
expect(wrapper.find('EuiHealth[data-test-subj="totalActiveRulesCount"]').text()).toEqual(
'Active: 2'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalErrorRulesCount"]').text()).toEqual(
'Error: 3'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalPendingRulesCount"]').text()).toEqual(
'Pending: 4'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalUnknownRulesCount"]').text()).toEqual(
'Unknown: 5'
);
expect(wrapper.find('EuiHealth[data-test-subj="totalWarningRulesCount"]').text()).toEqual(
'Warning: 6'
);
});
});
describe('rules_list component empty with show only capability', () => {
@ -893,7 +951,7 @@ describe('rules_list with show only capability', () => {
});
});
describe('rules_list with disabled itmes', () => {
describe('rules_list with disabled items', () => {
let wrapper: ReactWrapper<any>;
async function setup() {

View file

@ -986,6 +986,17 @@ export const RulesList: React.FunctionComponent = () => {
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="warning" data-test-subj="totalWarningRulesCount">
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.totalStatusesWarningDescription"
defaultMessage="Warning: {totalStatusesWarning}"
values={{
totalStatusesWarning: rulesStatusesTotal.warning,
}}
/>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHealth color="primary" data-test-subj="totalOkRulesCount">
<FormattedMessage

View file

@ -49,12 +49,20 @@ export const ALERT_STATUS_UNKNOWN = i18n.translate(
}
);
export const ALERT_STATUS_WARNING = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleStatusWarning',
{
defaultMessage: 'Warning',
}
);
export const rulesStatusesTranslationsMapping = {
ok: ALERT_STATUS_OK,
active: ALERT_STATUS_ACTIVE,
error: ALERT_STATUS_ERROR,
pending: ALERT_STATUS_PENDING,
unknown: ALERT_STATUS_UNKNOWN,
warning: ALERT_STATUS_WARNING,
};
export const ALERT_ERROR_UNKNOWN_REASON = i18n.translate(
@ -106,6 +114,20 @@ export const ALERT_ERROR_DISABLED_REASON = i18n.translate(
}
);
export const ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonMaxExecutableActions',
{
defaultMessage: 'Action limit exceeded',
}
);
export const ALERT_WARNING_UNKNOWN_REASON = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.ruleWarningReasonUnknown',
{
defaultMessage: 'Unknown reason',
}
);
export const rulesErrorReasonTranslationsMapping = {
read: ALERT_ERROR_READING_REASON,
decrypt: ALERT_ERROR_DECRYPTING_REASON,
@ -115,3 +137,8 @@ export const rulesErrorReasonTranslationsMapping = {
timeout: ALERT_ERROR_TIMEOUT_REASON,
disabled: ALERT_ERROR_DISABLED_REASON,
};
export const rulesWarningReasonTranslationsMapping = {
maxExecutableActions: ALERT_WARNING_MAX_EXECUTABLE_ACTIONS_REASON,
unknown: ALERT_WARNING_UNKNOWN_REASON,
};

View file

@ -36,6 +36,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext)
error: 0,
pending: 0,
unknown: 0,
warning: 0,
},
rule_muted_status: {
muted: 0,
@ -111,6 +112,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext)
error: NumErrorAlerts,
pending: 0,
unknown: 0,
warning: 0,
},
rule_muted_status: {
muted: 0,
@ -183,6 +185,7 @@ export default function createAggregateTests({ getService }: FtrProviderContext)
error: NumErrorAlerts,
pending: 0,
unknown: 0,
warning: 0,
},
ruleEnabledStatus: {
disabled: 0,