Skip to content

Commit 4ad3cef

Browse files
authored
Added ability to fire actions when an alert instance is resolved (#82799)
* 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
1 parent dde2d11 commit 4ad3cef

File tree

10 files changed

+355
-11
lines changed

10 files changed

+355
-11
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import { i18n } from '@kbn/i18n';
7+
import { ActionGroup } from './alert_type';
8+
9+
export const ResolvedActionGroup: ActionGroup = {
10+
id: 'resolved',
11+
name: i18n.translate('xpack.alerts.builtinActionGroups.resolved', {
12+
defaultMessage: 'Resolved',
13+
}),
14+
};
15+
16+
export function getBuiltinActionGroups(): ActionGroup[] {
17+
return [ResolvedActionGroup];
18+
}

x-pack/plugins/alerts/common/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ export * from './alert_instance';
1212
export * from './alert_task_instance';
1313
export * from './alert_navigation';
1414
export * from './alert_instance_summary';
15-
16-
export interface ActionGroup {
17-
id: string;
18-
name: string;
19-
}
15+
export * from './builtin_action_groups';
2016

2117
export interface AlertingFrameworkHealth {
2218
isSufficientlySecure: boolean;

x-pack/plugins/alerts/server/alert_type_registry.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,33 @@ describe('register()', () => {
9595
);
9696
});
9797

98+
test('throws if AlertType action groups contains reserved group id', () => {
99+
const alertType = {
100+
id: 'test',
101+
name: 'Test',
102+
actionGroups: [
103+
{
104+
id: 'default',
105+
name: 'Default',
106+
},
107+
{
108+
id: 'resolved',
109+
name: 'Resolved',
110+
},
111+
],
112+
defaultActionGroupId: 'default',
113+
executor: jest.fn(),
114+
producer: 'alerts',
115+
};
116+
const registry = new AlertTypeRegistry(alertTypeRegistryParams);
117+
118+
expect(() => registry.register(alertType)).toThrowError(
119+
new Error(
120+
`Alert type [id="${alertType.id}"] cannot be registered. Action groups [resolved] are reserved by the framework.`
121+
)
122+
);
123+
});
124+
98125
test('registers the executor with the task manager', () => {
99126
const alertType = {
100127
id: 'test',
@@ -201,6 +228,10 @@ describe('get()', () => {
201228
"id": "default",
202229
"name": "Default",
203230
},
231+
Object {
232+
"id": "resolved",
233+
"name": "Resolved",
234+
},
204235
],
205236
"actionVariables": Object {
206237
"context": Array [],
@@ -255,6 +286,10 @@ describe('list()', () => {
255286
"id": "testActionGroup",
256287
"name": "Test Action Group",
257288
},
289+
Object {
290+
"id": "resolved",
291+
"name": "Resolved",
292+
},
258293
],
259294
"actionVariables": Object {
260295
"context": Array [],

x-pack/plugins/alerts/server/alert_type_registry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import Boom from '@hapi/boom';
88
import { i18n } from '@kbn/i18n';
99
import { schema } from '@kbn/config-schema';
1010
import typeDetect from 'type-detect';
11+
import { intersection } from 'lodash';
12+
import _ from 'lodash';
1113
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
1214
import { TaskRunnerFactory } from './task_runner';
1315
import {
@@ -16,7 +18,9 @@ import {
1618
AlertTypeState,
1719
AlertInstanceState,
1820
AlertInstanceContext,
21+
ActionGroup,
1922
} from './types';
23+
import { getBuiltinActionGroups } from '../common';
2024

2125
interface ConstructorOptions {
2226
taskManager: TaskManagerSetupContract;
@@ -82,6 +86,8 @@ export class AlertTypeRegistry {
8286
);
8387
}
8488
alertType.actionVariables = normalizedActionVariables(alertType.actionVariables);
89+
validateActionGroups(alertType.id, alertType.actionGroups);
90+
alertType.actionGroups = [...alertType.actionGroups, ..._.cloneDeep(getBuiltinActionGroups())];
8591
this.alertTypes.set(alertIdSchema.validate(alertType.id), { ...alertType } as AlertType);
8692
this.taskManager.registerTaskDefinitions({
8793
[`alerting:${alertType.id}`]: {
@@ -137,3 +143,22 @@ function normalizedActionVariables(actionVariables: AlertType['actionVariables']
137143
params: actionVariables?.params ?? [],
138144
};
139145
}
146+
147+
function validateActionGroups(alertTypeId: string, actionGroups: ActionGroup[]) {
148+
const reservedActionGroups = intersection(
149+
actionGroups.map((item) => item.id),
150+
getBuiltinActionGroups().map((item) => item.id)
151+
);
152+
if (reservedActionGroups.length > 0) {
153+
throw new Error(
154+
i18n.translate('xpack.alerts.alertTypeRegistry.register.reservedActionGroupUsageError', {
155+
defaultMessage:
156+
'Alert type [id="{alertTypeId}"] cannot be registered. Action groups [{actionGroups}] are reserved by the framework.',
157+
values: {
158+
actionGroups: reservedActionGroups.join(', '),
159+
alertTypeId,
160+
},
161+
})
162+
);
163+
}
164+
}

x-pack/plugins/alerts/server/task_runner/task_runner.test.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ import { alertsMock, alertsClientMock } from '../mocks';
2525
import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
2626
import { IEventLogger } from '../../../event_log/server';
2727
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
28-
import { Alert } from '../../common';
28+
import { Alert, ResolvedActionGroup } from '../../common';
2929
import { omit } from 'lodash';
3030
const alertType = {
3131
id: 'test',
3232
name: 'My test alert',
33-
actionGroups: [{ id: 'default', name: 'Default' }],
33+
actionGroups: [{ id: 'default', name: 'Default' }, ResolvedActionGroup],
3434
defaultActionGroupId: 'default',
3535
executor: jest.fn(),
3636
producer: 'alerts',
@@ -91,7 +91,7 @@ describe('Task Runner', () => {
9191
throttle: null,
9292
muteAll: false,
9393
enabled: true,
94-
alertTypeId: '123',
94+
alertTypeId: alertType.id,
9595
apiKey: '',
9696
apiKeyOwner: 'elastic',
9797
schedule: { interval: '10s' },
@@ -112,6 +112,14 @@ describe('Task Runner', () => {
112112
foo: true,
113113
},
114114
},
115+
{
116+
group: ResolvedActionGroup.id,
117+
id: '2',
118+
actionTypeId: 'action',
119+
params: {
120+
isResolved: true,
121+
},
122+
},
115123
],
116124
executionStatus: {
117125
status: 'unknown',
@@ -507,6 +515,79 @@ describe('Task Runner', () => {
507515
`);
508516
});
509517

518+
test('fire resolved actions for execution for the alertInstances which is in the resolved state', async () => {
519+
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
520+
taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true);
521+
522+
alertType.executor.mockImplementation(
523+
({ services: executorServices }: AlertExecutorOptions) => {
524+
executorServices.alertInstanceFactory('1').scheduleActions('default');
525+
}
526+
);
527+
const taskRunner = new TaskRunner(
528+
alertType,
529+
{
530+
...mockedTaskInstance,
531+
state: {
532+
...mockedTaskInstance.state,
533+
alertInstances: {
534+
'1': { meta: {}, state: { bar: false } },
535+
'2': { meta: {}, state: { bar: false } },
536+
},
537+
},
538+
},
539+
taskRunnerFactoryInitializerParams
540+
);
541+
alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject);
542+
encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({
543+
id: '1',
544+
type: 'alert',
545+
attributes: {
546+
apiKey: Buffer.from('123:abc').toString('base64'),
547+
},
548+
references: [],
549+
});
550+
const runnerResult = await taskRunner.run();
551+
expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(`
552+
Object {
553+
"1": Object {
554+
"meta": Object {
555+
"lastScheduledActions": Object {
556+
"date": 1970-01-01T00:00:00.000Z,
557+
"group": "default",
558+
},
559+
},
560+
"state": Object {
561+
"bar": false,
562+
},
563+
},
564+
}
565+
`);
566+
567+
const eventLogger = taskRunnerFactoryInitializerParams.eventLogger;
568+
expect(eventLogger.logEvent).toHaveBeenCalledTimes(5);
569+
expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2);
570+
expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(`
571+
Array [
572+
Object {
573+
"apiKey": "MTIzOmFiYw==",
574+
"id": "2",
575+
"params": Object {
576+
"isResolved": true,
577+
},
578+
"source": Object {
579+
"source": Object {
580+
"id": "1",
581+
"type": "alert",
582+
},
583+
"type": "SAVED_OBJECT",
584+
},
585+
"spaceId": undefined,
586+
},
587+
]
588+
`);
589+
});
590+
510591
test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => {
511592
alertType.executor.mockImplementation(
512593
({ services: executorServices }: AlertExecutorOptions) => {

x-pack/plugins/alerts/server/task_runner/task_runner.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_l
3737
import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error';
3838
import { AlertsClient } from '../alerts_client';
3939
import { partiallyUpdateAlert } from '../saved_objects';
40+
import { ResolvedActionGroup } from '../../common';
4041

4142
const FALLBACK_RETRY_INTERVAL = '5m';
4243

@@ -210,6 +211,7 @@ export class TaskRunner {
210211
const instancesWithScheduledActions = pickBy(alertInstances, (alertInstance: AlertInstance) =>
211212
alertInstance.hasScheduledActions()
212213
);
214+
213215
generateNewAndResolvedInstanceEvents({
214216
eventLogger,
215217
originalAlertInstances,
@@ -220,6 +222,14 @@ export class TaskRunner {
220222
});
221223

222224
if (!muteAll) {
225+
scheduleActionsForResolvedInstances(
226+
alertInstances,
227+
executionHandler,
228+
originalAlertInstances,
229+
instancesWithScheduledActions,
230+
alert.mutedInstanceIds
231+
);
232+
223233
const mutedInstanceIdsSet = new Set(mutedInstanceIds);
224234

225235
await Promise.all(
@@ -479,6 +489,34 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst
479489
}
480490
}
481491

492+
function scheduleActionsForResolvedInstances(
493+
alertInstancesMap: Record<string, AlertInstance>,
494+
executionHandler: ReturnType<typeof createExecutionHandler>,
495+
originalAlertInstances: Record<string, AlertInstance>,
496+
currentAlertInstances: Dictionary<AlertInstance>,
497+
mutedInstanceIds: string[]
498+
) {
499+
const currentAlertInstanceIds = Object.keys(currentAlertInstances);
500+
const originalAlertInstanceIds = Object.keys(originalAlertInstances);
501+
const resolvedIds = without(
502+
originalAlertInstanceIds,
503+
...currentAlertInstanceIds,
504+
...mutedInstanceIds
505+
);
506+
for (const id of resolvedIds) {
507+
const instance = alertInstancesMap[id];
508+
instance.updateLastScheduledActions(ResolvedActionGroup.id);
509+
instance.unscheduleActions();
510+
executionHandler({
511+
actionGroup: ResolvedActionGroup.id,
512+
context: {},
513+
state: {},
514+
alertInstanceId: id,
515+
});
516+
instance.scheduleActions(ResolvedActionGroup.id);
517+
}
518+
}
519+
482520
/**
483521
* If an error is thrown, wrap it in an AlertTaskRunResult
484522
* so that we can treat each field independantly

x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { CoreSetup } from 'src/core/server';
88
import { schema, TypeOf } from '@kbn/config-schema';
99
import { times } from 'lodash';
10+
import { ES_TEST_INDEX_NAME } from '../../../../lib';
1011
import { FixtureStartDeps, FixtureSetupDeps } from './plugin';
1112
import {
1213
AlertType,
@@ -330,6 +331,7 @@ function getValidationAlertType() {
330331
function getPatternFiringAlertType() {
331332
const paramsSchema = schema.object({
332333
pattern: schema.recordOf(schema.string(), schema.arrayOf(schema.boolean())),
334+
reference: schema.maybe(schema.string()),
333335
});
334336
type ParamsType = TypeOf<typeof paramsSchema>;
335337
interface State {
@@ -353,6 +355,18 @@ function getPatternFiringAlertType() {
353355
maxPatternLength = Math.max(maxPatternLength, instancePattern.length);
354356
}
355357

358+
if (params.reference) {
359+
await services.scopedClusterClient.index({
360+
index: ES_TEST_INDEX_NAME,
361+
refresh: 'wait_for',
362+
body: {
363+
reference: params.reference,
364+
source: 'alert:test.patternFiring',
365+
...alertExecutorOptions,
366+
},
367+
});
368+
}
369+
356370
// get the pattern index, return if past it
357371
const patternIndex = state.patternIndex ?? 0;
358372
if (patternIndex >= maxPatternLength) {

x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export default function listAlertTypes({ getService }: FtrProviderContext) {
1515
const supertestWithoutAuth = getService('supertestWithoutAuth');
1616

1717
const expectedNoOpType = {
18-
actionGroups: [{ id: 'default', name: 'Default' }],
18+
actionGroups: [
19+
{ id: 'default', name: 'Default' },
20+
{ id: 'resolved', name: 'Resolved' },
21+
],
1922
defaultActionGroupId: 'default',
2023
id: 'test.noop',
2124
name: 'Test: Noop',
@@ -28,7 +31,10 @@ export default function listAlertTypes({ getService }: FtrProviderContext) {
2831
};
2932

3033
const expectedRestrictedNoOpType = {
31-
actionGroups: [{ id: 'default', name: 'Default' }],
34+
actionGroups: [
35+
{ id: 'default', name: 'Default' },
36+
{ id: 'resolved', name: 'Resolved' },
37+
],
3238
defaultActionGroupId: 'default',
3339
id: 'test.restricted-noop',
3440
name: 'Test: Restricted Noop',

0 commit comments

Comments
 (0)