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

Added ability to fire actions when an alert instance is resolved #82799

Merged
merged 11 commits into from
Nov 14, 2020
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;
}
YulNaumenko marked this conversation as resolved.
Show resolved Hide resolved
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: {},
Comment on lines +512 to +513
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we're going to find the empty context and state to be a problem 🤔
Should we perhaps remove the values expected in context and stat from the template variables in the UI?
Otherwise users might try to use them in actions when attaching to Resolved, no?

Copy link
Contributor Author

@YulNaumenko YulNaumenko Nov 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it in the UI PR

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