Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7.x] Added ability to fire actions when an alert instance is resolved (#82799) #83381

Merged
merged 1 commit into from
Nov 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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