Skip to content

Commit

Permalink
Added ability to fire actions when an alert instance is resolved (#82799
Browse files Browse the repository at this point in the history
) (#83381)

* Added ability to fire actions when an alert instance is resolved

* Fixed due to comments

* Fixed merge issue

* Fixed tests and added skip for muted resolve

* added test for muted alert

* Fixed due to comments

* Fixed registry error message

* Fixed jest test
  • Loading branch information
YulNaumenko authored Nov 14, 2020
1 parent 20aa1c4 commit e80aef3
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 11 deletions.
18 changes: 18 additions & 0 deletions x-pack/plugins/alerts/common/builtin_action_groups.ts
Original file line number Diff line number Diff line change
@@ -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];
}
6 changes: 1 addition & 5 deletions x-pack/plugins/alerts/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions x-pack/plugins/alerts/server/alert_type_registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -201,6 +228,10 @@ describe('get()', () => {
"id": "default",
"name": "Default",
},
Object {
"id": "resolved",
"name": "Resolved",
},
],
"actionVariables": Object {
"context": Array [],
Expand Down Expand Up @@ -255,6 +286,10 @@ describe('list()', () => {
"id": "testActionGroup",
"name": "Test Action Group",
},
Object {
"id": "resolved",
"name": "Resolved",
},
],
"actionVariables": Object {
"context": Array [],
Expand Down
25 changes: 25 additions & 0 deletions x-pack/plugins/alerts/server/alert_type_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,7 +18,9 @@ import {
AlertTypeState,
AlertInstanceState,
AlertInstanceContext,
ActionGroup,
} from './types';
import { getBuiltinActionGroups } from '../common';

interface ConstructorOptions {
taskManager: TaskManagerSetupContract;
Expand Down Expand Up @@ -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}`]: {
Expand Down Expand Up @@ -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,
},
})
);
}
}
87 changes: 84 additions & 3 deletions x-pack/plugins/alerts/server/task_runner/task_runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -91,7 +91,7 @@ describe('Task Runner', () => {
throttle: null,
muteAll: false,
enabled: true,
alertTypeId: '123',
alertTypeId: alertType.id,
apiKey: '',
apiKeyOwner: 'elastic',
schedule: { interval: '10s' },
Expand All @@ -112,6 +112,14 @@ describe('Task Runner', () => {
foo: true,
},
},
{
group: ResolvedActionGroup.id,
id: '2',
actionTypeId: 'action',
params: {
isResolved: true,
},
},
],
executionStatus: {
status: 'unknown',
Expand Down Expand Up @@ -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) => {
Expand Down
38 changes: 38 additions & 0 deletions x-pack/plugins/alerts/server/task_runner/task_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -210,6 +211,7 @@ export class TaskRunner {
const instancesWithScheduledActions = pickBy(alertInstances, (alertInstance: AlertInstance) =>
alertInstance.hasScheduledActions()
);

generateNewAndResolvedInstanceEvents({
eventLogger,
originalAlertInstances,
Expand All @@ -220,6 +222,14 @@ export class TaskRunner {
});

if (!muteAll) {
scheduleActionsForResolvedInstances(
alertInstances,
executionHandler,
originalAlertInstances,
instancesWithScheduledActions,
alert.mutedInstanceIds
);

const mutedInstanceIdsSet = new Set(mutedInstanceIds);

await Promise.all(
Expand Down Expand Up @@ -479,6 +489,34 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst
}
}

function scheduleActionsForResolvedInstances(
alertInstancesMap: Record<string, AlertInstance>,
executionHandler: ReturnType<typeof createExecutionHandler>,
originalAlertInstances: Record<string, AlertInstance>,
currentAlertInstances: Dictionary<AlertInstance>,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof paramsSchema>;
interface State {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading

0 comments on commit e80aef3

Please sign in to comment.