diff --git a/x-pack/plugins/alerts/common/builtin_action_groups.ts b/x-pack/plugins/alerts/common/builtin_action_groups.ts new file mode 100644 index 0000000000000..d31f75357d370 --- /dev/null +++ b/x-pack/plugins/alerts/common/builtin_action_groups.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { ActionGroup } from './alert_type'; + +export const ResolvedActionGroup: ActionGroup = { + id: 'resolved', + name: i18n.translate('xpack.alerts.builtinActionGroups.resolved', { + defaultMessage: 'Resolved', + }), +}; + +export function getBuiltinActionGroups(): ActionGroup[] { + return [ResolvedActionGroup]; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 65aeec840da7e..4d0e7bf7eb0bc 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -12,11 +12,7 @@ export * from './alert_instance'; export * from './alert_task_instance'; export * from './alert_navigation'; export * from './alert_instance_summary'; - -export interface ActionGroup { - id: string; - name: string; -} +export * from './builtin_action_groups'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/alert_type_registry.test.ts b/x-pack/plugins/alerts/server/alert_type_registry.test.ts index 9e1545bae5384..8dc387f96addb 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.test.ts @@ -95,6 +95,33 @@ describe('register()', () => { ); }); + test('throws if AlertType action groups contains reserved group id', () => { + const alertType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'resolved', + name: 'Resolved', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + }; + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + + expect(() => registry.register(alertType)).toThrowError( + new Error( + `Alert type [id="${alertType.id}"] cannot be registered. Action groups [resolved] are reserved by the framework.` + ) + ); + }); + test('registers the executor with the task manager', () => { const alertType = { id: 'test', @@ -201,6 +228,10 @@ describe('get()', () => { "id": "default", "name": "Default", }, + Object { + "id": "resolved", + "name": "Resolved", + }, ], "actionVariables": Object { "context": Array [], @@ -255,6 +286,10 @@ describe('list()', () => { "id": "testActionGroup", "name": "Test Action Group", }, + Object { + "id": "resolved", + "name": "Resolved", + }, ], "actionVariables": Object { "context": Array [], diff --git a/x-pack/plugins/alerts/server/alert_type_registry.ts b/x-pack/plugins/alerts/server/alert_type_registry.ts index 1d2d9981faeaa..8fe2ab06acd9a 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.ts @@ -8,6 +8,8 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import typeDetect from 'type-detect'; +import { intersection } from 'lodash'; +import _ from 'lodash'; import { RunContext, TaskManagerSetupContract } from '../../task_manager/server'; import { TaskRunnerFactory } from './task_runner'; import { @@ -16,7 +18,9 @@ import { AlertTypeState, AlertInstanceState, AlertInstanceContext, + ActionGroup, } from './types'; +import { getBuiltinActionGroups } from '../common'; interface ConstructorOptions { taskManager: TaskManagerSetupContract; @@ -82,6 +86,8 @@ export class AlertTypeRegistry { ); } alertType.actionVariables = normalizedActionVariables(alertType.actionVariables); + validateActionGroups(alertType.id, alertType.actionGroups); + alertType.actionGroups = [...alertType.actionGroups, ..._.cloneDeep(getBuiltinActionGroups())]; this.alertTypes.set(alertIdSchema.validate(alertType.id), { ...alertType } as AlertType); this.taskManager.registerTaskDefinitions({ [`alerting:${alertType.id}`]: { @@ -137,3 +143,22 @@ function normalizedActionVariables(actionVariables: AlertType['actionVariables'] params: actionVariables?.params ?? [], }; } + +function validateActionGroups(alertTypeId: string, actionGroups: ActionGroup[]) { + const reservedActionGroups = intersection( + actionGroups.map((item) => item.id), + getBuiltinActionGroups().map((item) => item.id) + ); + if (reservedActionGroups.length > 0) { + throw new Error( + i18n.translate('xpack.alerts.alertTypeRegistry.register.reservedActionGroupUsageError', { + defaultMessage: + 'Alert type [id="{alertTypeId}"] cannot be registered. Action groups [{actionGroups}] are reserved by the framework.', + values: { + actionGroups: reservedActionGroups.join(', '), + alertTypeId, + }, + }) + ); + } +} diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 859b6ec4362ce..bd583159af5d5 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -25,12 +25,12 @@ import { alertsMock, alertsClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { Alert } from '../../common'; +import { Alert, ResolvedActionGroup } from '../../common'; import { omit } from 'lodash'; const alertType = { id: 'test', name: 'My test alert', - actionGroups: [{ id: 'default', name: 'Default' }], + actionGroups: [{ id: 'default', name: 'Default' }, ResolvedActionGroup], defaultActionGroupId: 'default', executor: jest.fn(), producer: 'alerts', @@ -91,7 +91,7 @@ describe('Task Runner', () => { throttle: null, muteAll: false, enabled: true, - alertTypeId: '123', + alertTypeId: alertType.id, apiKey: '', apiKeyOwner: 'elastic', schedule: { interval: '10s' }, @@ -112,6 +112,14 @@ describe('Task Runner', () => { foo: true, }, }, + { + group: ResolvedActionGroup.id, + id: '2', + actionTypeId: 'action', + params: { + isResolved: true, + }, + }, ], executionStatus: { status: 'unknown', @@ -507,6 +515,79 @@ describe('Task Runner', () => { `); }); + test('fire resolved actions for execution for the alertInstances which is in the resolved state', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "lastScheduledActions": Object { + "date": 1970-01-01T00:00:00.000Z, + "group": "default", + }, + }, + "state": Object { + "bar": false, + }, + }, + } + `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); + expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "id": "2", + "params": Object { + "isResolved": true, + }, + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": undefined, + }, + ] + `); + }); + test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { alertType.executor.mockImplementation( ({ services: executorServices }: AlertExecutorOptions) => { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 5bccf5c497a60..0dad952a86590 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -37,6 +37,7 @@ import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_l import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error'; import { AlertsClient } from '../alerts_client'; import { partiallyUpdateAlert } from '../saved_objects'; +import { ResolvedActionGroup } from '../../common'; const FALLBACK_RETRY_INTERVAL = '5m'; @@ -210,6 +211,7 @@ export class TaskRunner { const instancesWithScheduledActions = pickBy(alertInstances, (alertInstance: AlertInstance) => alertInstance.hasScheduledActions() ); + generateNewAndResolvedInstanceEvents({ eventLogger, originalAlertInstances, @@ -220,6 +222,14 @@ export class TaskRunner { }); if (!muteAll) { + scheduleActionsForResolvedInstances( + alertInstances, + executionHandler, + originalAlertInstances, + instancesWithScheduledActions, + alert.mutedInstanceIds + ); + const mutedInstanceIdsSet = new Set(mutedInstanceIds); await Promise.all( @@ -479,6 +489,34 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst } } +function scheduleActionsForResolvedInstances( + alertInstancesMap: Record, + executionHandler: ReturnType, + originalAlertInstances: Record, + currentAlertInstances: Dictionary, + mutedInstanceIds: string[] +) { + const currentAlertInstanceIds = Object.keys(currentAlertInstances); + const originalAlertInstanceIds = Object.keys(originalAlertInstances); + const resolvedIds = without( + originalAlertInstanceIds, + ...currentAlertInstanceIds, + ...mutedInstanceIds + ); + for (const id of resolvedIds) { + const instance = alertInstancesMap[id]; + instance.updateLastScheduledActions(ResolvedActionGroup.id); + instance.unscheduleActions(); + executionHandler({ + actionGroup: ResolvedActionGroup.id, + context: {}, + state: {}, + alertInstanceId: id, + }); + instance.scheduleActions(ResolvedActionGroup.id); + } +} + /** * If an error is thrown, wrap it in an AlertTaskRunResult * so that we can treat each field independantly diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index d43c3363f86b1..7ed864afac4cc 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -7,6 +7,7 @@ import { CoreSetup } from 'src/core/server'; import { schema, TypeOf } from '@kbn/config-schema'; import { times } from 'lodash'; +import { ES_TEST_INDEX_NAME } from '../../../../lib'; import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; import { AlertType, @@ -330,6 +331,7 @@ function getValidationAlertType() { function getPatternFiringAlertType() { const paramsSchema = schema.object({ pattern: schema.recordOf(schema.string(), schema.arrayOf(schema.boolean())), + reference: schema.maybe(schema.string()), }); type ParamsType = TypeOf; interface State { @@ -353,6 +355,18 @@ function getPatternFiringAlertType() { maxPatternLength = Math.max(maxPatternLength, instancePattern.length); } + if (params.reference) { + await services.scopedClusterClient.index({ + index: ES_TEST_INDEX_NAME, + refresh: 'wait_for', + body: { + reference: params.reference, + source: 'alert:test.patternFiring', + ...alertExecutorOptions, + }, + }); + } + // get the pattern index, return if past it const patternIndex = state.patternIndex ?? 0; if (patternIndex >= maxPatternLength) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts index ad60ed6941caf..b3635b9f40e27 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts @@ -15,7 +15,10 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const expectedNoOpType = { - actionGroups: [{ id: 'default', name: 'Default' }], + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'resolved', name: 'Resolved' }, + ], defaultActionGroupId: 'default', id: 'test.noop', name: 'Test: Noop', @@ -28,7 +31,10 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { }; const expectedRestrictedNoOpType = { - actionGroups: [{ id: 'default', name: 'Default' }], + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'resolved', name: 'Resolved' }, + ], defaultActionGroupId: 'default', id: 'test.restricted-noop', name: 'Test: Restricted Noop', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts index 75628f6c72487..26f52475a2d4e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; +import { ResolvedActionGroup } from '../../../../../plugins/alerts/common'; import { Space } from '../../../common/types'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { @@ -135,6 +136,133 @@ instanceStateValue: true await taskManagerUtils.waitForActionTaskParamsToBeCleanedUp(testStart); }); + it('should fire actions when an alert instance is resolved', async () => { + const reference = alertUtils.generateReference(); + + const { body: createdAction } = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + // pattern of when the alert should fire. + const pattern = { + instance: [true, true], + }; + + const createdAlert = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + schedule: { interval: '1s' }, + throttle: null, + params: { + pattern, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + group: ResolvedActionGroup.id, + id: indexRecordActionId, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: 'Resolved message', + }, + }, + ], + }) + ); + + expect(createdAlert.status).to.eql(200); + const alertId = createdAlert.body.id; + objectRemover.add(space.id, alertId, 'alert', 'alerts'); + + const actionTestRecord = ( + await esTestIndexTool.waitForDocs('action:test.index-record', reference) + )[0]; + + expect(actionTestRecord._source.params.message).to.eql('Resolved message'); + }); + + it('should not fire actions when an alert instance is resolved, but alert is muted', async () => { + const testStart = new Date(); + const reference = alertUtils.generateReference(); + + const { body: createdAction } = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + // pattern of when the alert should fire. + const pattern = { + instance: [true, true], + }; + // created disabled alert + const createdAlert = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + schedule: { interval: '1s' }, + enabled: false, + throttle: null, + params: { + pattern, + reference, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + group: ResolvedActionGroup.id, + id: indexRecordActionId, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: 'Resolved message', + }, + }, + ], + }) + ); + expect(createdAlert.status).to.eql(200); + const alertId = createdAlert.body.id; + + await alertUtils.muteAll(alertId); + + await alertUtils.enable(alertId); + + await esTestIndexTool.search('alert:test.patternFiring', reference); + + await taskManagerUtils.waitForActionTaskParamsToBeCleanedUp(testStart); + + const actionTestRecord = await esTestIndexTool.search('action:test.index-record', reference); + expect(actionTestRecord.hits.total.value).to.eql(0); + objectRemover.add(space.id, alertId, 'alert', 'alerts'); + }); + it('should reschedule failing alerts using the Task Manager retry logic with alert schedule interval', async () => { /* Alerts should set the Task Manager schedule interval with initial value. diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts index 6fb573c7344b3..3fb2cc40437d8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts @@ -23,7 +23,10 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { (alertType: any) => alertType.id === 'test.noop' ); expect(fixtureAlertType).to.eql({ - actionGroups: [{ id: 'default', name: 'Default' }], + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'resolved', name: 'Resolved' }, + ], defaultActionGroupId: 'default', id: 'test.noop', name: 'Test: Noop',