mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Alerting] formalize alert status and add status fields to alert saved object (#75553)
resolves https://github.com/elastic/kibana/issues/51099 This formalizes the concept of "alert status", in terms of it's execution, with some new fields in the alert saved object and types used with the alert client and http APIs. These fields are read-only from the client point-of-view; they are provided in the alert structures, but are only updated by the alerting framework itself. The values will be updated after each run of the alert type executor. The data is added to the alert as the `executionStatus` field, with the following shape: ```ts interface AlertExecutionStatus { status: 'ok' | 'active' | 'error' | 'pending' | 'unknown'; lastExecutionDate: Date; error?: { reason: 'read' | 'decrypt' | 'execute' | 'unknown'; message: string; }; } ```
This commit is contained in:
parent
5f187307c2
commit
117b5771dc
50 changed files with 1176 additions and 47 deletions
|
@ -15,6 +15,28 @@ export interface IntervalSchedule extends SavedObjectAttributes {
|
|||
interval: string;
|
||||
}
|
||||
|
||||
// for the `typeof ThingValues[number]` types below, become string types that
|
||||
// only accept the values in the associated string arrays
|
||||
export const AlertExecutionStatusValues = ['ok', 'active', 'error', 'pending', 'unknown'] as const;
|
||||
export type AlertExecutionStatuses = typeof AlertExecutionStatusValues[number];
|
||||
|
||||
export const AlertExecutionStatusErrorReasonValues = [
|
||||
'read',
|
||||
'decrypt',
|
||||
'execute',
|
||||
'unknown',
|
||||
] as const;
|
||||
export type AlertExecutionStatusErrorReasons = typeof AlertExecutionStatusErrorReasonValues[number];
|
||||
|
||||
export interface AlertExecutionStatus {
|
||||
status: AlertExecutionStatuses;
|
||||
lastExecutionDate: Date;
|
||||
error?: {
|
||||
reason: AlertExecutionStatusErrorReasons;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AlertActionParams = SavedObjectAttributes;
|
||||
|
||||
export interface AlertAction {
|
||||
|
@ -44,6 +66,7 @@ export interface Alert {
|
|||
throttle: string | null;
|
||||
muteAll: boolean;
|
||||
mutedInstanceIds: string[];
|
||||
executionStatus: AlertExecutionStatus;
|
||||
}
|
||||
|
||||
export type SanitizedAlert = Omit<Alert, 'apiKey'>;
|
||||
|
|
|
@ -393,6 +393,11 @@ describe('create()', () => {
|
|||
"createdAt": "2019-02-12T21:01:22.479Z",
|
||||
"createdBy": "elastic",
|
||||
"enabled": true,
|
||||
"executionStatus": Object {
|
||||
"error": null,
|
||||
"lastExecutionDate": "2019-02-12T21:01:22.479Z",
|
||||
"status": "pending",
|
||||
},
|
||||
"meta": Object {
|
||||
"versionApiKeyLastmodified": "v7.10.0",
|
||||
},
|
||||
|
@ -1034,6 +1039,11 @@ describe('create()', () => {
|
|||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
tags: ['foo'],
|
||||
executionStatus: {
|
||||
lastExecutionDate: '2019-02-12T21:01:22.479Z',
|
||||
status: 'pending',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
references: [
|
||||
|
@ -1150,6 +1160,11 @@ describe('create()', () => {
|
|||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
tags: ['foo'],
|
||||
executionStatus: {
|
||||
lastExecutionDate: '2019-02-12T21:01:22.479Z',
|
||||
status: 'pending',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
references: [
|
||||
|
@ -2506,6 +2521,11 @@ const BaseAlertInstanceSummarySavedObject: SavedObject<RawAlert> = {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: '2020-08-20T19:23:38Z',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
AlertTaskState,
|
||||
AlertInstanceSummary,
|
||||
} from './types';
|
||||
import { validateAlertTypeParams } from './lib';
|
||||
import { validateAlertTypeParams, alertExecutionStatusFromRaw } from './lib';
|
||||
import {
|
||||
InvalidateAPIKeyParams,
|
||||
GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult,
|
||||
|
@ -122,6 +122,7 @@ export interface CreateOptions {
|
|||
| 'muteAll'
|
||||
| 'mutedInstanceIds'
|
||||
| 'actions'
|
||||
| 'executionStatus'
|
||||
> & { actions: NormalizedAlertAction[] };
|
||||
options?: {
|
||||
migrationVersion?: Record<string, string>;
|
||||
|
@ -228,6 +229,11 @@ export class AlertsClient {
|
|||
params: validatedAlertTypeParams as RawAlert['params'],
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'pending',
|
||||
lastExecutionDate: new Date().toISOString(),
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
let createdAlert: SavedObject<RawAlert>;
|
||||
try {
|
||||
|
@ -978,9 +984,19 @@ export class AlertsClient {
|
|||
updatedAt: SavedObject['updated_at'] = createdAt,
|
||||
references: SavedObjectReference[] | undefined
|
||||
): PartialAlert {
|
||||
// Not the prettiest code here, but if we want to use most of the
|
||||
// alert fields from the rawAlert using `...rawAlert` kind of access, we
|
||||
// need to specifically delete the executionStatus as it's a different type
|
||||
// in RawAlert and Alert. Probably next time we need to do something similar
|
||||
// here, we should look at redesigning the implementation of this method.
|
||||
const rawAlertWithoutExecutionStatus: Partial<Omit<RawAlert, 'executionStatus'>> = {
|
||||
...rawAlert,
|
||||
};
|
||||
delete rawAlertWithoutExecutionStatus.executionStatus;
|
||||
const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus);
|
||||
return {
|
||||
id,
|
||||
...rawAlert,
|
||||
...rawAlertWithoutExecutionStatus,
|
||||
// we currently only support the Interval Schedule type
|
||||
// Once we support additional types, this type signature will likely change
|
||||
schedule: rawAlert.schedule as IntervalSchedule,
|
||||
|
@ -990,6 +1006,7 @@ export class AlertsClient {
|
|||
...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}),
|
||||
...(createdAt ? { createdAt: new Date(createdAt) } : {}),
|
||||
...(scheduledTaskId ? { scheduledTaskId } : {}),
|
||||
...(executionStatus ? { executionStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
185
x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts
Normal file
185
x-pack/plugins/alerts/server/lib/alert_execution_status.test.ts
Normal file
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* 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 { loggingSystemMock } from '../../../../../src/core/server/mocks';
|
||||
import { AlertExecutionStatusErrorReasons } from '../types';
|
||||
import {
|
||||
executionStatusFromState,
|
||||
executionStatusFromError,
|
||||
alertExecutionStatusToRaw,
|
||||
alertExecutionStatusFromRaw,
|
||||
} from './alert_execution_status';
|
||||
import { ErrorWithReason } from './error_with_reason';
|
||||
|
||||
const MockLogger = loggingSystemMock.create().get();
|
||||
|
||||
describe('AlertExecutionStatus', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('executionStatusFromState()', () => {
|
||||
test('empty task state', () => {
|
||||
const status = executionStatusFromState({});
|
||||
checkDateIsNearNow(status.lastExecutionDate);
|
||||
expect(status.status).toBe('ok');
|
||||
expect(status.error).toBe(undefined);
|
||||
});
|
||||
|
||||
test('task state with no instances', () => {
|
||||
const status = executionStatusFromState({ alertInstances: {} });
|
||||
checkDateIsNearNow(status.lastExecutionDate);
|
||||
expect(status.status).toBe('ok');
|
||||
expect(status.error).toBe(undefined);
|
||||
});
|
||||
|
||||
test('task state with one instance', () => {
|
||||
const status = executionStatusFromState({ alertInstances: { a: {} } });
|
||||
checkDateIsNearNow(status.lastExecutionDate);
|
||||
expect(status.status).toBe('active');
|
||||
expect(status.error).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executionStatusFromError()', () => {
|
||||
test('error with no reason', () => {
|
||||
const status = executionStatusFromError(new Error('boo!'));
|
||||
expect(status.status).toBe('error');
|
||||
expect(status.error).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "boo!",
|
||||
"reason": "unknown",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('error with a reason', () => {
|
||||
const status = executionStatusFromError(new ErrorWithReason('execute', new Error('hoo!')));
|
||||
expect(status.status).toBe('error');
|
||||
expect(status.error).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"message": "hoo!",
|
||||
"reason": "execute",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertExecutionStatusToRaw()', () => {
|
||||
const date = new Date('2020-09-03T16:26:58Z');
|
||||
const status = 'ok';
|
||||
const reason: AlertExecutionStatusErrorReasons = 'decrypt';
|
||||
const error = { reason, message: 'wops' };
|
||||
|
||||
test('status without an error', () => {
|
||||
expect(alertExecutionStatusToRaw({ lastExecutionDate: date, status })).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": null,
|
||||
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('status with an error', () => {
|
||||
expect(alertExecutionStatusToRaw({ lastExecutionDate: date, status, error }))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "wops",
|
||||
"reason": "decrypt",
|
||||
},
|
||||
"lastExecutionDate": "2020-09-03T16:26:58.000Z",
|
||||
"status": "ok",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertExecutionStatusFromRaw()', () => {
|
||||
const date = new Date('2020-09-03T16:26:58Z').toISOString();
|
||||
const status = 'active';
|
||||
const reason: AlertExecutionStatusErrorReasons = 'execute';
|
||||
const error = { reason, message: 'wops' };
|
||||
|
||||
test('no input', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id');
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
|
||||
test('undefined input', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', undefined);
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
|
||||
test('null input', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', null);
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
|
||||
test('invalid date', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', {
|
||||
lastExecutionDate: 'an invalid date',
|
||||
})!;
|
||||
checkDateIsNearNow(result.lastExecutionDate);
|
||||
expect(result.status).toBe('unknown');
|
||||
expect(result.error).toBe(undefined);
|
||||
expect(MockLogger.debug).toBeCalledWith(
|
||||
'invalid alertExecutionStatus lastExecutionDate "an invalid date" in raw alert alert-id'
|
||||
);
|
||||
});
|
||||
|
||||
test('valid date', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', {
|
||||
lastExecutionDate: date,
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"lastExecutionDate": 2020-09-03T16:26:58.000Z,
|
||||
"status": "unknown",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('valid status and date', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', {
|
||||
status,
|
||||
lastExecutionDate: date,
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"lastExecutionDate": 2020-09-03T16:26:58.000Z,
|
||||
"status": "active",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('valid status, date and error', () => {
|
||||
const result = alertExecutionStatusFromRaw(MockLogger, 'alert-id', {
|
||||
status,
|
||||
lastExecutionDate: date,
|
||||
error,
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "wops",
|
||||
"reason": "execute",
|
||||
},
|
||||
"lastExecutionDate": 2020-09-03T16:26:58.000Z,
|
||||
"status": "active",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function checkDateIsNearNow(date: any) {
|
||||
expect(date instanceof Date).toBe(true);
|
||||
// allow for lots of slop in the time difference
|
||||
expect(Date.now() - date.valueOf()).toBeLessThanOrEqual(10000);
|
||||
}
|
66
x-pack/plugins/alerts/server/lib/alert_execution_status.ts
Normal file
66
x-pack/plugins/alerts/server/lib/alert_execution_status.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { Logger } from 'src/core/server';
|
||||
import { AlertTaskState, AlertExecutionStatus, RawAlertExecutionStatus } from '../types';
|
||||
import { getReasonFromError } from './error_with_reason';
|
||||
|
||||
export function executionStatusFromState(state: AlertTaskState): AlertExecutionStatus {
|
||||
const instanceIds = Object.keys(state.alertInstances ?? {});
|
||||
return {
|
||||
lastExecutionDate: new Date(),
|
||||
status: instanceIds.length === 0 ? 'ok' : 'active',
|
||||
};
|
||||
}
|
||||
|
||||
export function executionStatusFromError(error: Error): AlertExecutionStatus {
|
||||
return {
|
||||
lastExecutionDate: new Date(),
|
||||
status: 'error',
|
||||
error: {
|
||||
reason: getReasonFromError(error),
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function alertExecutionStatusToRaw({
|
||||
lastExecutionDate,
|
||||
status,
|
||||
error,
|
||||
}: AlertExecutionStatus): RawAlertExecutionStatus {
|
||||
return {
|
||||
lastExecutionDate: lastExecutionDate.toISOString(),
|
||||
status,
|
||||
// explicitly setting to null (in case undefined) due to partial update concerns
|
||||
error: error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function alertExecutionStatusFromRaw(
|
||||
logger: Logger,
|
||||
alertId: string,
|
||||
rawAlertExecutionStatus?: Partial<RawAlertExecutionStatus> | null | undefined
|
||||
): AlertExecutionStatus | undefined {
|
||||
if (!rawAlertExecutionStatus) return undefined;
|
||||
|
||||
const { lastExecutionDate, status = 'unknown', error } = rawAlertExecutionStatus;
|
||||
|
||||
let parsedDateMillis = lastExecutionDate ? Date.parse(lastExecutionDate) : Date.now();
|
||||
if (isNaN(parsedDateMillis)) {
|
||||
logger.debug(
|
||||
`invalid alertExecutionStatus lastExecutionDate "${lastExecutionDate}" in raw alert ${alertId}`
|
||||
);
|
||||
parsedDateMillis = Date.now();
|
||||
}
|
||||
|
||||
const parsedDate = new Date(parsedDateMillis);
|
||||
if (error) {
|
||||
return { lastExecutionDate: parsedDate, status, error };
|
||||
} else {
|
||||
return { lastExecutionDate: parsedDate, status };
|
||||
}
|
||||
}
|
|
@ -511,4 +511,8 @@ const BaseAlert: SanitizedAlert = {
|
|||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
apiKeyOwner: null,
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
|
28
x-pack/plugins/alerts/server/lib/error_with_reason.test.ts
Normal file
28
x-pack/plugins/alerts/server/lib/error_with_reason.test.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason';
|
||||
|
||||
describe('ErrorWithReason', () => {
|
||||
const plainError = new Error('well, actually');
|
||||
const errorWithReason = new ErrorWithReason('decrypt', plainError);
|
||||
|
||||
test('ErrorWithReason class', () => {
|
||||
expect(errorWithReason.message).toBe(plainError.message);
|
||||
expect(errorWithReason.error).toBe(plainError);
|
||||
expect(errorWithReason.reason).toBe('decrypt');
|
||||
});
|
||||
|
||||
test('getReasonFromError()', () => {
|
||||
expect(getReasonFromError(plainError)).toBe('unknown');
|
||||
expect(getReasonFromError(errorWithReason)).toBe('decrypt');
|
||||
});
|
||||
|
||||
test('isErrorWithReason()', () => {
|
||||
expect(isErrorWithReason(plainError)).toBe(false);
|
||||
expect(isErrorWithReason(errorWithReason)).toBe(true);
|
||||
});
|
||||
});
|
29
x-pack/plugins/alerts/server/lib/error_with_reason.ts
Normal file
29
x-pack/plugins/alerts/server/lib/error_with_reason.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { AlertExecutionStatusErrorReasons } from '../types';
|
||||
|
||||
export class ErrorWithReason extends Error {
|
||||
public readonly reason: AlertExecutionStatusErrorReasons;
|
||||
public readonly error: Error;
|
||||
|
||||
constructor(reason: AlertExecutionStatusErrorReasons, error: Error) {
|
||||
super(error.message);
|
||||
this.error = error;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function getReasonFromError(error: Error): AlertExecutionStatusErrorReasons {
|
||||
if (isErrorWithReason(error)) {
|
||||
return error.reason;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isErrorWithReason(error: Error | ErrorWithReason): error is ErrorWithReason {
|
||||
return error instanceof ErrorWithReason;
|
||||
}
|
|
@ -7,3 +7,10 @@
|
|||
export { parseDuration, validateDurationSchema } from '../../common/parse_duration';
|
||||
export { LicenseState } from './license_state';
|
||||
export { validateAlertTypeParams } from './validate_alert_type_params';
|
||||
export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason';
|
||||
export {
|
||||
executionStatusFromState,
|
||||
executionStatusFromError,
|
||||
alertExecutionStatusToRaw,
|
||||
alertExecutionStatusFromRaw,
|
||||
} from './alert_execution_status';
|
||||
|
|
|
@ -5,27 +5,27 @@
|
|||
*/
|
||||
|
||||
import { isAlertSavedObjectNotFoundError } from './is_alert_not_found_error';
|
||||
import { ErrorWithReason } from './error_with_reason';
|
||||
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
|
||||
import uuid from 'uuid';
|
||||
|
||||
describe('isAlertSavedObjectNotFoundError', () => {
|
||||
const id = uuid.v4();
|
||||
const errorSONF = SavedObjectsErrorHelpers.createGenericNotFoundError('alert', id);
|
||||
|
||||
test('identifies SavedObjects Not Found errors', () => {
|
||||
const id = uuid.v4();
|
||||
// ensure the error created by SO parses as a string with the format we expect
|
||||
expect(
|
||||
`${SavedObjectsErrorHelpers.createGenericNotFoundError('alert', id)}`.includes(`alert/${id}`)
|
||||
).toBe(true);
|
||||
expect(`${errorSONF}`.includes(`alert/${id}`)).toBe(true);
|
||||
|
||||
const errorBySavedObjectsHelper = SavedObjectsErrorHelpers.createGenericNotFoundError(
|
||||
'alert',
|
||||
id
|
||||
);
|
||||
|
||||
expect(isAlertSavedObjectNotFoundError(errorBySavedObjectsHelper, id)).toBe(true);
|
||||
expect(isAlertSavedObjectNotFoundError(errorSONF, id)).toBe(true);
|
||||
});
|
||||
|
||||
test('identifies generic errors', () => {
|
||||
const id = uuid.v4();
|
||||
expect(isAlertSavedObjectNotFoundError(new Error(`not found`), id)).toBe(false);
|
||||
});
|
||||
|
||||
test('identifies SavedObjects Not Found errors wrapped in an ErrorWithReason', () => {
|
||||
const error = new ErrorWithReason('read', errorSONF);
|
||||
expect(isAlertSavedObjectNotFoundError(error, id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,13 @@
|
|||
*/
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
|
||||
import { isErrorWithReason } from './error_with_reason';
|
||||
|
||||
export function isAlertSavedObjectNotFoundError(err: Error, alertId: string) {
|
||||
return SavedObjectsErrorHelpers.isNotFoundError(err) && `${err}`.includes(alertId);
|
||||
// if this is an error with a reason, the actual error needs to be extracted
|
||||
const actualError = isErrorWithReason(err) ? err.error : err;
|
||||
|
||||
return (
|
||||
SavedObjectsErrorHelpers.isNotFoundError(actualError) && `${actualError}`.includes(alertId)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -264,6 +264,7 @@ export class AlertingPlugin {
|
|||
encryptedSavedObjectsClient,
|
||||
getBasePath: this.getBasePath,
|
||||
eventLogger: this.eventLogger!,
|
||||
internalSavedObjectsRepository: core.savedObjects.createInternalRepository(['alert']),
|
||||
});
|
||||
|
||||
this.eventLogService!.registerSavedObjectProvider('alert', (request) => {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { mockLicenseState } from '../lib/license_state.mock';
|
|||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
import { alertsClientMock } from '../alerts_client.mock';
|
||||
import { Alert } from '../../common/alert';
|
||||
|
||||
const alertsClient = alertsClientMock.create();
|
||||
|
||||
|
@ -46,7 +47,7 @@ describe('createAlertRoute', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const createResult = {
|
||||
const createResult: Alert = {
|
||||
...mockedAlert,
|
||||
enabled: true,
|
||||
muteAll: false,
|
||||
|
@ -64,6 +65,10 @@ describe('createAlertRoute', () => {
|
|||
actionTypeId: 'test',
|
||||
},
|
||||
],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
||||
it('creates an alert with proper parameters', async () => {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { mockLicenseState } from '../lib/license_state.mock';
|
|||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
import { alertsClientMock } from '../alerts_client.mock';
|
||||
import { Alert } from '../../common';
|
||||
|
||||
const alertsClient = alertsClientMock.create();
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
|
@ -21,7 +22,7 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe('getAlertRoute', () => {
|
||||
const mockedAlert = {
|
||||
const mockedAlert: Alert = {
|
||||
id: '1',
|
||||
alertTypeId: '1',
|
||||
schedule: { interval: '10s' },
|
||||
|
@ -51,6 +52,10 @@ describe('getAlertRoute', () => {
|
|||
apiKeyOwner: '',
|
||||
throttle: '30s',
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
||||
it('gets an alert with proper parameters', async () => {
|
||||
|
|
|
@ -16,15 +16,19 @@ export const AlertAttributesExcludedFromAAD = [
|
|||
'muteAll',
|
||||
'mutedInstanceIds',
|
||||
'updatedBy',
|
||||
'executionStatus',
|
||||
];
|
||||
|
||||
// useful for Pick<RawAlert, AlertAttributesExcludedFromAADType> which is a
|
||||
// type which is a subset of RawAlert with just attributes excluded from AAD
|
||||
|
||||
// useful for Pick<RawAlert, AlertAttributesExcludedFromAADType>
|
||||
export type AlertAttributesExcludedFromAADType =
|
||||
| 'scheduledTaskId'
|
||||
| 'muteAll'
|
||||
| 'mutedInstanceIds'
|
||||
| 'updatedBy';
|
||||
| 'updatedBy'
|
||||
| 'executionStatus';
|
||||
|
||||
export function setupSavedObjects(
|
||||
savedObjects: SavedObjectsServiceSetup,
|
||||
|
@ -42,11 +46,6 @@ export function setupSavedObjects(
|
|||
encryptedSavedObjects.registerType({
|
||||
type: 'alert',
|
||||
attributesToEncrypt: new Set(['apiKey']),
|
||||
attributesToExcludeFromAAD: new Set([
|
||||
'scheduledTaskId',
|
||||
'muteAll',
|
||||
'mutedInstanceIds',
|
||||
'updatedBy',
|
||||
]),
|
||||
attributesToExcludeFromAAD: new Set(AlertAttributesExcludedFromAAD),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -83,6 +83,26 @@
|
|||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"executionStatus": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"lastExecutionDate": {
|
||||
"type": "date"
|
||||
},
|
||||
"error": {
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"message": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -177,7 +177,7 @@ describe('7.10.0', () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
expect(migration710(alert, { log })).toEqual({
|
||||
expect(migration710(alert, { log })).toMatchObject({
|
||||
...alert,
|
||||
attributes: {
|
||||
...alert.attributes,
|
||||
|
@ -199,6 +199,32 @@ describe('7.10.0', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('creates execution status', () => {
|
||||
const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0'];
|
||||
const alert = getMockData();
|
||||
const dateStart = Date.now();
|
||||
const migratedAlert = migration710(alert, { log });
|
||||
const dateStop = Date.now();
|
||||
const dateExecutionStatus = Date.parse(
|
||||
migratedAlert.attributes.executionStatus.lastExecutionDate
|
||||
);
|
||||
|
||||
expect(dateStart).toBeLessThanOrEqual(dateExecutionStatus);
|
||||
expect(dateStop).toBeGreaterThanOrEqual(dateExecutionStatus);
|
||||
|
||||
expect(migratedAlert).toMatchObject({
|
||||
...alert,
|
||||
attributes: {
|
||||
...alert.attributes,
|
||||
executionStatus: {
|
||||
lastExecutionDate: migratedAlert.attributes.executionStatus.lastExecutionDate,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('7.10.0 migrates with failure', () => {
|
||||
|
@ -237,7 +263,7 @@ describe('7.10.0 migrates with failure', () => {
|
|||
|
||||
function getMockData(
|
||||
overwrites: Record<string, unknown> = {}
|
||||
): SavedObjectUnsanitizedDoc<RawAlert> {
|
||||
): SavedObjectUnsanitizedDoc<Partial<RawAlert>> {
|
||||
return {
|
||||
attributes: {
|
||||
enabled: true,
|
||||
|
|
|
@ -30,7 +30,11 @@ export function getMigrations(
|
|||
// migrate all documents in 7.10 in order to add the "meta" RBAC field
|
||||
return true;
|
||||
},
|
||||
pipeMigrations(markAsLegacyAndChangeConsumer, setAlertIdAsDefaultDedupkeyOnPagerDutyActions)
|
||||
pipeMigrations(
|
||||
markAsLegacyAndChangeConsumer,
|
||||
setAlertIdAsDefaultDedupkeyOnPagerDutyActions,
|
||||
initializeExecutionStatus
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -110,6 +114,23 @@ function setAlertIdAsDefaultDedupkeyOnPagerDutyActions(
|
|||
};
|
||||
}
|
||||
|
||||
function initializeExecutionStatus(
|
||||
doc: SavedObjectUnsanitizedDoc<RawAlert>
|
||||
): SavedObjectUnsanitizedDoc<RawAlert> {
|
||||
const { attributes } = doc;
|
||||
return {
|
||||
...doc,
|
||||
attributes: {
|
||||
...attributes,
|
||||
executionStatus: {
|
||||
status: 'pending',
|
||||
lastExecutionDate: new Date().toISOString(),
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pipeMigrations(...migrations: AlertMigration[]): AlertMigration {
|
||||
return (doc: SavedObjectUnsanitizedDoc<RawAlert>) =>
|
||||
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);
|
||||
|
|
|
@ -15,7 +15,9 @@ import {
|
|||
|
||||
import { AlertAttributesExcludedFromAAD, AlertAttributesExcludedFromAADType } from './index';
|
||||
|
||||
export type PartiallyUpdateableAlertAttributes = Pick<RawAlert, AlertAttributesExcludedFromAADType>;
|
||||
export type PartiallyUpdateableAlertAttributes = Partial<
|
||||
Pick<RawAlert, AlertAttributesExcludedFromAADType>
|
||||
>;
|
||||
|
||||
export interface PartiallyUpdateAlertSavedObjectOptions {
|
||||
version?: string;
|
||||
|
|
|
@ -29,6 +29,10 @@ const alert: SanitizedAlert = {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
||||
describe('Alert Task Instance', () => {
|
||||
|
|
|
@ -11,14 +11,17 @@ import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server';
|
|||
import { TaskRunnerContext } from './task_runner_factory';
|
||||
import { TaskRunner } from './task_runner';
|
||||
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
|
||||
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
|
||||
import {
|
||||
loggingSystemMock,
|
||||
savedObjectsRepositoryMock,
|
||||
} from '../../../../../src/core/server/mocks';
|
||||
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
|
||||
import { actionsMock, actionsClientMock } from '../../../actions/server/mocks';
|
||||
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';
|
||||
const alertType = {
|
||||
id: 'test',
|
||||
name: 'My test alert',
|
||||
|
@ -71,9 +74,10 @@ describe('Task Runner', () => {
|
|||
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
|
||||
getBasePath: jest.fn().mockReturnValue(undefined),
|
||||
eventLogger: eventLoggerMock.create(),
|
||||
internalSavedObjectsRepository: savedObjectsRepositoryMock.create(),
|
||||
};
|
||||
|
||||
const mockedAlertTypeSavedObject = {
|
||||
const mockedAlertTypeSavedObject: Alert = {
|
||||
id: '1',
|
||||
consumer: 'bar',
|
||||
createdAt: new Date('2019-02-12T21:01:22.479Z'),
|
||||
|
@ -82,6 +86,7 @@ describe('Task Runner', () => {
|
|||
muteAll: false,
|
||||
enabled: true,
|
||||
alertTypeId: '123',
|
||||
apiKey: '',
|
||||
apiKeyOwner: 'elastic',
|
||||
schedule: { interval: '10s' },
|
||||
name: 'alert-name',
|
||||
|
@ -102,6 +107,10 @@ describe('Task Runner', () => {
|
|||
},
|
||||
},
|
||||
],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -11,7 +11,13 @@ import { ConcreteTaskInstance } from '../../../task_manager/server';
|
|||
import { createExecutionHandler } from './create_execution_handler';
|
||||
import { AlertInstance, createAlertInstanceFactory } from '../alert_instance';
|
||||
import { getNextRunAt } from './get_next_run_at';
|
||||
import { validateAlertTypeParams } from '../lib';
|
||||
import {
|
||||
validateAlertTypeParams,
|
||||
executionStatusFromState,
|
||||
executionStatusFromError,
|
||||
alertExecutionStatusToRaw,
|
||||
ErrorWithReason,
|
||||
} from '../lib';
|
||||
import {
|
||||
AlertType,
|
||||
RawAlert,
|
||||
|
@ -22,6 +28,7 @@ import {
|
|||
Alert,
|
||||
AlertExecutorOptions,
|
||||
SanitizedAlert,
|
||||
AlertExecutionStatus,
|
||||
} from '../types';
|
||||
import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type';
|
||||
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
|
||||
|
@ -29,6 +36,7 @@ import { EVENT_LOG_ACTIONS } from '../plugin';
|
|||
import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server';
|
||||
import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error';
|
||||
import { AlertsClient } from '../alerts_client';
|
||||
import { partiallyUpdateAlert } from '../saved_objects';
|
||||
|
||||
const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' };
|
||||
|
||||
|
@ -204,7 +212,7 @@ export class TaskRunner {
|
|||
event.event = event.event || {};
|
||||
event.event.outcome = 'failure';
|
||||
eventLogger.logEvent(event);
|
||||
throw err;
|
||||
throw new ErrorWithReason('execute', err);
|
||||
}
|
||||
|
||||
eventLogger.stopTiming(event);
|
||||
|
@ -278,15 +286,22 @@ export class TaskRunner {
|
|||
const {
|
||||
params: { alertId, spaceId },
|
||||
} = this.taskInstance;
|
||||
let apiKey: string | null;
|
||||
try {
|
||||
apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId);
|
||||
} catch (err) {
|
||||
throw new ErrorWithReason('decrypt', err);
|
||||
}
|
||||
const [services, alertsClient] = this.getServicesWithSpaceLevelPermissions(spaceId, apiKey);
|
||||
|
||||
const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId);
|
||||
const [services, alertsClient] = await this.getServicesWithSpaceLevelPermissions(
|
||||
spaceId,
|
||||
apiKey
|
||||
);
|
||||
let alert: SanitizedAlert;
|
||||
|
||||
// Ensure API key is still valid and user has access
|
||||
const alert = await alertsClient.get({ id: alertId });
|
||||
try {
|
||||
alert = await alertsClient.get({ id: alertId });
|
||||
} catch (err) {
|
||||
throw new ErrorWithReason('read', err);
|
||||
}
|
||||
|
||||
return {
|
||||
state: await promiseResult<AlertTaskState, Error>(
|
||||
|
@ -306,12 +321,38 @@ export class TaskRunner {
|
|||
|
||||
async run(): Promise<AlertTaskRunResult> {
|
||||
const {
|
||||
params: { alertId },
|
||||
params: { alertId, spaceId },
|
||||
startedAt: previousStartedAt,
|
||||
state: originalState,
|
||||
} = this.taskInstance;
|
||||
|
||||
const { state, runAt } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun());
|
||||
const namespace = spaceId === 'default' ? undefined : spaceId;
|
||||
|
||||
const executionStatus: AlertExecutionStatus = map(
|
||||
state,
|
||||
(alertTaskState: AlertTaskState) => executionStatusFromState(alertTaskState),
|
||||
(err: Error) => executionStatusFromError(err)
|
||||
);
|
||||
this.logger.debug(
|
||||
`alertExecutionStatus for ${this.alertType.id}:${alertId}: ${JSON.stringify(executionStatus)}`
|
||||
);
|
||||
|
||||
const client = this.context.internalSavedObjectsRepository;
|
||||
const attributes = {
|
||||
executionStatus: alertExecutionStatusToRaw(executionStatus),
|
||||
};
|
||||
|
||||
try {
|
||||
await partiallyUpdateAlert(client, alertId, attributes, {
|
||||
ignore404: true,
|
||||
namespace,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`error updating alert execution status for ${this.alertType.id}:${alertId} ${err.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
state: map<AlertTaskState, Error, AlertTaskState>(
|
||||
|
|
|
@ -8,7 +8,10 @@ import sinon from 'sinon';
|
|||
import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server';
|
||||
import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory';
|
||||
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
|
||||
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
|
||||
import {
|
||||
loggingSystemMock,
|
||||
savedObjectsRepositoryMock,
|
||||
} from '../../../../../src/core/server/mocks';
|
||||
import { actionsMock } from '../../../actions/server/mocks';
|
||||
import { alertsMock, alertsClientMock } from '../mocks';
|
||||
import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
|
||||
|
@ -63,6 +66,7 @@ describe('Task Runner Factory', () => {
|
|||
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
|
||||
getBasePath: jest.fn().mockReturnValue(undefined),
|
||||
eventLogger: eventLoggerMock.create(),
|
||||
internalSavedObjectsRepository: savedObjectsRepositoryMock.create(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Logger, KibanaRequest } from '../../../../../src/core/server';
|
||||
import { Logger, KibanaRequest, ISavedObjectsRepository } from '../../../../../src/core/server';
|
||||
import { RunContext } from '../../../task_manager/server';
|
||||
import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server';
|
||||
import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server';
|
||||
|
@ -26,6 +26,7 @@ export interface TaskRunnerContext {
|
|||
encryptedSavedObjectsClient: EncryptedSavedObjectsClient;
|
||||
spaceIdToNamespace: SpaceIdToNamespaceFunction;
|
||||
getBasePath: GetBasePathFunction;
|
||||
internalSavedObjectsRepository: ISavedObjectsRepository;
|
||||
}
|
||||
|
||||
export class TaskRunnerFactory {
|
||||
|
|
|
@ -24,6 +24,8 @@ import {
|
|||
AlertTypeState,
|
||||
AlertInstanceContext,
|
||||
AlertInstanceState,
|
||||
AlertExecutionStatuses,
|
||||
AlertExecutionStatusErrorReasons,
|
||||
} from '../common';
|
||||
|
||||
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
|
||||
|
@ -115,6 +117,18 @@ export interface AlertMeta extends SavedObjectAttributes {
|
|||
versionApiKeyLastmodified?: string;
|
||||
}
|
||||
|
||||
// note that the `error` property is "null-able", as we're doing a partial
|
||||
// update on the alert when we update this data, but need to ensure we
|
||||
// delete any previous error if the current status has no error
|
||||
export interface RawAlertExecutionStatus extends SavedObjectAttributes {
|
||||
status: AlertExecutionStatuses;
|
||||
lastExecutionDate: string;
|
||||
error: null | {
|
||||
reason: AlertExecutionStatusErrorReasons;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PartialAlert = Pick<Alert, 'id'> & Partial<Omit<Alert, 'id'>>;
|
||||
|
||||
export interface RawAlert extends SavedObjectAttributes {
|
||||
|
@ -136,6 +150,7 @@ export interface RawAlert extends SavedObjectAttributes {
|
|||
muteAll: boolean;
|
||||
mutedInstanceIds: string[];
|
||||
meta?: AlertMeta;
|
||||
executionStatus: RawAlertExecutionStatus;
|
||||
}
|
||||
|
||||
export type AlertInfoParams = Pick<
|
||||
|
|
|
@ -419,6 +419,10 @@ export const getResult = (): RuleAlertType => ({
|
|||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888',
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
});
|
||||
|
||||
export const getMlResult = (): RuleAlertType => {
|
||||
|
@ -630,6 +634,10 @@ export const getNotificationResult = (): RuleNotificationAlertType => ({
|
|||
mutedInstanceIds: [],
|
||||
scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7',
|
||||
updatedAt: new Date('2020-03-21T12:37:08.730Z'),
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
});
|
||||
|
||||
export const getFindNotificationsResultWithSingleHit = (): FindHit<RuleNotificationAlertType> => ({
|
||||
|
|
|
@ -110,6 +110,10 @@ const rule: SanitizedAlert = {
|
|||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888',
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
|
||||
export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({
|
||||
|
|
|
@ -398,6 +398,10 @@ describe('createAlert', () => {
|
|||
updatedBy: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
http.post.mockResolvedValueOnce(resolvedValue);
|
||||
|
||||
|
@ -440,6 +444,10 @@ describe('updateAlert', () => {
|
|||
updatedBy: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
http.put.mockResolvedValueOnce(resolvedValue);
|
||||
|
||||
|
|
|
@ -136,7 +136,10 @@ export async function createAlert({
|
|||
alert,
|
||||
}: {
|
||||
http: HttpSetup;
|
||||
alert: Omit<AlertWithoutId, 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds'>;
|
||||
alert: Omit<
|
||||
AlertWithoutId,
|
||||
'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus'
|
||||
>;
|
||||
}): Promise<Alert> {
|
||||
return await http.post(`${BASE_ALERT_API_PATH}/alert`, {
|
||||
body: JSON.stringify(alert),
|
||||
|
|
|
@ -757,6 +757,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -404,6 +404,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -254,6 +254,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -132,6 +132,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -105,6 +105,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers';
|
|||
import { act } from 'react-dom/test-utils';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
|
||||
import { ValidationResult } from '../../../types';
|
||||
import { ValidationResult, Alert } from '../../../types';
|
||||
import { AlertsContextProvider } from '../../context/alerts_context';
|
||||
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
|
@ -73,7 +73,7 @@ describe('alert_edit', () => {
|
|||
actionParamsFields: null,
|
||||
};
|
||||
|
||||
const alert = {
|
||||
const alert: Alert = {
|
||||
id: 'ab5661e0-197e-45ee-b477-302d89193b5e',
|
||||
params: {
|
||||
aggType: 'average',
|
||||
|
@ -93,7 +93,6 @@ describe('alert_edit', () => {
|
|||
actionTypeId: 'my-action-type',
|
||||
group: 'threshold met',
|
||||
params: { message: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold' },
|
||||
message: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold',
|
||||
id: '917f5d41-fbc4-4056-a8ad-ac592f7dcee2',
|
||||
},
|
||||
],
|
||||
|
@ -107,6 +106,10 @@ describe('alert_edit', () => {
|
|||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
updatedAt: new Date(),
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
};
|
||||
actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel);
|
||||
actionTypeRegistry.has.mockReturnValue(true);
|
||||
|
|
|
@ -264,6 +264,10 @@ function mockAlert(overloads: Partial<Alert> = {}): Alert {
|
|||
throttle: null,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: {
|
||||
status: 'unknown',
|
||||
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
|
||||
},
|
||||
...overloads,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,3 +15,21 @@ export function ensureDatetimeIsWithinRange(
|
|||
expect(diff).to.be.greaterThan(expectedDiff - buffer);
|
||||
expect(diff).to.be.lessThan(expectedDiff + buffer);
|
||||
}
|
||||
|
||||
export function ensureDatetimesAreOrdered(dates: Array<Date | string | number>) {
|
||||
const dateStrings = dates.map(normalizeDate);
|
||||
const sortedDateStrings = dateStrings.slice().sort();
|
||||
expect(dateStrings).to.eql(sortedDateStrings);
|
||||
}
|
||||
|
||||
function normalizeDate(date: Date | string | number): string {
|
||||
if (typeof date === 'number') return new Date(date).toISOString();
|
||||
if (date instanceof Date) return date.toISOString();
|
||||
|
||||
const dateString = `${date}`;
|
||||
const dateNumber = Date.parse(dateString);
|
||||
if (isNaN(dateNumber)) {
|
||||
throw new Error(`invalid date string: "${dateString}"`);
|
||||
}
|
||||
return new Date(dateNumber).toISOString();
|
||||
}
|
||||
|
|
|
@ -119,6 +119,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) {
|
|||
apiKeyOwner: user.username,
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(typeof response.body.scheduledTaskId).to.be('string');
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { Spaces } from '../../scenarios';
|
||||
import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function executionStatusAlertTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const spaceId = Spaces[0].id;
|
||||
|
||||
// the only tests here are those that can't be run in spaces_only
|
||||
describe('executionStatus', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
|
||||
after(async () => await objectRemover.removeAll());
|
||||
|
||||
it('should eventually have error reason "decrypt" when appropriate', async () => {
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(spaceId)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.noop',
|
||||
schedule: { interval: '1s' },
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
objectRemover.add(spaceId, alertId, 'alert', 'alerts');
|
||||
|
||||
let executionStatus = await waitForStatus(alertId, new Set(['ok']), 10000);
|
||||
|
||||
// break AAD
|
||||
await supertest
|
||||
.put(`${getUrlPrefix(spaceId)}/api/alerts_fixture/saved_object/alert/${alertId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
attributes: {
|
||||
name: 'bar',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
executionStatus = await waitForStatus(alertId, new Set(['error']));
|
||||
expect(executionStatus.error).to.be.ok();
|
||||
expect(executionStatus.error.reason).to.be('decrypt');
|
||||
expect(executionStatus.error.message).to.be('Unable to decrypt attribute "apiKey"');
|
||||
});
|
||||
});
|
||||
|
||||
const WaitForStatusIncrement = 500;
|
||||
|
||||
async function waitForStatus(
|
||||
id: string,
|
||||
statuses: Set<string>,
|
||||
waitMillis: number = 10000
|
||||
): Promise<Record<string, any>> {
|
||||
if (waitMillis < 0) {
|
||||
expect().fail(`waiting for alert ${id} statuses ${Array.from(statuses)} timed out`);
|
||||
}
|
||||
|
||||
const response = await supertest.get(`${getUrlPrefix(spaceId)}/api/alerts/alert/${id}`);
|
||||
expect(response.status).to.eql(200);
|
||||
const { status } = response.body.executionStatus;
|
||||
if (statuses.has(status)) return response.body.executionStatus;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`waitForStatus(${Array.from(statuses)}): got ${JSON.stringify(
|
||||
response.body.executionStatus
|
||||
)}, retrying`
|
||||
);
|
||||
|
||||
await delay(WaitForStatusIncrement);
|
||||
return await waitForStatus(id, statuses, waitMillis - WaitForStatusIncrement);
|
||||
}
|
||||
}
|
||||
|
||||
async function delay(millis: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
|
@ -79,6 +79,7 @@ export default function createFindTests({ getService }: FtrProviderContext) {
|
|||
apiKeyOwner: 'elastic',
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: match.executionStatus,
|
||||
});
|
||||
expect(Date.parse(match.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(match.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -273,6 +274,7 @@ export default function createFindTests({ getService }: FtrProviderContext) {
|
|||
mutedInstanceIds: [],
|
||||
createdAt: match.createdAt,
|
||||
updatedAt: match.updatedAt,
|
||||
executionStatus: match.executionStatus,
|
||||
});
|
||||
expect(Date.parse(match.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(match.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -359,6 +361,85 @@ export default function createFindTests({ getService }: FtrProviderContext) {
|
|||
}
|
||||
});
|
||||
|
||||
it('should handle find alert request with executionStatus field appropriately', async () => {
|
||||
const myTag = uuid.v4();
|
||||
const { body: createdAlert } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
enabled: false,
|
||||
tags: [myTag],
|
||||
alertTypeId: 'test.restricted-noop',
|
||||
consumer: 'alertsRestrictedFixture',
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts');
|
||||
|
||||
// create another type with same tag
|
||||
const { body: createdSecondAlert } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
tags: [myTag],
|
||||
alertTypeId: 'test.restricted-noop',
|
||||
consumer: 'alertsRestrictedFixture',
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
objectRemover.add(space.id, createdSecondAlert.id, 'alert', 'alerts');
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.get(
|
||||
`${getUrlPrefix(
|
||||
space.id
|
||||
)}/api/alerts/_find?filter=alert.attributes.alertTypeId:test.restricted-noop&fields=["tags","executionStatus"]&sort_field=createdAt`
|
||||
)
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.statusCode).to.eql(403);
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: `Unauthorized to find any alert types`,
|
||||
statusCode: 403,
|
||||
});
|
||||
break;
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
expect(response.statusCode).to.eql(200);
|
||||
expect(response.body.data).to.eql([]);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.statusCode).to.eql(200);
|
||||
expect(response.body.page).to.equal(1);
|
||||
expect(response.body.perPage).to.be.greaterThan(0);
|
||||
expect(response.body.total).to.be.greaterThan(0);
|
||||
const [matchFirst, matchSecond] = response.body.data;
|
||||
expect(omit(matchFirst, 'updatedAt')).to.eql({
|
||||
id: createdAlert.id,
|
||||
actions: [],
|
||||
tags: [myTag],
|
||||
executionStatus: matchFirst.executionStatus,
|
||||
});
|
||||
expect(omit(matchSecond, 'updatedAt')).to.eql({
|
||||
id: createdSecondAlert.id,
|
||||
actions: [],
|
||||
tags: [myTag],
|
||||
executionStatus: matchSecond.executionStatus,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it(`shouldn't find alert from another space`, async () => {
|
||||
const { body: createdAlert } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerts/alert`)
|
||||
|
|
|
@ -75,6 +75,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
|
|||
apiKeyOwner: 'elastic',
|
||||
muteAll: false,
|
||||
mutedInstanceIds: [],
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./delete'));
|
||||
loadTestFile(require.resolve('./disable'));
|
||||
loadTestFile(require.resolve('./enable'));
|
||||
loadTestFile(require.resolve('./execution_status'));
|
||||
loadTestFile(require.resolve('./get'));
|
||||
loadTestFile(require.resolve('./get_alert_state'));
|
||||
loadTestFile(require.resolve('./get_alert_instance_summary'));
|
||||
|
|
|
@ -129,6 +129,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -211,6 +212,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -304,6 +306,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -397,6 +400,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
@ -486,6 +490,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -87,6 +87,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) {
|
|||
mutedInstanceIds: [],
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -0,0 +1,333 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { Spaces } from '../../scenarios';
|
||||
import {
|
||||
checkAAD,
|
||||
getUrlPrefix,
|
||||
getTestAlertData,
|
||||
ObjectRemover,
|
||||
ensureDatetimesAreOrdered,
|
||||
} from '../../../common/lib';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function executionStatusAlertTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('executionStatus', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
|
||||
after(async () => await objectRemover.removeAll());
|
||||
|
||||
it('should be "pending" for newly created alert', async () => {
|
||||
const dateStart = Date.now();
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestAlertData());
|
||||
const dateEnd = Date.now();
|
||||
expect(response.status).to.eql(200);
|
||||
objectRemover.add(Spaces.space1.id, response.body.id, 'alert', 'alerts');
|
||||
|
||||
expect(response.body.executionStatus).to.be.ok();
|
||||
const { status, lastExecutionDate, error } = response.body.executionStatus;
|
||||
expect(status).to.be('pending');
|
||||
ensureDatetimesAreOrdered([dateStart, lastExecutionDate, dateEnd]);
|
||||
expect(error).not.to.be.ok();
|
||||
|
||||
// Ensure AAD isn't broken
|
||||
await checkAAD({
|
||||
supertest,
|
||||
spaceId: Spaces.space1.id,
|
||||
type: 'alert',
|
||||
id: response.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should eventually be "ok" for no-op alert', async () => {
|
||||
const dates = [];
|
||||
dates.push(Date.now());
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.noop',
|
||||
schedule: { interval: '1s' },
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
dates.push(response.body.executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
const executionStatus = await waitForStatus(alertId, new Set(['ok']));
|
||||
dates.push(executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
ensureDatetimesAreOrdered(dates);
|
||||
|
||||
// Ensure AAD isn't broken
|
||||
await checkAAD({
|
||||
supertest,
|
||||
spaceId: Spaces.space1.id,
|
||||
type: 'alert',
|
||||
id: response.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should eventually be "active" for firing alert', async () => {
|
||||
const dates = [];
|
||||
dates.push(Date.now());
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.patternFiring',
|
||||
schedule: { interval: '1s' },
|
||||
params: {
|
||||
pattern: { instance: trues(100) },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
dates.push(response.body.executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
const executionStatus = await waitForStatus(alertId, new Set(['active']));
|
||||
dates.push(executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
ensureDatetimesAreOrdered(dates);
|
||||
|
||||
// Ensure AAD isn't broken
|
||||
await checkAAD({
|
||||
supertest,
|
||||
spaceId: Spaces.space1.id,
|
||||
type: 'alert',
|
||||
id: response.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should eventually be "error" for an error alert', async () => {
|
||||
const dates = [];
|
||||
dates.push(Date.now());
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.throw',
|
||||
schedule: { interval: '1s' },
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
dates.push(response.body.executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
const executionStatus = await waitForStatus(alertId, new Set(['error']));
|
||||
dates.push(executionStatus.lastExecutionDate);
|
||||
dates.push(Date.now());
|
||||
ensureDatetimesAreOrdered(dates);
|
||||
|
||||
// Ensure AAD isn't broken
|
||||
await checkAAD({
|
||||
supertest,
|
||||
spaceId: Spaces.space1.id,
|
||||
type: 'alert',
|
||||
id: response.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
// not sure how to test the read error reason!
|
||||
|
||||
// note the decrypt error reason is tested in security_and_spaces, can't be tested
|
||||
// without security on
|
||||
|
||||
it('should eventually have error reason "execute" when appropriate', async () => {
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.throw',
|
||||
schedule: { interval: '1s' },
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
const executionStatus = await waitForStatus(alertId, new Set(['error']));
|
||||
expect(executionStatus.error).to.be.ok();
|
||||
expect(executionStatus.error.reason).to.be('execute');
|
||||
expect(executionStatus.error.message).to.be('this alert is intended to fail');
|
||||
});
|
||||
|
||||
it('should eventually have error reason "unknown" when appropriate', async () => {
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.validation',
|
||||
schedule: { interval: '1s' },
|
||||
params: { param1: 'valid now, but will change to a number soon!' },
|
||||
})
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const alertId = response.body.id;
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
let executionStatus = await waitForStatus(alertId, new Set(['ok']));
|
||||
|
||||
// break the validation of the params
|
||||
await supertest
|
||||
.put(`${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/saved_object/alert/${alertId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
attributes: {
|
||||
params: { param1: 42 },
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
executionStatus = await waitForStatus(alertId, new Set(['error']));
|
||||
expect(executionStatus.error).to.be.ok();
|
||||
expect(executionStatus.error.reason).to.be('unknown');
|
||||
|
||||
const message = 'params invalid: [param1]: expected value of type [string] but got [number]';
|
||||
expect(executionStatus.error.message).to.be(message);
|
||||
});
|
||||
|
||||
it('should be able to find over all the fields', async () => {
|
||||
const startDate = Date.now();
|
||||
const createResponse = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
alertTypeId: 'test.throw',
|
||||
schedule: { interval: '1s' },
|
||||
})
|
||||
);
|
||||
expect(createResponse.status).to.eql(200);
|
||||
const alertId = createResponse.body.id;
|
||||
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
|
||||
|
||||
await waitForStatus(alertId, new Set(['error']));
|
||||
|
||||
let filter = `lastExecutionDate>${startDate}`;
|
||||
let executionStatus = await waitForFindStatus(alertId, new Set(['error']), filter);
|
||||
expectErrorExecutionStatus(executionStatus, startDate);
|
||||
|
||||
filter = `status:error`;
|
||||
executionStatus = await waitForFindStatus(alertId, new Set(['error']), filter);
|
||||
expectErrorExecutionStatus(executionStatus, startDate);
|
||||
|
||||
filter = `error.message:*intended*`;
|
||||
executionStatus = await waitForFindStatus(alertId, new Set(['error']), filter);
|
||||
expectErrorExecutionStatus(executionStatus, startDate);
|
||||
|
||||
filter = `error.reason:execute`;
|
||||
executionStatus = await waitForFindStatus(alertId, new Set(['error']), filter);
|
||||
expectErrorExecutionStatus(executionStatus, startDate);
|
||||
});
|
||||
});
|
||||
|
||||
const WaitForStatusIncrement = 500;
|
||||
|
||||
async function waitForStatus(
|
||||
id: string,
|
||||
statuses: Set<string>,
|
||||
waitMillis: number = 10000
|
||||
): Promise<Record<string, any>> {
|
||||
if (waitMillis < 0) {
|
||||
expect().fail(`waiting for alert ${id} statuses ${Array.from(statuses)} timed out`);
|
||||
}
|
||||
|
||||
const response = await supertest.get(
|
||||
`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${id}`
|
||||
);
|
||||
expect(response.status).to.eql(200);
|
||||
const { status } = response.body.executionStatus;
|
||||
|
||||
const message = `waitForStatus(${Array.from(statuses)}): got ${JSON.stringify(
|
||||
response.body.executionStatus
|
||||
)}`;
|
||||
|
||||
if (statuses.has(status)) {
|
||||
return response.body.executionStatus;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${message}, retrying`);
|
||||
|
||||
await delay(WaitForStatusIncrement);
|
||||
return await waitForStatus(id, statuses, waitMillis - WaitForStatusIncrement);
|
||||
}
|
||||
|
||||
async function waitForFindStatus(
|
||||
id: string,
|
||||
statuses: Set<string>,
|
||||
filter: string,
|
||||
waitMillis: number = 10000
|
||||
): Promise<Record<string, any>> {
|
||||
if (waitMillis < 0) {
|
||||
expect().fail(`waiting for find alert ${id} statuses ${Array.from(statuses)} timed out`);
|
||||
}
|
||||
|
||||
const findUri = getFindUri(filter);
|
||||
const response = await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/${findUri}`);
|
||||
|
||||
expect(response.status).to.eql(200);
|
||||
const { executionStatus } = response.body.data.find((obj: any) => obj.id === id);
|
||||
|
||||
const message = `waitForFindStatus(${Array.from(statuses)}): got ${JSON.stringify(
|
||||
executionStatus
|
||||
)}`;
|
||||
|
||||
if (statuses.has(executionStatus.status)) {
|
||||
return executionStatus;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${message}, retrying`);
|
||||
|
||||
await delay(WaitForStatusIncrement);
|
||||
return await waitForStatus(id, statuses, waitMillis - WaitForStatusIncrement);
|
||||
}
|
||||
}
|
||||
|
||||
function expectErrorExecutionStatus(executionStatus: Record<string, any>, startDate: number) {
|
||||
expect(executionStatus.status).to.equal('error');
|
||||
|
||||
const statusDate = Date.parse(executionStatus.lastExecutionDate);
|
||||
const stopDate = Date.now();
|
||||
expect(startDate).to.be.lessThan(statusDate);
|
||||
expect(stopDate).to.be.greaterThan(statusDate);
|
||||
|
||||
expect(executionStatus.error.message).to.equal('this alert is intended to fail');
|
||||
expect(executionStatus.error.reason).to.equal('execute');
|
||||
}
|
||||
|
||||
function getFindUri(filter: string) {
|
||||
return `api/alerts/_find?filter=alert.attributes.executionStatus.${filter}`;
|
||||
}
|
||||
|
||||
function trues(length: number): boolean[] {
|
||||
return new Array(length).fill(true);
|
||||
}
|
||||
|
||||
async function delay(millis: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
|
@ -56,6 +56,7 @@ export default function createFindTests({ getService }: FtrProviderContext) {
|
|||
mutedInstanceIds: [],
|
||||
createdAt: match.createdAt,
|
||||
updatedAt: match.updatedAt,
|
||||
executionStatus: match.executionStatus,
|
||||
});
|
||||
expect(Date.parse(match.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(match.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -50,6 +50,7 @@ export default function createGetTests({ getService }: FtrProviderContext) {
|
|||
mutedInstanceIds: [],
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
|
|||
loadTestFile(require.resolve('./get_alert_instance_summary'));
|
||||
loadTestFile(require.resolve('./list_alert_types'));
|
||||
loadTestFile(require.resolve('./event_log'));
|
||||
loadTestFile(require.resolve('./execution_status'));
|
||||
loadTestFile(require.resolve('./mute_all'));
|
||||
loadTestFile(require.resolve('./mute_instance'));
|
||||
loadTestFile(require.resolve('./unmute_all'));
|
||||
|
|
|
@ -57,6 +57,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) {
|
|||
scheduledTaskId: createdAlert.scheduledTaskId,
|
||||
createdAt: response.body.createdAt,
|
||||
updatedAt: response.body.updatedAt,
|
||||
executionStatus: response.body.executionStatus,
|
||||
});
|
||||
expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0);
|
||||
expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0);
|
||||
|
|
|
@ -248,16 +248,25 @@ export const getSimpleMlRuleOutput = (ruleId = 'rule-1'): Partial<RulesSchema> =
|
|||
export const deleteAllAlerts = async (es: Client, retryCount = 20): Promise<void> => {
|
||||
if (retryCount > 0) {
|
||||
try {
|
||||
await es.deleteByQuery({
|
||||
const result = await es.deleteByQuery({
|
||||
index: '.kibana',
|
||||
q: 'type:alert',
|
||||
wait_for_completion: true,
|
||||
refresh: true,
|
||||
conflicts: 'proceed',
|
||||
body: {},
|
||||
});
|
||||
// deleteByQuery will cause version conflicts as alerts are being updated
|
||||
// by background processes; the code below accounts for that
|
||||
if (result.body.version_conflicts !== 0) {
|
||||
throw new Error(`Version conflicts for ${result.body.version_conflicts} alerts`);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failure trying to deleteAllAlerts, retries left are: ${retryCount - 1}`, err);
|
||||
console.log(`Error in deleteAllAlerts(), retries left: ${retryCount - 1}`, err);
|
||||
|
||||
// retry, counting down, and delay a bit before
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await deleteAllAlerts(es, retryCount - 1);
|
||||
}
|
||||
} else {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue