Implemented Alerting health status pusher by using task manager and status pooler for Kibana status plugins 'kibanahost/api/status' (#79056)

* Implemented Alerting health status pusher by using task manager and status pooler for Kibana status plugins 'kibanahost/api/status'

* Exposed health task registration to alerts plugin

* Fixed type error

* Extended health API endpoint with info about decryption failures, added correct health task implementation

* adjusted query

* Tested locally and got it working as expected, fixed tests and type check

* Added unit tests

* Changed AlertExecutionStatusErrorReasons to be enum

* Uppercase the enum

* Replaced string values to enum

* Fixed types

* Extended AlertsClient with getHealth method

* added return type to healthStatus$

* Added configurable health check interval and timestamps

* Extended update core status interval to 5mins

* Fixed failing tests

* Registered alerts config

* Fixed date for ok health state

* fixed jest test

* fixed task state

* Fixed due to comments, moved getHealth to a plugin level

* fixed type checks

* Added sorting to the latest Ok state last update

* adjusted error queries

* Fixed jest tests

* removed unused

* fixed type check
This commit is contained in:
Yuliia Naumenko 2020-11-06 16:20:39 -08:00 committed by GitHub
parent b08677b904
commit 802c6dccb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 917 additions and 81 deletions

View file

@ -20,13 +20,12 @@ export interface IntervalSchedule extends SavedObjectAttributes {
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 enum AlertExecutionStatusErrorReasons {
Read = 'read',
Decrypt = 'decrypt',
Execute = 'execute',
Unknown = 'unknown',
}
export interface AlertExecutionStatus {
status: AlertExecutionStatuses;
@ -74,3 +73,24 @@ export interface Alert {
}
export type SanitizedAlert = Omit<Alert, 'apiKey'>;
export enum HealthStatus {
OK = 'ok',
Warning = 'warn',
Error = 'error',
}
export interface AlertsHealth {
decryptionHealth: {
status: HealthStatus;
timestamp: string;
};
executionHealth: {
status: HealthStatus;
timestamp: string;
};
readHealth: {
status: HealthStatus;
timestamp: string;
};
}

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertsHealth } from './alert';
export * from './alert';
export * from './alert_type';
export * from './alert_instance';
@ -19,6 +21,7 @@ export interface ActionGroup {
export interface AlertingFrameworkHealth {
isSufficientlySecure: boolean;
hasPermanentEncryptionKey: boolean;
alertingFrameworkHeath: AlertsHealth;
}
export const BASE_ALERT_API_PATH = '/api/alerts';

View file

@ -0,0 +1,19 @@
/*
* 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 { configSchema } from './config';
describe('config validation', () => {
test('alerts defaults', () => {
const config: Record<string, unknown> = {};
expect(configSchema.validate(config)).toMatchInlineSnapshot(`
Object {
"healthCheck": Object {
"interval": "60m",
},
}
`);
});
});

View file

@ -0,0 +1,16 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { validateDurationSchema } from './lib';
export const configSchema = schema.object({
healthCheck: schema.object({
interval: schema.string({ validate: validateDurationSchema, defaultValue: '60m' }),
}),
});
export type AlertsConfig = TypeOf<typeof configSchema>;

View file

@ -0,0 +1,221 @@
/*
* 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 { savedObjectsRepositoryMock } from '../../../../../src/core/server/mocks';
import { AlertExecutionStatusErrorReasons, HealthStatus } from '../types';
import { getHealth } from './get_health';
const savedObjectsRepository = savedObjectsRepositoryMock.create();
describe('getHealth()', () => {
test('return true if some of alerts has a decryption error', async () => {
const lastExecutionDateError = new Date().toISOString();
const lastExecutionDate = new Date().toISOString();
savedObjectsRepository.find.mockResolvedValueOnce({
total: 1,
per_page: 1,
page: 1,
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
alertTypeId: 'myType',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
executionStatus: {
status: 'error',
lastExecutionDate: lastExecutionDateError,
error: {
reason: AlertExecutionStatusErrorReasons.Decrypt,
message: 'Failed decrypt',
},
},
},
score: 1,
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 0,
per_page: 10,
page: 1,
saved_objects: [],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 0,
per_page: 10,
page: 1,
saved_objects: [],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 1,
per_page: 1,
page: 1,
saved_objects: [
{
id: '2',
type: 'alert',
attributes: {
alertTypeId: 'myType',
schedule: { interval: '1s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
actions: [],
executionStatus: {
status: 'ok',
lastExecutionDate,
},
},
score: 1,
references: [],
},
],
});
const result = await getHealth(savedObjectsRepository);
expect(result).toStrictEqual({
executionHealth: {
status: HealthStatus.OK,
timestamp: lastExecutionDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: lastExecutionDate,
},
decryptionHealth: {
status: HealthStatus.Warning,
timestamp: lastExecutionDateError,
},
});
expect(savedObjectsRepository.find).toHaveBeenCalledTimes(4);
});
test('return false if no alerts with a decryption error', async () => {
const lastExecutionDateError = new Date().toISOString();
const lastExecutionDate = new Date().toISOString();
savedObjectsRepository.find.mockResolvedValueOnce({
total: 0,
per_page: 10,
page: 1,
saved_objects: [],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 1,
per_page: 1,
page: 1,
saved_objects: [
{
id: '1',
type: 'alert',
attributes: {
alertTypeId: 'myType',
schedule: { interval: '10s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
executionStatus: {
status: 'error',
lastExecutionDate: lastExecutionDateError,
error: {
reason: AlertExecutionStatusErrorReasons.Execute,
message: 'Failed',
},
},
},
score: 1,
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
},
],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 0,
per_page: 10,
page: 1,
saved_objects: [],
});
savedObjectsRepository.find.mockResolvedValueOnce({
total: 1,
per_page: 1,
page: 1,
saved_objects: [
{
id: '2',
type: 'alert',
attributes: {
alertTypeId: 'myType',
schedule: { interval: '1s' },
params: {
bar: true,
},
createdAt: new Date().toISOString(),
actions: [],
executionStatus: {
status: 'ok',
lastExecutionDate,
},
},
score: 1,
references: [],
},
],
});
const result = await getHealth(savedObjectsRepository);
expect(result).toStrictEqual({
executionHealth: {
status: HealthStatus.Warning,
timestamp: lastExecutionDateError,
},
readHealth: {
status: HealthStatus.OK,
timestamp: lastExecutionDate,
},
decryptionHealth: {
status: HealthStatus.OK,
timestamp: lastExecutionDate,
},
});
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 { ISavedObjectsRepository } from 'src/core/server';
import { AlertsHealth, HealthStatus, RawAlert, AlertExecutionStatusErrorReasons } from '../types';
export const getHealth = async (
internalSavedObjectsRepository: ISavedObjectsRepository
): Promise<AlertsHealth> => {
const healthStatuses = {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: '',
},
executionHealth: {
status: HealthStatus.OK,
timestamp: '',
},
readHealth: {
status: HealthStatus.OK,
timestamp: '',
},
};
const { saved_objects: decryptErrorData } = await internalSavedObjectsRepository.find<RawAlert>({
filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Decrypt}`,
fields: ['executionStatus'],
type: 'alert',
sortField: 'executionStatus.lastExecutionDate',
sortOrder: 'desc',
page: 1,
perPage: 1,
});
if (decryptErrorData.length > 0) {
healthStatuses.decryptionHealth = {
status: HealthStatus.Warning,
timestamp: decryptErrorData[0].attributes.executionStatus.lastExecutionDate,
};
}
const { saved_objects: executeErrorData } = await internalSavedObjectsRepository.find<RawAlert>({
filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Execute}`,
fields: ['executionStatus'],
type: 'alert',
sortField: 'executionStatus.lastExecutionDate',
sortOrder: 'desc',
page: 1,
perPage: 1,
});
if (executeErrorData.length > 0) {
healthStatuses.executionHealth = {
status: HealthStatus.Warning,
timestamp: executeErrorData[0].attributes.executionStatus.lastExecutionDate,
};
}
const { saved_objects: readErrorData } = await internalSavedObjectsRepository.find<RawAlert>({
filter: `alert.attributes.executionStatus.status:error and alert.attributes.executionStatus.error.reason:${AlertExecutionStatusErrorReasons.Read}`,
fields: ['executionStatus'],
type: 'alert',
sortField: 'executionStatus.lastExecutionDate',
sortOrder: 'desc',
page: 1,
perPage: 1,
});
if (readErrorData.length > 0) {
healthStatuses.readHealth = {
status: HealthStatus.Warning,
timestamp: readErrorData[0].attributes.executionStatus.lastExecutionDate,
};
}
const { saved_objects: noErrorData } = await internalSavedObjectsRepository.find<RawAlert>({
filter: 'not alert.attributes.executionStatus.status:error',
fields: ['executionStatus'],
type: 'alert',
sortField: 'executionStatus.lastExecutionDate',
sortOrder: 'desc',
});
const lastExecutionDate =
noErrorData.length > 0
? noErrorData[0].attributes.executionStatus.lastExecutionDate
: new Date().toISOString();
for (const [, statusItem] of Object.entries(healthStatuses)) {
if (statusItem.status === HealthStatus.OK) {
statusItem.timestamp = lastExecutionDate;
}
}
return healthStatuses;
};

View file

@ -0,0 +1,75 @@
/*
* 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 { taskManagerMock } from '../../../task_manager/server/mocks';
import { getHealthStatusStream } from '.';
import { TaskStatus } from '../../../task_manager/server';
import { HealthStatus } from '../types';
describe('getHealthStatusStream()', () => {
const mockTaskManager = taskManagerMock.createStart();
it('should return an object with the "unavailable" level and proper summary of "Alerting framework is unhealthy"', async () => {
mockTaskManager.get.mockReturnValue(
new Promise((_resolve, _reject) => {
return {
id: 'test',
attempts: 0,
status: TaskStatus.Running,
version: '123',
runAt: new Date(),
scheduledAt: new Date(),
startedAt: new Date(),
retryAt: new Date(Date.now() + 5 * 60 * 1000),
state: {
runs: 1,
health_status: HealthStatus.Warning,
},
taskType: 'alerting:alerting_health_check',
params: {
alertId: '1',
},
ownerId: null,
};
})
);
getHealthStatusStream(mockTaskManager).subscribe(
(val: { level: Readonly<unknown>; summary: string }) => {
expect(val.level).toBe(false);
}
);
});
it('should return an object with the "available" level and proper summary of "Alerting framework is healthy"', async () => {
mockTaskManager.get.mockReturnValue(
new Promise((_resolve, _reject) => {
return {
id: 'test',
attempts: 0,
status: TaskStatus.Running,
version: '123',
runAt: new Date(),
scheduledAt: new Date(),
startedAt: new Date(),
retryAt: new Date(Date.now() + 5 * 60 * 1000),
state: {
runs: 1,
health_status: HealthStatus.OK,
},
taskType: 'alerting:alerting_health_check',
params: {
alertId: '1',
},
ownerId: null,
};
})
);
getHealthStatusStream(mockTaskManager).subscribe(
(val: { level: Readonly<unknown>; summary: string }) => {
expect(val.level).toBe(true);
}
);
});
});

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { interval, Observable } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { ServiceStatus, ServiceStatusLevels } from '../../../../../src/core/server';
import { TaskManagerStartContract } from '../../../task_manager/server';
import { HEALTH_TASK_ID } from './task';
import { HealthStatus } from '../types';
async function getLatestTaskState(taskManager: TaskManagerStartContract) {
try {
const result = await taskManager.get(HEALTH_TASK_ID);
return result;
} catch (err) {
const errMessage = err && err.message ? err.message : err.toString();
if (!errMessage.includes('NotInitialized')) {
throw err;
}
}
return null;
}
const LEVEL_SUMMARY = {
[ServiceStatusLevels.available.toString()]: i18n.translate(
'xpack.alerts.server.healthStatus.available',
{
defaultMessage: 'Alerting framework is available',
}
),
[ServiceStatusLevels.degraded.toString()]: i18n.translate(
'xpack.alerts.server.healthStatus.degraded',
{
defaultMessage: 'Alerting framework is degraded',
}
),
[ServiceStatusLevels.unavailable.toString()]: i18n.translate(
'xpack.alerts.server.healthStatus.unavailable',
{
defaultMessage: 'Alerting framework is unavailable',
}
),
};
export const getHealthStatusStream = (
taskManager: TaskManagerStartContract
): Observable<ServiceStatus<unknown>> => {
return interval(60000 * 5).pipe(
switchMap(async () => {
const doc = await getLatestTaskState(taskManager);
const level =
doc?.state?.health_status === HealthStatus.OK
? ServiceStatusLevels.available
: doc?.state?.health_status === HealthStatus.Warning
? ServiceStatusLevels.degraded
: ServiceStatusLevels.unavailable;
return {
level,
summary: LEVEL_SUMMARY[level.toString()],
};
}),
catchError(async (error) => ({
level: ServiceStatusLevels.unavailable,
summary: LEVEL_SUMMARY[ServiceStatusLevels.unavailable.toString()],
meta: { error },
}))
);
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getHealthStatusStream } from './get_state';
export { scheduleAlertingHealthCheck, initializeAlertingHealth } from './task';

View file

@ -0,0 +1,94 @@
/*
* 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 { CoreStart, Logger } from 'kibana/server';
import {
RunContext,
TaskManagerSetupContract,
TaskManagerStartContract,
} from '../../../task_manager/server';
import { AlertsConfig } from '../config';
import { AlertingPluginsStart } from '../plugin';
import { HealthStatus } from '../types';
import { getHealth } from './get_health';
export const HEALTH_TASK_TYPE = 'alerting_health_check';
export const HEALTH_TASK_ID = `Alerting-${HEALTH_TASK_TYPE}`;
export function initializeAlertingHealth(
logger: Logger,
taskManager: TaskManagerSetupContract,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>
) {
registerAlertingHealthCheckTask(logger, taskManager, coreStartServices);
}
export async function scheduleAlertingHealthCheck(
logger: Logger,
config: Promise<AlertsConfig>,
taskManager: TaskManagerStartContract
) {
try {
const interval = (await config).healthCheck.interval;
await taskManager.ensureScheduled({
id: HEALTH_TASK_ID,
taskType: HEALTH_TASK_TYPE,
schedule: {
interval,
},
state: {},
params: {},
});
} catch (e) {
logger.debug(`Error scheduling task, received ${e.message}`);
}
}
function registerAlertingHealthCheckTask(
logger: Logger,
taskManager: TaskManagerSetupContract,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>
) {
taskManager.registerTaskDefinitions({
[HEALTH_TASK_TYPE]: {
title: 'Alerting framework health check task',
createTaskRunner: healthCheckTaskRunner(logger, coreStartServices),
},
});
}
export function healthCheckTaskRunner(
logger: Logger,
coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>
) {
return ({ taskInstance }: RunContext) => {
const { state } = taskInstance;
return {
async run() {
try {
const alertingHealthStatus = await getHealth(
(await coreStartServices)[0].savedObjects.createInternalRepository(['alert'])
);
return {
state: {
runs: (state.runs || 0) + 1,
health_status: alertingHealthStatus.decryptionHealth.status,
},
};
} catch (errMsg) {
logger.warn(`Error executing alerting health check task: ${errMsg}`);
return {
state: {
runs: (state.runs || 0) + 1,
health_status: HealthStatus.Error,
},
};
}
},
};
};
}

View file

@ -5,8 +5,10 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
import { AlertsClient as AlertsClientClass } from './alerts_client';
import { PluginInitializerContext } from '../../../../src/core/server';
import { PluginConfigDescriptor, PluginInitializerContext } from '../../../../src/core/server';
import { AlertingPlugin } from './plugin';
import { configSchema } from './config';
import { AlertsConfigType } from './types';
export type AlertsClient = PublicMethodsOf<AlertsClientClass>;
@ -30,3 +32,7 @@ export { AlertInstance } from './alert_instance';
export { parseDuration } from './lib';
export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext);
export const config: PluginConfigDescriptor<AlertsConfigType> = {
schema: configSchema,
};

View file

@ -57,7 +57,9 @@ describe('AlertExecutionStatus', () => {
});
test('error with a reason', () => {
const status = executionStatusFromError(new ErrorWithReason('execute', new Error('hoo!')));
const status = executionStatusFromError(
new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, new Error('hoo!'))
);
expect(status.status).toBe('error');
expect(status.error).toMatchInlineSnapshot(`
Object {
@ -71,7 +73,7 @@ describe('AlertExecutionStatus', () => {
describe('alertExecutionStatusToRaw()', () => {
const date = new Date('2020-09-03T16:26:58Z');
const status = 'ok';
const reason: AlertExecutionStatusErrorReasons = 'decrypt';
const reason = AlertExecutionStatusErrorReasons.Decrypt;
const error = { reason, message: 'wops' };
test('status without an error', () => {
@ -102,7 +104,7 @@ describe('AlertExecutionStatus', () => {
describe('alertExecutionStatusFromRaw()', () => {
const date = new Date('2020-09-03T16:26:58Z').toISOString();
const status = 'active';
const reason: AlertExecutionStatusErrorReasons = 'execute';
const reason = AlertExecutionStatusErrorReasons.Execute;
const error = { reason, message: 'wops' };
test('no input', () => {

View file

@ -5,20 +5,21 @@
*/
import { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason';
import { AlertExecutionStatusErrorReasons } from '../types';
describe('ErrorWithReason', () => {
const plainError = new Error('well, actually');
const errorWithReason = new ErrorWithReason('decrypt', plainError);
const errorWithReason = new ErrorWithReason(AlertExecutionStatusErrorReasons.Decrypt, plainError);
test('ErrorWithReason class', () => {
expect(errorWithReason.message).toBe(plainError.message);
expect(errorWithReason.error).toBe(plainError);
expect(errorWithReason.reason).toBe('decrypt');
expect(errorWithReason.reason).toBe(AlertExecutionStatusErrorReasons.Decrypt);
});
test('getReasonFromError()', () => {
expect(getReasonFromError(plainError)).toBe('unknown');
expect(getReasonFromError(errorWithReason)).toBe('decrypt');
expect(getReasonFromError(errorWithReason)).toBe(AlertExecutionStatusErrorReasons.Decrypt);
});
test('isErrorWithReason()', () => {

View file

@ -21,7 +21,7 @@ export function getReasonFromError(error: Error): AlertExecutionStatusErrorReaso
if (isErrorWithReason(error)) {
return error.reason;
}
return 'unknown';
return AlertExecutionStatusErrorReasons.Unknown;
}
export function isErrorWithReason(error: Error | ErrorWithReason): error is ErrorWithReason {

View file

@ -8,6 +8,7 @@ import { isAlertSavedObjectNotFoundError } from './is_alert_not_found_error';
import { ErrorWithReason } from './error_with_reason';
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
import uuid from 'uuid';
import { AlertExecutionStatusErrorReasons } from '../types';
describe('isAlertSavedObjectNotFoundError', () => {
const id = uuid.v4();
@ -25,7 +26,7 @@ describe('isAlertSavedObjectNotFoundError', () => {
});
test('identifies SavedObjects Not Found errors wrapped in an ErrorWithReason', () => {
const error = new ErrorWithReason('read', errorSONF);
const error = new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, errorSONF);
expect(isAlertSavedObjectNotFoundError(error, id)).toBe(true);
});
});

View file

@ -25,6 +25,7 @@ const createStartMock = () => {
const mock: jest.Mocked<PluginStartContract> = {
listTypes: jest.fn(),
getAlertsClientWithRequest: jest.fn().mockResolvedValue(alertsClientMock.create()),
getFrameworkHealth: jest.fn(),
};
return mock;
};

View file

@ -5,7 +5,7 @@
*/
import { AlertingPlugin, AlertingPluginsSetup, AlertingPluginsStart } from './plugin';
import { coreMock } from '../../../../src/core/server/mocks';
import { coreMock, statusServiceMock } from '../../../../src/core/server/mocks';
import { licensingMock } from '../../licensing/server/mocks';
import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks';
import { taskManagerMock } from '../../task_manager/server/mocks';
@ -13,15 +13,21 @@ import { eventLogServiceMock } from '../../event_log/server/event_log_service.mo
import { KibanaRequest, CoreSetup } from 'kibana/server';
import { featuresPluginMock } from '../../features/server/mocks';
import { KibanaFeature } from '../../features/server';
import { AlertsConfig } from './config';
describe('Alerting Plugin', () => {
describe('setup()', () => {
it('should log warning when Encrypted Saved Objects plugin is using an ephemeral encryption key', async () => {
const context = coreMock.createPluginInitializerContext();
const context = coreMock.createPluginInitializerContext<AlertsConfig>({
healthCheck: {
interval: '5m',
},
});
const plugin = new AlertingPlugin(context);
const coreSetup = coreMock.createSetup();
const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup();
const statusMock = statusServiceMock.createSetupContract();
await plugin.setup(
({
...coreSetup,
@ -29,6 +35,7 @@ describe('Alerting Plugin', () => {
...coreSetup.http,
route: jest.fn(),
},
status: statusMock,
} as unknown) as CoreSetup<AlertingPluginsStart, unknown>,
({
licensing: licensingMock.createSetup(),
@ -38,6 +45,7 @@ describe('Alerting Plugin', () => {
} as unknown) as AlertingPluginsSetup
);
expect(statusMock.set).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsSetup.usingEphemeralEncryptionKey).toEqual(true);
expect(context.logger.get().warn).toHaveBeenCalledWith(
'APIs are disabled due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml.'
@ -55,7 +63,11 @@ describe('Alerting Plugin', () => {
*/
describe('getAlertsClientWithRequest()', () => {
it('throws error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to true', async () => {
const context = coreMock.createPluginInitializerContext();
const context = coreMock.createPluginInitializerContext<AlertsConfig>({
healthCheck: {
interval: '5m',
},
});
const plugin = new AlertingPlugin(context);
const coreSetup = coreMock.createSetup();
@ -98,7 +110,11 @@ describe('Alerting Plugin', () => {
});
it(`doesn't throw error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to false`, async () => {
const context = coreMock.createPluginInitializerContext();
const context = coreMock.createPluginInitializerContext<AlertsConfig>({
healthCheck: {
interval: '5m',
},
});
const plugin = new AlertingPlugin(context);
const coreSetup = coreMock.createSetup();

View file

@ -6,6 +6,7 @@
import type { PublicMethodsOf } from '@kbn/utility-types';
import { first, map } from 'rxjs/operators';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { combineLatest } from 'rxjs';
import { SecurityPluginSetup } from '../../security/server';
import {
EncryptedSavedObjectsPluginSetup,
@ -30,6 +31,8 @@ import {
SharedGlobalConfig,
ElasticsearchServiceStart,
ILegacyClusterClient,
StatusServiceSetup,
ServiceStatus,
} from '../../../../src/core/server';
import {
@ -56,12 +59,19 @@ import {
PluginSetupContract as ActionsPluginSetupContract,
PluginStartContract as ActionsPluginStartContract,
} from '../../actions/server';
import { Services } from './types';
import { AlertsHealth, Services } from './types';
import { registerAlertsUsageCollector } from './usage';
import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task';
import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server';
import { PluginStartContract as FeaturesPluginStart } from '../../features/server';
import { setupSavedObjects } from './saved_objects';
import {
getHealthStatusStream,
scheduleAlertingHealthCheck,
initializeAlertingHealth,
} from './health';
import { AlertsConfig } from './config';
import { getHealth } from './health/get_health';
export const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = {
@ -78,6 +88,7 @@ export interface PluginSetupContract {
export interface PluginStartContract {
listTypes: AlertTypeRegistry['list'];
getAlertsClientWithRequest(request: KibanaRequest): PublicMethodsOf<AlertsClient>;
getFrameworkHealth: () => Promise<AlertsHealth>;
}
export interface AlertingPluginsSetup {
@ -89,6 +100,7 @@ export interface AlertingPluginsSetup {
spaces?: SpacesPluginSetup;
usageCollection?: UsageCollectionSetup;
eventLog: IEventLogService;
statusService: StatusServiceSetup;
}
export interface AlertingPluginsStart {
actions: ActionsPluginStartContract;
@ -99,6 +111,7 @@ export interface AlertingPluginsStart {
}
export class AlertingPlugin {
private readonly config: Promise<AlertsConfig>;
private readonly logger: Logger;
private alertTypeRegistry?: AlertTypeRegistry;
private readonly taskRunnerFactory: TaskRunnerFactory;
@ -115,6 +128,7 @@ export class AlertingPlugin {
private eventLogger?: IEventLogger;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.create<AlertsConfig>().pipe(first()).toPromise();
this.logger = initializerContext.logger.get('plugins', 'alerting');
this.taskRunnerFactory = new TaskRunnerFactory();
this.alertsClientFactory = new AlertsClientFactory();
@ -186,6 +200,25 @@ export class AlertingPlugin {
});
}
core.getStartServices().then(async ([, startPlugins]) => {
core.status.set(
combineLatest([
core.status.derivedStatus$,
getHealthStatusStream(startPlugins.taskManager),
]).pipe(
map(([derivedStatus, healthStatus]) => {
if (healthStatus.level > derivedStatus.level) {
return healthStatus as ServiceStatus;
} else {
return derivedStatus;
}
})
)
);
});
initializeAlertingHealth(this.logger, plugins.taskManager, core.getStartServices());
core.http.registerRouteHandlerContext('alerting', this.createRouteHandlerContext(core));
// Routes
@ -275,10 +308,13 @@ export class AlertingPlugin {
});
scheduleAlertingTelemetry(this.telemetryLogger, plugins.taskManager);
scheduleAlertingHealthCheck(this.logger, this.config, plugins.taskManager);
return {
listTypes: alertTypeRegistry!.list.bind(this.alertTypeRegistry!),
getAlertsClientWithRequest,
getFrameworkHealth: async () =>
await getHealth(core.savedObjects.createInternalRepository(['alert'])),
};
}
@ -293,6 +329,8 @@ export class AlertingPlugin {
return alertsClientFactory!.create(request, savedObjects);
},
listTypes: alertTypeRegistry!.list.bind(alertTypeRegistry!),
getFrameworkHealth: async () =>
await getHealth(savedObjects.createInternalRepository(['alert'])),
};
};
};

View file

@ -14,7 +14,7 @@ import { identity } from 'lodash';
import type { MethodKeysOf } from '@kbn/utility-types';
import { httpServerMock } from '../../../../../src/core/server/mocks';
import { alertsClientMock, AlertsClientMock } from '../alerts_client.mock';
import { AlertType } from '../../common';
import { AlertsHealth, AlertType } from '../../common';
import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
export function mockHandlerArguments(
@ -22,10 +22,13 @@ export function mockHandlerArguments(
alertsClient = alertsClientMock.create(),
listTypes: listTypesRes = [],
esClient = elasticsearchServiceMock.createLegacyClusterClient(),
getFrameworkHealth,
}: {
alertsClient?: AlertsClientMock;
listTypes?: AlertType[];
esClient?: jest.Mocked<ILegacyClusterClient>;
getFrameworkHealth?: jest.MockInstance<Promise<AlertsHealth>, []> &
(() => Promise<AlertsHealth>);
},
req: unknown,
res?: Array<MethodKeysOf<KibanaResponseFactory>>
@ -39,6 +42,7 @@ export function mockHandlerArguments(
getAlertsClient() {
return alertsClient || alertsClientMock.create();
},
getFrameworkHealth,
},
} as unknown) as RequestHandlerContext,
req as KibanaRequest<unknown, unknown, unknown>,

View file

@ -11,13 +11,34 @@ import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockLicenseState } from '../lib/license_state.mock';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
import { alertsClientMock } from '../alerts_client.mock';
import { HealthStatus } from '../types';
import { alertsMock } from '../mocks';
const alertsClient = alertsClientMock.create();
jest.mock('../lib/license_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
const alerting = alertsMock.createStart();
const currentDate = new Date().toISOString();
beforeEach(() => {
jest.resetAllMocks();
alerting.getFrameworkHealth.mockResolvedValue({
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
});
});
describe('healthRoute', () => {
@ -46,7 +67,7 @@ describe('healthRoute', () => {
const esClient = elasticsearchServiceMock.createLegacyClusterClient();
esClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments({ esClient, alertsClient }, {}, ['ok']);
await handler(context, req, res);
@ -75,16 +96,32 @@ describe('healthRoute', () => {
const esClient = elasticsearchServiceMock.createLegacyClusterClient();
esClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": false,
"isSufficientlySecure": true,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: false,
isSufficientlySecure: true,
},
});
});
it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
@ -99,16 +136,32 @@ describe('healthRoute', () => {
const esClient = elasticsearchServiceMock.createLegacyClusterClient();
esClient.callAsInternalUser.mockReturnValue(Promise.resolve({}));
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: true,
isSufficientlySecure: true,
},
});
});
it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => {
@ -123,16 +176,32 @@ describe('healthRoute', () => {
const esClient = elasticsearchServiceMock.createLegacyClusterClient();
esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} }));
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: true,
isSufficientlySecure: true,
},
});
});
it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => {
@ -147,16 +216,32 @@ describe('healthRoute', () => {
const esClient = elasticsearchServiceMock.createLegacyClusterClient();
esClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: { enabled: true } }));
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": false,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: true,
isSufficientlySecure: false,
},
});
});
it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => {
@ -173,16 +258,32 @@ describe('healthRoute', () => {
Promise.resolve({ security: { enabled: true, ssl: {} } })
);
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": false,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: true,
isSufficientlySecure: false,
},
});
});
it('evaluates security and tls enabled to mean that the user can generate keys', async () => {
@ -199,15 +300,31 @@ describe('healthRoute', () => {
Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } })
);
const [context, req, res] = mockHandlerArguments({ esClient }, {}, ['ok']);
const [context, req, res] = mockHandlerArguments(
{ esClient, alertsClient, getFrameworkHealth: alerting.getFrameworkHealth },
{},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"hasPermanentEncryptionKey": true,
"isSufficientlySecure": true,
expect(await handler(context, req, res)).toStrictEqual({
body: {
alertingFrameworkHeath: {
decryptionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
executionHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
readHealth: {
status: HealthStatus.OK,
timestamp: currentDate,
},
},
}
`);
hasPermanentEncryptionKey: true,
isSufficientlySecure: true,
},
});
});
});

View file

@ -43,6 +43,9 @@ export function healthRoute(
res: KibanaResponseFactory
): Promise<IKibanaResponse> {
verifyApiAccess(licenseState);
if (!context.alerting) {
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
try {
const {
security: {
@ -57,9 +60,12 @@ export function healthRoute(
path: '/_xpack/usage',
});
const alertingFrameworkHeath = await context.alerting.getFrameworkHealth();
const frameworkHealth: AlertingFrameworkHealth = {
isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled),
hasPermanentEncryptionKey: !encryptedSavedObjects.usingEphemeralEncryptionKey,
alertingFrameworkHeath,
};
return res.ok({

View file

@ -28,6 +28,7 @@ import {
AlertExecutorOptions,
SanitizedAlert,
AlertExecutionStatus,
AlertExecutionStatusErrorReasons,
} from '../types';
import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type';
import { taskInstanceToAlertTaskInstance } from './alert_task_instance';
@ -211,7 +212,7 @@ export class TaskRunner {
event.event = event.event || {};
event.event.outcome = 'failure';
eventLogger.logEvent(event);
throw new ErrorWithReason('execute', err);
throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err);
}
eventLogger.stopTiming(event);
@ -288,7 +289,7 @@ export class TaskRunner {
try {
apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId);
} catch (err) {
throw new ErrorWithReason('decrypt', err);
throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Decrypt, err);
}
const [services, alertsClient] = this.getServicesWithSpaceLevelPermissions(spaceId, apiKey);
@ -298,7 +299,7 @@ export class TaskRunner {
try {
alert = await alertsClient.get({ id: alertId });
} catch (err) {
throw new ErrorWithReason('read', err);
throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Read, err);
}
return {

View file

@ -27,6 +27,7 @@ import {
AlertInstanceState,
AlertExecutionStatuses,
AlertExecutionStatusErrorReasons,
AlertsHealth,
} from '../common';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
@ -39,6 +40,7 @@ declare module 'src/core/server' {
alerting?: {
getAlertsClient: () => AlertsClient;
listTypes: AlertTypeRegistry['list'];
getFrameworkHealth: () => Promise<AlertsHealth>;
};
}
}
@ -172,4 +174,10 @@ export interface AlertingPlugin {
start: PluginStartContract;
}
export interface AlertsConfigType {
healthCheck: {
interval: string;
};
}
export type AlertTypeRegistry = PublicMethodsOf<OrigAlertTypeRegistry>;

View file

@ -9,6 +9,7 @@ import { getFindResultStatus, ruleStatusRequest, getResult } from '../__mocks__/
import { serverMock, requestContextMock, requestMock } from '../__mocks__';
import { findRulesStatusesRoute } from './find_rules_status_route';
import { RuleStatusResponse } from '../../rules/types';
import { AlertExecutionStatusErrorReasons } from '../../../../../../alerts/common';
jest.mock('../../signals/rule_status_service');
@ -57,7 +58,7 @@ describe('find_statuses', () => {
status: 'error',
lastExecutionDate: failingExecutionRule.executionStatus.lastExecutionDate,
error: {
reason: 'read',
reason: AlertExecutionStatusErrorReasons.Read,
message: 'oops',
},
};

View file

@ -27,6 +27,7 @@ import {
import { responseMock } from './__mocks__';
import { exampleRuleStatus, exampleFindRuleStatusResponse } from '../signals/__mocks__/es_results';
import { getResult } from './__mocks__/request_responses';
import { AlertExecutionStatusErrorReasons } from '../../../../../alerts/common';
let alertsClient: ReturnType<typeof alertsClientMock.create>;
@ -464,7 +465,7 @@ describe('utils', () => {
status: 'error',
lastExecutionDate: foundRule.executionStatus.lastExecutionDate,
error: {
reason: 'read',
reason: AlertExecutionStatusErrorReasons.Read,
message: 'oops',
},
};

View file

@ -11,7 +11,10 @@ import { Alert, ActionType, ValidationResult } from '../../../../types';
import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiButtonEmpty, EuiText } from '@elastic/eui';
import { ViewInApp } from './view_in_app';
import { coreMock } from 'src/core/public/mocks';
import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common';
import {
AlertExecutionStatusErrorReasons,
ALERTS_FEATURE_ID,
} from '../../../../../../alerts/common';
const mockes = coreMock.createSetup();
@ -125,7 +128,7 @@ describe('alert_details', () => {
status: 'error',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: 'unknown',
reason: AlertExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},

View file

@ -17,7 +17,10 @@ import { AppContextProvider } from '../../../app_context';
import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { alertingPluginMock } from '../../../../../../alerts/public/mocks';
import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common';
import {
AlertExecutionStatusErrorReasons,
ALERTS_FEATURE_ID,
} from '../../../../../../alerts/common';
import { featuresPluginMock } from '../../../../../../features/public/mocks';
jest.mock('../../../lib/action_connector_api', () => ({
@ -245,7 +248,7 @@ describe('alerts_list component with items', () => {
status: 'error',
lastExecutionDate: new Date('2020-08-20T19:23:38Z'),
error: {
reason: 'unknown',
reason: AlertExecutionStatusErrorReasons.Unknown,
message: 'test',
},
},

View file

@ -5,6 +5,7 @@
*/
import expect from '@kbn/expect';
import { AlertExecutionStatusErrorReasons } from '../../../../../plugins/alerts/common';
import { Spaces } from '../../scenarios';
import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
@ -49,7 +50,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
executionStatus = await waitForStatus(alertId, new Set(['error']));
expect(executionStatus.error).to.be.ok();
expect(executionStatus.error.reason).to.be('decrypt');
expect(executionStatus.error.reason).to.be(AlertExecutionStatusErrorReasons.Decrypt);
expect(executionStatus.error.message).to.be('Unable to decrypt attribute "apiKey"');
});
});