pass more alert info into alert executor (#54035)

resolves https://github.com/elastic/kibana/issues/50522

The alert executor function is now passed these additional alert-specific
properties as parameters:

- spaceId
- namespace
- name
- tags
- createdBy
- updatedBy
This commit is contained in:
Patrick Mueller 2020-01-09 18:14:53 -05:00 committed by GitHub
parent 32e61592ec
commit 5853360d75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 281 additions and 54 deletions

View file

@ -63,6 +63,13 @@ This is the primary function for an alert type. Whenever the alert needs to exec
|previousStartedAt|The previous date and time the alert type started a successful execution.|
|params|Parameters for the execution. This is where the parameters you require will be passed in. (example threshold). Use alert type validation to ensure values are set before execution.|
|state|State returned from previous execution. This is the alert level state. What the executor returns will be serialized and provided here at the next execution.|
|alertId|The id of this alert.|
|spaceId|The id of the space of this alert.|
|namespace|The namespace of the space of this alert; same as spaceId, unless spaceId === 'default', then namespace = undefined.|
|name|The name of this alert.|
|tags|The tags associated with this alert.|
|createdBy|The userid that created this alert.|
|updatedBy|The userid that last updated this alert.|
### Example

View file

@ -75,6 +75,10 @@ describe('Task Runner', () => {
enabled: true,
alertTypeId: '123',
schedule: { interval: '10s' },
name: 'alert-name',
tags: ['alert-', '-tags'],
createdBy: 'alert-creator',
updatedBy: 'alert-updater',
mutedInstanceIds: [],
params: {
bar: true,
@ -138,6 +142,10 @@ describe('Task Runner', () => {
`);
expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`);
expect(call.state).toMatchInlineSnapshot(`Object {}`);
expect(call.name).toBe('alert-name');
expect(call.tags).toEqual(['alert-', '-tags']);
expect(call.createdBy).toBe('alert-creator');
expect(call.updatedBy).toBe('alert-updater');
expect(call.services.alertInstanceFactory).toBeTruthy();
expect(call.services.callCluster).toBeTruthy();
expect(call.services).toBeTruthy();

View file

@ -13,7 +13,7 @@ import { createExecutionHandler } from './create_execution_handler';
import { AlertInstance, createAlertInstanceFactory } from '../alert_instance';
import { getNextRunAt } from './get_next_run_at';
import { validateAlertTypeParams } from '../lib';
import { AlertType, RawAlert, IntervalSchedule, Services, State } from '../types';
import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types';
import { promiseResult, map } from '../lib/result_type';
type AlertInstances = Record<string, AlertInstance>;
@ -118,13 +118,25 @@ export class TaskRunner {
async executeAlertInstances(
services: Services,
{ params, throttle, muteAll, mutedInstanceIds }: SavedObject['attributes'],
executionHandler: ReturnType<typeof createExecutionHandler>
alertInfoParams: AlertInfoParams,
executionHandler: ReturnType<typeof createExecutionHandler>,
spaceId: string
): Promise<State> {
const {
params,
throttle,
muteAll,
mutedInstanceIds,
name,
tags,
createdBy,
updatedBy,
} = alertInfoParams;
const {
params: { alertId },
state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt },
} = this.taskInstance;
const namespace = this.context.spaceIdToNamespace(spaceId);
const alertInstances = mapValues<AlertInstances>(
alertRawInstances,
@ -141,6 +153,12 @@ export class TaskRunner {
state: alertTypeState,
startedAt: this.taskInstance.startedAt!,
previousStartedAt,
spaceId,
namespace,
name,
tags,
createdBy,
updatedBy,
});
// Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object
@ -175,7 +193,7 @@ export class TaskRunner {
async validateAndRunAlert(
services: Services,
apiKey: string | null,
attributes: SavedObject['attributes'],
attributes: RawAlert,
references: SavedObject['references']
) {
const {
@ -191,7 +209,12 @@ export class TaskRunner {
attributes.actions,
references
);
return this.executeAlertInstances(services, { ...attributes, params }, executionHandler);
return this.executeAlertInstances(
services,
{ ...attributes, params },
executionHandler,
spaceId
);
}
async run() {

View file

@ -32,6 +32,12 @@ export interface AlertExecutorOptions {
services: AlertServices;
params: Record<string, any>;
state: State;
spaceId: string;
namespace?: string;
name: string;
tags: string[];
createdBy: string | null;
updatedBy: string | null;
}
export interface AlertType {
@ -108,6 +114,18 @@ export interface RawAlert extends SavedObjectAttributes {
mutedInstanceIds: string[];
}
export type AlertInfoParams = Pick<
RawAlert,
| 'params'
| 'throttle'
| 'muteAll'
| 'mutedInstanceIds'
| 'name'
| 'tags'
| 'createdBy'
| 'updatedBy'
>;
export interface AlertingPlugin {
setup: PluginSetupContract;
start: PluginStartContract;

View file

@ -202,8 +202,21 @@ export default function(kibana: any) {
id: 'test.always-firing',
name: 'Test: Always Firing',
actionGroups: ['default', 'other'],
async executor({ services, params, state }: AlertExecutorOptions) {
async executor(alertExecutorOptions: AlertExecutorOptions) {
const {
services,
params,
state,
alertId,
spaceId,
namespace,
name,
tags,
createdBy,
updatedBy,
} = alertExecutorOptions;
let group = 'default';
const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy };
if (params.groupsToScheduleActionsInSeries) {
const index = state.groupInSeriesIndex || 0;
@ -226,6 +239,7 @@ export default function(kibana: any) {
params,
reference: params.reference,
source: 'alert:test.always-firing',
alertInfo,
},
});
return {

View file

@ -24,6 +24,14 @@ export interface CreateAlertWithActionOpts {
reference: string;
}
interface UpdateAlwaysFiringAction {
alertId: string;
actionId: string | undefined;
reference: string;
user: User;
overwrites: Record<string, any>;
}
export class AlertUtils {
private referenceCounter = 1;
private readonly user?: User;
@ -176,38 +184,41 @@ export class AlertUtils {
if (this.user) {
request = request.auth(this.user.username, this.user.password);
}
const response = await request.send({
enabled: true,
name: 'abc',
schedule: { interval: '1m' },
throttle: '1m',
tags: [],
alertTypeId: 'test.always-firing',
consumer: 'bar',
params: {
index: ES_TEST_INDEX_NAME,
reference,
},
actions: [
{
group: 'default',
id: this.indexRecordActionId,
params: {
index: ES_TEST_INDEX_NAME,
reference,
message:
'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}',
},
},
],
...overwrites,
});
const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId);
const response = await request.send({ ...alertBody, ...overwrites });
if (response.statusCode === 200) {
objRemover.add(this.space.id, response.body.id, 'alert');
}
return response;
}
public async updateAlwaysFiringAction({
alertId,
actionId,
reference,
user,
overwrites = {},
}: UpdateAlwaysFiringAction) {
actionId = actionId || this.indexRecordActionId;
if (!actionId) {
throw new Error('actionId is required ');
}
const request = this.supertestWithoutAuth
.put(`${getUrlPrefix(this.space.id)}/api/alert/${alertId}`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password);
const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId);
delete alertBody.alertTypeId;
delete alertBody.enabled;
delete alertBody.consumer;
const response = await request.send({ ...alertBody, ...overwrites });
return response;
}
public async createAlwaysFailingAction({
objectRemover,
overwrites = {},
@ -251,3 +262,31 @@ export class AlertUtils {
return response;
}
}
function getDefaultAlwaysFiringAlertData(reference: string, actionId: string) {
return {
enabled: true,
name: 'abc',
schedule: { interval: '1m' },
throttle: '1m',
tags: [],
alertTypeId: 'test.always-firing',
consumer: 'bar',
params: {
index: ES_TEST_INDEX_NAME,
reference,
},
actions: [
{
group: 'default',
id: actionId,
params: {
index: ES_TEST_INDEX_NAME,
reference,
message:
'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}',
},
},
],
};
}

View file

@ -52,6 +52,7 @@ export interface User {
export interface Space {
id: string;
namespace?: string;
name: string;
disabledFeatures: string[];
}

View file

@ -29,7 +29,7 @@ const NoKibanaPrivileges: User = {
},
};
const Superuser: User = {
export const Superuser: User = {
username: 'superuser',
fullName: 'superuser',
password: 'superuser-password',

View file

@ -5,7 +5,7 @@
*/
import expect from '@kbn/expect';
import { UserAtSpaceScenarios } from '../../scenarios';
import { UserAtSpaceScenarios, Superuser } from '../../scenarios';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
ESTestIndexTool,
@ -96,7 +96,9 @@ export default function alertTests({ getService }: FtrProviderContext) {
// Wait for the action to index a document before disabling the alert and waiting for tasks to finish
await esTestIndexTool.waitForDocs('action:test.index-record', reference);
await alertUtils.disable(response.body.id);
const alertId = response.body.id;
await alertUtils.disable(alertId);
await taskManagerUtils.waitForIdle(testStart);
// Ensure only 1 alert executed with proper params
@ -113,6 +115,15 @@ export default function alertTests({ getService }: FtrProviderContext) {
index: ES_TEST_INDEX_NAME,
reference,
},
alertInfo: {
alertId,
spaceId: space.id,
namespace: space.id,
name: 'abc',
tags: [],
createdBy: user.fullName,
updatedBy: user.fullName,
},
});
// Ensure only 1 action executed with proper params
@ -142,6 +153,56 @@ export default function alertTests({ getService }: FtrProviderContext) {
}
});
it('should pass updated alert params to executor', async () => {
// create an alert
const reference = alertUtils.generateReference();
const overwrites = {
throttle: '1s',
schedule: { interval: '1s' },
};
const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites });
// only need to test creation success paths
if (response.statusCode !== 200) return;
// update the alert with super user
const alertId = response.body.id;
const reference2 = alertUtils.generateReference();
const response2 = await alertUtils.updateAlwaysFiringAction({
alertId,
actionId: indexRecordActionId,
user: Superuser,
reference: reference2,
overwrites: {
name: 'def',
tags: ['fee', 'fi', 'fo'],
throttle: '1s',
schedule: { interval: '1s' },
},
});
expect(response2.statusCode).to.eql(200);
// make sure alert info passed to executor is correct
await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2);
await alertUtils.disable(alertId);
const alertSearchResult = await esTestIndexTool.search(
'alert:test.always-firing',
reference2
);
expect(alertSearchResult.hits.total.value).to.be.greaterThan(0);
expect(alertSearchResult.hits.hits[0]._source.alertInfo).to.eql({
alertId,
spaceId: space.id,
namespace: space.id,
name: 'def',
tags: ['fee', 'fi', 'fo'],
createdBy: user.fullName,
updatedBy: Superuser.fullName,
});
});
it('should handle custom retry logic when appropriate', async () => {
const testStart = new Date();
// We have to provide the test.rate-limit the next runAt, for testing purposes

View file

@ -8,17 +8,27 @@ import { Space } from '../common/types';
const Space1: Space = {
id: 'space1',
namespace: 'space1',
name: 'Space 1',
disabledFeatures: [],
};
const Other: Space = {
id: 'other',
namespace: 'other',
name: 'Other',
disabledFeatures: [],
};
const Default: Space = {
id: 'default',
namespace: undefined,
name: 'Default',
disabledFeatures: [],
};
export const Spaces = {
space1: Space1,
other: Other,
default: Default,
};

View file

@ -6,7 +6,7 @@
import expect from '@kbn/expect';
import { Response as SupertestResponse } from 'supertest';
import { Spaces } from '../../scenarios';
import { Space } from '../../../common/types';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
ESTestIndexTool,
@ -19,7 +19,7 @@ import {
} from '../../../common/lib';
// eslint-disable-next-line import/no-default-export
export default function alertTests({ getService }: FtrProviderContext) {
export function alertTests({ getService }: FtrProviderContext, space: Space) {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const es = getService('legacyEs');
const retry = getService('retry');
@ -43,7 +43,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
await esTestIndexTool.setup();
await es.indices.create({ index: authorizationIndex });
const { body: createdAction } = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action`)
.post(`${getUrlPrefix(space.id)}/api/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
@ -58,7 +58,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
.expect(200);
indexRecordActionId = createdAction.id;
alertUtils = new AlertUtils({
space: Spaces.space1,
space,
supertestWithoutAuth,
indexRecordActionId,
objectRemover,
@ -68,19 +68,20 @@ export default function alertTests({ getService }: FtrProviderContext) {
after(async () => {
await esTestIndexTool.destroy();
await es.indices.delete({ index: authorizationIndex });
objectRemover.add(Spaces.space1.id, indexRecordActionId, 'action');
objectRemover.add(space.id, indexRecordActionId, 'action');
await objectRemover.removeAll();
});
it('should schedule task, run alert and schedule actions', async () => {
const reference = alertUtils.generateReference();
const response = await alertUtils.createAlwaysFiringAction({ reference });
const alertId = response.body.id;
expect(response.statusCode).to.eql(200);
const alertTestRecord = (
await esTestIndexTool.waitForDocs('alert:test.always-firing', reference)
)[0];
expect(alertTestRecord._source).to.eql({
const expected = {
source: 'alert:test.always-firing',
reference,
state: {},
@ -88,7 +89,20 @@ export default function alertTests({ getService }: FtrProviderContext) {
index: ES_TEST_INDEX_NAME,
reference,
},
});
alertInfo: {
alertId,
spaceId: space.id,
namespace: space.namespace,
name: 'abc',
tags: [],
createdBy: null,
updatedBy: null,
},
};
if (expected.alertInfo.namespace === undefined) {
delete expected.alertInfo.namespace;
}
expect(alertTestRecord._source).to.eql(expected);
const actionTestRecord = (
await esTestIndexTool.waitForDocs('action:test.index-record', reference)
)[0];
@ -147,7 +161,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
const retryDate = new Date(Date.now() + 60000);
const { body: createdAction } = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action`)
.post(`${getUrlPrefix(space.id)}/api/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'Test rate limit',
@ -155,11 +169,11 @@ export default function alertTests({ getService }: FtrProviderContext) {
config: {},
})
.expect(200);
objectRemover.add(Spaces.space1.id, createdAction.id, 'action');
objectRemover.add(space.id, createdAction.id, 'action');
const reference = alertUtils.generateReference();
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`)
.post(`${getUrlPrefix(space.id)}/api/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
@ -184,7 +198,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
);
expect(response.statusCode).to.eql(200);
objectRemover.add(Spaces.space1.id, response.body.id, 'alert');
objectRemover.add(space.id, response.body.id, 'alert');
const scheduledActionTask = await retry.try(async () => {
const searchResult = await es.search({
index: '.kibana_task_manager',
@ -228,7 +242,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
it('should have proper callCluster and savedObjectsClient authorization for alert type executor', async () => {
const reference = alertUtils.generateReference();
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`)
.post(`${getUrlPrefix(space.id)}/api/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
@ -244,7 +258,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
);
expect(response.statusCode).to.eql(200);
objectRemover.add(Spaces.space1.id, response.body.id, 'alert');
objectRemover.add(space.id, response.body.id, 'alert');
const alertTestRecord = (
await esTestIndexTool.waitForDocs('alert:test.authorization', reference)
)[0];
@ -264,16 +278,16 @@ export default function alertTests({ getService }: FtrProviderContext) {
it('should have proper callCluster and savedObjectsClient authorization for action type executor', async () => {
const reference = alertUtils.generateReference();
const { body: createdAction } = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/action`)
.post(`${getUrlPrefix(space.id)}/api/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
actionTypeId: 'test.authorization',
})
.expect(200);
objectRemover.add(Spaces.space1.id, createdAction.id, 'action');
objectRemover.add(space.id, createdAction.id, 'action');
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`)
.post(`${getUrlPrefix(space.id)}/api/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
@ -299,7 +313,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
);
expect(response.statusCode).to.eql(200);
objectRemover.add(Spaces.space1.id, response.body.id, 'alert');
objectRemover.add(space.id, response.body.id, 'alert');
const actionTestRecord = (
await esTestIndexTool.waitForDocs('action:test.authorization', reference)
)[0];

View file

@ -0,0 +1,14 @@
/*
* 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 { FtrProviderContext } from '../../../common/ftr_provider_context';
import { Spaces } from '../../scenarios';
import { alertTests } from './alerts_base';
// eslint-disable-next-line import/no-default-export
export default function alertSpace1Tests(context: FtrProviderContext) {
alertTests(context, Spaces.default);
}

View file

@ -0,0 +1,14 @@
/*
* 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 { FtrProviderContext } from '../../../common/ftr_provider_context';
import { Spaces } from '../../scenarios';
import { alertTests } from './alerts_base';
// eslint-disable-next-line import/no-default-export
export default function alertSpace1Tests(context: FtrProviderContext) {
alertTests(context, Spaces.space1);
}

View file

@ -22,6 +22,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./unmute_instance'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./update_api_key'));
loadTestFile(require.resolve('./alerts'));
loadTestFile(require.resolve('./alerts_space1'));
loadTestFile(require.resolve('./alerts_default_space'));
});
}

View file

@ -20,7 +20,10 @@ export default function alertingApiIntegrationTests({
before(async () => {
for (const space of Object.values(Spaces)) {
await spacesService.create(space);
if (space.id === 'default') continue;
const { id, name, disabledFeatures } = space;
await spacesService.create({ id, name, disabledFeatures });
}
});