From 743c8c2a14932865ad8bbbf0fd14c9677d986c56 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Fri, 16 Jul 2021 15:34:15 -0400 Subject: [PATCH] [Uptime] migrate to observability rules registry (#100699) * uptime - migrate to observability rules registry * Modify Uptime alert types to work with server rule registry. * Export `RuleType` type for consumption by client plugins. * Add platinum as an option for `minimumLicenseRequired` field of `RuleTypeBase`. * Simplify alert bootstrapping, inherit `RuleType` for alert factories. * update rule field map * adjust rule registery to be created within setup instead of mount * adjust plugin setup to account for rule registry changes * export types from rule registry * move alert action message translations to common * update rule field map * update monitor status public alert model * update tls public alert model * update monitor status alert server model * update tls alert sever model * update server plugin file to scope alerts indices to synthetics * add initContext to server Plugin class * adjust public plugin to register alerts when core start is availabile * update mappings * update asset names * adjust dependencies for alert initialization * adjust duration anomaly server alert model * adjust duration anomaly and monitor status public alert model to account for undefined types * add duration_anomaly tests * add anomaly severity * adjust types * update uptime server plugin * remove test_helpers * add getMonitorRouteFromMonitorId helper * export AlertTypeWithExecutor from rule_registry * adjust types * mock time zone * update types * update types for legacy tls alert * update mappings * update monitor status check tls types * update tls types and indexed fields * update duration anomaly types and indexed fields * update mappings * delete unnecessary file * adjust types * adjust ruleDataClient initialization * index anomaly bucket span * update types * adjust registration of legacy tls alert type * adjust types * update index alias name * update anomaly detection rule mappings * adjust import for certificate alert * adjust rbac settings * adjust content * adjust uptime server plugin Co-authored-by: Justin Kambic Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/rule_registry/server/index.ts | 6 +- .../create_lifecycle_rule_type_factory.ts | 7 +- .../rule_registry/server/utils/rbac.ts | 1 + .../common/rules/uptime_rule_field_map.ts | 60 ++ x-pack/plugins/uptime/common/translations.ts | 75 ++ x-pack/plugins/uptime/kibana.json | 25 +- x-pack/plugins/uptime/public/apps/plugin.ts | 47 +- .../uptime/public/lib/alert_types/common.ts | 40 + .../lib/alert_types/duration_anomaly.tsx | 20 +- .../uptime/public/lib/alert_types/index.ts | 10 +- .../lib/alert_types/monitor_status.test.ts | 1 + .../public/lib/alert_types/monitor_status.tsx | 26 +- .../uptime/public/lib/alert_types/tls.tsx | 15 +- .../public/lib/alert_types/tls_legacy.tsx | 4 +- .../public/lib/alert_types/translations.ts | 83 -- x-pack/plugins/uptime/server/kibana.index.ts | 11 +- .../lib/adapters/framework/adapter_types.ts | 4 + .../uptime/server/lib/alerts/common.ts | 5 + .../lib/alerts/duration_anomaly.test.ts | 210 ++++ .../server/lib/alerts/duration_anomaly.ts | 137 +-- .../server/lib/alerts/status_check.test.ts | 900 +++++++++--------- .../uptime/server/lib/alerts/status_check.ts | 404 ++++---- .../server/lib/alerts/test_utils/index.ts | 81 ++ .../uptime/server/lib/alerts/tls.test.ts | 228 ++++- .../plugins/uptime/server/lib/alerts/tls.ts | 162 ++-- .../uptime/server/lib/alerts/tls_legacy.ts | 147 +-- .../plugins/uptime/server/lib/alerts/types.ts | 30 +- .../server/lib/alerts/uptime_alert_wrapper.ts | 68 -- x-pack/plugins/uptime/server/plugin.ts | 73 +- x-pack/plugins/uptime/server/uptime_server.ts | 36 +- 30 files changed, 1813 insertions(+), 1103 deletions(-) create mode 100644 x-pack/plugins/uptime/common/rules/uptime_rule_field_map.ts create mode 100644 x-pack/plugins/uptime/public/lib/alert_types/common.ts delete mode 100644 x-pack/plugins/uptime/public/lib/alert_types/translations.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts create mode 100644 x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts delete mode 100644 x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 19ea85b056be..6b0765e71cba 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -14,13 +14,17 @@ export type { RacRequestHandlerContext, RacApiRequestHandlerContext } from './ty export { RuleDataClient } from './rule_data_client'; export { IRuleDataClient } from './rule_data_client/types'; export { getRuleExecutorData, RuleExecutorData } from './utils/get_rule_executor_data'; -export { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory'; +export { + createLifecycleRuleTypeFactory, + LifecycleAlertService, +} from './utils/create_lifecycle_rule_type_factory'; export { LifecycleRuleExecutor, LifecycleAlertServices, createLifecycleExecutor, } from './utils/create_lifecycle_executor'; export { createPersistenceRuleTypeFactory } from './utils/create_persistence_rule_type_factory'; +export type { AlertTypeWithExecutor } from './types'; export const plugin = (initContext: PluginInitializerContext) => new RuleRegistryPlugin(initContext); diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index cf1be1bd3201..783077a1f68a 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -16,10 +16,13 @@ import { AlertInstance } from '../../../alerting/server'; import { AlertTypeWithExecutor } from '../types'; import { createLifecycleExecutor } from './create_lifecycle_executor'; -export type LifecycleAlertService> = (alert: { +export type LifecycleAlertService< + TAlertInstanceContext extends Record, + TActionGroupIds extends string = string +> = (alert: { id: string; fields: Record; -}) => AlertInstance; +}) => AlertInstance; export const createLifecycleRuleTypeFactory = ({ logger, diff --git a/x-pack/plugins/rule_registry/server/utils/rbac.ts b/x-pack/plugins/rule_registry/server/utils/rbac.ts index e07c4394be2f..172201400606 100644 --- a/x-pack/plugins/rule_registry/server/utils/rbac.ts +++ b/x-pack/plugins/rule_registry/server/utils/rbac.ts @@ -19,6 +19,7 @@ export const mapConsumerToIndexName = { infrastructure: '.alerts-observability.metrics', observability: '.alerts-observability', siem: ['.alerts-security.alerts', '.siem-signals'], + synthetics: '.alerts-observability-synthetics', }; export type ValidFeatureId = keyof typeof mapConsumerToIndexName; diff --git a/x-pack/plugins/uptime/common/rules/uptime_rule_field_map.ts b/x-pack/plugins/uptime/common/rules/uptime_rule_field_map.ts new file mode 100644 index 000000000000..2a4e6ddcf58f --- /dev/null +++ b/x-pack/plugins/uptime/common/rules/uptime_rule_field_map.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const uptimeRuleFieldMap = { + // common fields + 'monitor.id': { + type: 'keyword', + }, + 'url.full': { + type: 'keyword', + }, + 'observer.geo.name': { + type: 'keyword', + }, + reason: { + type: 'text', + }, + // monitor status alert fields + 'error.message': { + type: 'text', + }, + 'agent.name': { + type: 'keyword', + }, + 'monitor.name': { + type: 'keyword', + }, + 'monitor.type': { + type: 'keyword', + }, + // tls alert fields + 'tls.server.x509.issuer.common_name': { + type: 'keyword', + }, + 'tls.server.x509.subject.common_name': { + type: 'keyword', + }, + 'tls.server.x509.not_after': { + type: 'date', + }, + 'tls.server.x509.not_before': { + type: 'date', + }, + 'tls.server.hash.sha256': { + type: 'keyword', + }, + // anomaly alert fields + 'anomaly.start': { + type: 'date', + }, + 'anomaly.bucket_span.minutes': { + type: 'keyword', + }, +} as const; + +export type UptimeRuleFieldMap = typeof uptimeRuleFieldMap; diff --git a/x-pack/plugins/uptime/common/translations.ts b/x-pack/plugins/uptime/common/translations.ts index 353c7fb5325f..5211438b8ec4 100644 --- a/x-pack/plugins/uptime/common/translations.ts +++ b/x-pack/plugins/uptime/common/translations.ts @@ -37,3 +37,78 @@ export const MonitorStatusTranslations = { defaultMessage: 'Alert when a monitor is down or an availability threshold is breached.', }), }; + +export const TlsTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.legacy.defaultActionMessage', { + defaultMessage: `Detected TLS certificate {commonName} from issuer {issuer} is {status}. Certificate {summary} +`, + values: { + commonName: '{{state.commonName}}', + issuer: '{{state.issuer}}', + summary: '{{state.summary}}', + status: '{{state.status}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.tls.legacy.clientName', { + defaultMessage: 'Uptime TLS (Legacy)', + }), + description: i18n.translate('xpack.uptime.alerts.tls.legacy.description', { + defaultMessage: + 'Alert when the TLS certificate of an Uptime monitor is about to expire. This alert will be deprecated in a future version.', + }), +}; + +export const TlsTranslationsLegacy = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.defaultActionMessage', { + defaultMessage: `Detected {count} TLS certificates expiring or becoming too old. +{expiringConditionalOpen} +Expiring cert count: {expiringCount} +Expiring Certificates: {expiringCommonNameAndDate} +{expiringConditionalClose} +{agingConditionalOpen} +Aging cert count: {agingCount} +Aging Certificates: {agingCommonNameAndDate} +{agingConditionalClose} +`, + values: { + count: '{{state.count}}', + expiringCount: '{{state.expiringCount}}', + expiringCommonNameAndDate: '{{state.expiringCommonNameAndDate}}', + expiringConditionalOpen: '{{#state.hasExpired}}', + expiringConditionalClose: '{{/state.hasExpired}}', + agingCount: '{{state.agingCount}}', + agingCommonNameAndDate: '{{state.agingCommonNameAndDate}}', + agingConditionalOpen: '{{#state.hasAging}}', + agingConditionalClose: '{{/state.hasAging}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.tls.clientName', { + defaultMessage: 'Uptime TLS', + }), + description: i18n.translate('xpack.uptime.alerts.tls.description', { + defaultMessage: 'Alert when the TLS certificate of an Uptime monitor is about to expire.', + }), +}; + +export const DurationAnomalyTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.durationAnomaly.defaultActionMessage', { + defaultMessage: `Abnormal ({severity} level) response time detected on {monitor} with url {monitorUrl} at {anomalyStartTimestamp}. Anomaly severity score is {severityScore}. +Response times as high as {slowestAnomalyResponse} have been detected from location {observerLocation}. Expected response time is {expectedResponseTime}.`, + values: { + severity: '{{state.severity}}', + anomalyStartTimestamp: '{{state.anomalyStartTimestamp}}', + monitor: '{{state.monitor}}', + monitorUrl: '{{{state.monitorUrl}}}', + slowestAnomalyResponse: '{{state.slowestAnomalyResponse}}', + expectedResponseTime: '{{state.expectedResponseTime}}', + severityScore: '{{state.severityScore}}', + observerLocation: '{{state.observerLocation}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.durationAnomaly.clientName', { + defaultMessage: 'Uptime Duration Anomaly', + }), + description: i18n.translate('xpack.uptime.alerts.durationAnomaly.description', { + defaultMessage: 'Alert when the Uptime monitor duration is anaomalous.', + }), +}; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 97d154843231..e7fcb4607a8e 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -1,8 +1,16 @@ { - "configPath": ["xpack", "uptime"], + "configPath": [ + "xpack", + "uptime" + ], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["data", "home", "ml", "fleet"], + "optionalPlugins": [ + "data", + "home", + "ml", + "fleet" + ], "requiredPlugins": [ "alerting", "embeddable", @@ -10,15 +18,24 @@ "licensing", "triggersActionsUi", "usageCollection", + "ruleRegistry", "observability" ], "server": true, "ui": true, "version": "8.0.0", - "requiredBundles": ["observability", "kibanaReact", "kibanaUtils", "home", "data", "ml", "fleet"], + "requiredBundles": [ + "observability", + "kibanaReact", + "kibanaUtils", + "home", + "data", + "ml", + "fleet" + ], "owner": { "name": "Uptime", "githubTeam": "uptime" }, "description": "This plugin visualizes data from Synthetics and Heartbeat, and integrates with other Observability solutions." -} +} \ No newline at end of file diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 869ecda3d29c..45067c6018cc 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { CoreSetup, CoreStart, @@ -29,7 +28,7 @@ import { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../../src/plugins/data/public'; -import { alertTypeInitializers } from '../lib/alert_types'; +import { alertTypeInitializers, legacyAlertTypeInitializers } from '../lib/alert_types'; import { FleetStart } from '../../../fleet/public'; import { FetchDataParams, @@ -141,6 +140,36 @@ export class UptimePlugin ) ); + const { observabilityRuleTypeRegistry } = plugins.observability; + + core.getStartServices().then(([coreStart, clientPluginsStart]) => { + alertTypeInitializers.forEach((init) => { + const alertInitializer = init({ + core: coreStart, + plugins: clientPluginsStart, + }); + if ( + clientPluginsStart.triggersActionsUi && + !clientPluginsStart.triggersActionsUi.alertTypeRegistry.has(alertInitializer.id) + ) { + observabilityRuleTypeRegistry.register(alertInitializer); + } + }); + + legacyAlertTypeInitializers.forEach((init) => { + const alertInitializer = init({ + core: coreStart, + plugins: clientPluginsStart, + }); + if ( + clientPluginsStart.triggersActionsUi && + !clientPluginsStart.triggersActionsUi.alertTypeRegistry.has(alertInitializer.id) + ) { + plugins.triggersActionsUi.alertTypeRegistry.register(alertInitializer); + } + }); + }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'logoObservability', @@ -171,26 +200,12 @@ export class UptimePlugin const [coreStart, corePlugins] = await core.getStartServices(); const { renderApp } = await import('./render_app'); - return renderApp(coreStart, plugins, corePlugins, params); }, }); } public start(start: CoreStart, plugins: ClientPluginsStart): void { - alertTypeInitializers.forEach((init) => { - const alertInitializer = init({ - core: start, - plugins, - }); - if ( - plugins.triggersActionsUi && - !plugins.triggersActionsUi.alertTypeRegistry.has(alertInitializer.id) - ) { - plugins.triggersActionsUi.alertTypeRegistry.register(alertInitializer); - } - }); - if (plugins.fleet) { const { registerExtension } = plugins.fleet; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/common.ts b/x-pack/plugins/uptime/public/lib/alert_types/common.ts new file mode 100644 index 000000000000..09b02150957d --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/alert_types/common.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { stringify } from 'querystring'; + +export const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; + +export const getMonitorRouteFromMonitorId = ({ + monitorId, + dateRangeStart, + dateRangeEnd, + filters = {}, +}: { + monitorId: string; + dateRangeStart: string; + dateRangeEnd: string; + filters?: Record; +}) => + format({ + pathname: `/app/uptime/monitor/${btoa(monitorId)}`, + query: { + dateRangeEnd, + dateRangeStart, + ...(Object.keys(filters).length + ? { filters: JSON.stringify(Object.keys(filters).map((key) => [key, filters[key]])) } + : {}), + }, + }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx index a80e38ac622a..f14c1a4a9fdd 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx @@ -6,18 +6,23 @@ */ import React from 'react'; -import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; +import moment from 'moment'; + import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; -import { DurationAnomalyTranslations } from './translations'; +import { DurationAnomalyTranslations } from '../../../common/translations'; import { AlertTypeInitializer } from '.'; +import { getMonitorRouteFromMonitorId } from './common'; + +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; + const { defaultActionMessage, description } = DurationAnomalyTranslations; const DurationAnomalyAlert = React.lazy(() => import('./lazy_wrapper/duration_anomaly')); export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ core, plugins, -}): AlertTypeModel => ({ +}): ObservabilityRuleTypeModel => ({ id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, iconClass: 'uptimeApp', documentationUrl(docLinks) { @@ -30,4 +35,13 @@ export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ validate: () => ({ errors: {} }), defaultActionMessage, requiresAppContext: true, + format: ({ fields }) => ({ + reason: fields.reason, + link: getMonitorRouteFromMonitorId({ + monitorId: fields['monitor.id']!, + dateRangeEnd: + fields['kibana.rac.alert.status'] === 'open' ? 'now' : fields['kibana.rac.alert.end']!, + dateRangeStart: moment(new Date(fields['anomaly.start']!)).subtract('5', 'm').toISOString(), + }), + }), }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/plugins/uptime/public/lib/alert_types/index.ts index 406b730fa1e6..9dc67340a043 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/index.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/index.ts @@ -6,6 +6,7 @@ */ import { CoreStart } from 'kibana/public'; +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { initMonitorStatusAlertType } from './monitor_status'; import { initTlsAlertType } from './tls'; @@ -13,14 +14,17 @@ import { initTlsLegacyAlertType } from './tls_legacy'; import { ClientPluginsStart } from '../../apps/plugin'; import { initDurationAnomalyAlertType } from './duration_anomaly'; -export type AlertTypeInitializer = (dependenies: { +export type AlertTypeInitializer = (dependenies: { core: CoreStart; plugins: ClientPluginsStart; -}) => AlertTypeModel; +}) => TAlertTypeModel; export const alertTypeInitializers: AlertTypeInitializer[] = [ initMonitorStatusAlertType, initTlsAlertType, - initTlsLegacyAlertType, initDurationAnomalyAlertType, ]; + +export const legacyAlertTypeInitializers: Array> = [ + initTlsLegacyAlertType, +]; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts index fc19d4c60e17..16c20cf7666e 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.test.ts @@ -206,6 +206,7 @@ describe('monitor status alert type', () => { "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}", "description": "Alert when a monitor is down or an availability threshold is breached.", "documentationUrl": [Function], + "format": [Function], "iconClass": "uptimeApp", "id": "xpack.uptime.alerts.monitorStatus", "requiresAppContext": false, diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx index 50db7d9b5b5a..a87ba4aedbb2 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -6,12 +6,18 @@ */ import React from 'react'; -import { AlertTypeModel, ValidationResult } from '../../../../triggers_actions_ui/public'; -import { AlertTypeInitializer } from '.'; +import moment from 'moment'; + +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; +import { ValidationResult } from '../../../../triggers_actions_ui/public'; import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; import { MonitorStatusTranslations } from '../../../common/translations'; +import { getMonitorRouteFromMonitorId } from './common'; + +import { AlertTypeInitializer } from '.'; + const { defaultActionMessage, description } = MonitorStatusTranslations; const MonitorStatusAlert = React.lazy(() => import('./lazy_wrapper/monitor_status')); @@ -21,7 +27,7 @@ let validateFunc: (alertParams: any) => ValidationResult; export const initMonitorStatusAlertType: AlertTypeInitializer = ({ core, plugins, -}): AlertTypeModel => ({ +}): ObservabilityRuleTypeModel => ({ id: CLIENT_ALERT_TYPES.MONITOR_STATUS, description, iconClass: 'uptimeApp', @@ -44,4 +50,18 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({ }, defaultActionMessage, requiresAppContext: false, + format: ({ fields }) => ({ + reason: fields.reason, + link: getMonitorRouteFromMonitorId({ + monitorId: fields['monitor.id']!, + dateRangeEnd: + fields['kibana.rac.alert.status'] === 'open' ? 'now' : fields['kibana.rac.alert.end']!, + dateRangeStart: moment(new Date(fields['kibana.rac.alert.start']!)) + .subtract('5', 'm') + .toISOString(), + filters: { + 'observer.geo.name': [fields['observer.geo.name'][0]], + }, + }), + }), }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx index c3bcfc46646d..6632a0c04396 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx @@ -6,14 +6,19 @@ */ import React from 'react'; -import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; -import { TlsTranslations } from './translations'; +import { TlsTranslations } from '../../../common/translations'; import { AlertTypeInitializer } from '.'; +import { CERTIFICATES_ROUTE } from '../../../common/constants/ui'; + const { defaultActionMessage, description } = TlsTranslations; const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); -export const initTlsAlertType: AlertTypeInitializer = ({ core, plugins }): AlertTypeModel => ({ +export const initTlsAlertType: AlertTypeInitializer = ({ + core, + plugins, +}): ObservabilityRuleTypeModel => ({ id: CLIENT_ALERT_TYPES.TLS, iconClass: 'uptimeApp', documentationUrl(docLinks) { @@ -26,4 +31,8 @@ export const initTlsAlertType: AlertTypeInitializer = ({ core, plugins }): Alert validate: () => ({ errors: {} }), defaultActionMessage, requiresAppContext: false, + format: ({ fields }) => ({ + reason: fields.reason, + link: `/app/uptime${CERTIFICATES_ROUTE}`, + }), }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/tls_legacy.tsx b/x-pack/plugins/uptime/public/lib/alert_types/tls_legacy.tsx index 9982eb385d90..bed5dae55571 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/tls_legacy.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/tls_legacy.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; -import { TlsTranslationsLegacy } from './translations'; +import { TlsTranslationsLegacy } from '../../../common/translations'; import { AlertTypeInitializer } from '.'; const { defaultActionMessage, description } = TlsTranslationsLegacy; const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); -export const initTlsLegacyAlertType: AlertTypeInitializer = ({ +export const initTlsLegacyAlertType: AlertTypeInitializer = ({ core, plugins, }): AlertTypeModel => ({ diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts deleted file mode 100644 index 5122120479cf..000000000000 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const TlsTranslations = { - defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.defaultActionMessage', { - defaultMessage: `Detected TLS certificate {commonName} from issuer {issuer} is {status}. Certificate {summary} -`, - values: { - commonName: '{{state.commonName}}', - issuer: '{{state.issuer}}', - summary: '{{state.summary}}', - status: '{{state.status}}', - }, - }), - name: i18n.translate('xpack.uptime.alerts.tls.clientName', { - defaultMessage: 'Uptime TLS', - }), - description: i18n.translate('xpack.uptime.alerts.tls.description', { - defaultMessage: 'Alert when the TLS certificate of an Uptime monitor is about to expire.', - }), -}; - -export const TlsTranslationsLegacy = { - defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.legacy.defaultActionMessage', { - defaultMessage: `Detected {count} TLS certificates expiring or becoming too old. -{expiringConditionalOpen} -Expiring cert count: {expiringCount} -Expiring Certificates: {expiringCommonNameAndDate} -{expiringConditionalClose} -{agingConditionalOpen} -Aging cert count: {agingCount} -Aging Certificates: {agingCommonNameAndDate} -{agingConditionalClose} -`, - values: { - count: '{{state.count}}', - expiringCount: '{{state.expiringCount}}', - expiringCommonNameAndDate: '{{state.expiringCommonNameAndDate}}', - expiringConditionalOpen: '{{#state.hasExpired}}', - expiringConditionalClose: '{{/state.hasExpired}}', - agingCount: '{{state.agingCount}}', - agingCommonNameAndDate: '{{state.agingCommonNameAndDate}}', - agingConditionalOpen: '{{#state.hasAging}}', - agingConditionalClose: '{{/state.hasAging}}', - }, - }), - name: i18n.translate('xpack.uptime.alerts.tls.legacy.clientName', { - defaultMessage: 'Uptime TLS', - }), - description: i18n.translate('xpack.uptime.alerts.tls.legacy.description', { - defaultMessage: - 'Alert when the TLS certificate of an Uptime monitor is about to expire. This rule type will be deprecated in a future version.', - }), -}; - -export const DurationAnomalyTranslations = { - defaultActionMessage: i18n.translate('xpack.uptime.alerts.durationAnomaly.defaultActionMessage', { - defaultMessage: `Abnormal ({severity} level) response time detected on {monitor} with url {monitorUrl} at {anomalyStartTimestamp}. Anomaly severity score is {severityScore}. -Response times as high as {slowestAnomalyResponse} have been detected from location {observerLocation}. Expected response time is {expectedResponseTime}.`, - values: { - severity: '{{state.severity}}', - anomalyStartTimestamp: '{{state.anomalyStartTimestamp}}', - monitor: '{{state.monitor}}', - monitorUrl: '{{{state.monitorUrl}}}', - slowestAnomalyResponse: '{{state.slowestAnomalyResponse}}', - expectedResponseTime: '{{state.expectedResponseTime}}', - severityScore: '{{state.severityScore}}', - observerLocation: '{{state.observerLocation}}', - }, - }), - name: i18n.translate('xpack.uptime.alerts.durationAnomaly.clientName', { - defaultMessage: 'Uptime Duration Anomaly', - }), - description: i18n.translate('xpack.uptime.alerts.durationAnomaly.description', { - defaultMessage: 'Alert when the Uptime monitor duration is anaomalous.', - }), -}; diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 82ba70155608..c303c7827333 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -6,12 +6,14 @@ */ import { Request, Server } from '@hapi/hapi'; +import { Logger } from 'kibana/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { PLUGIN } from '../common/constants/plugin'; import { compose } from './lib/compose/kibana'; import { initUptimeServer } from './uptime_server'; import { UptimeCorePlugins, UptimeCoreSetup } from './lib/adapters/framework'; import { umDynamicSettings } from './lib/saved_objects'; +import { UptimeRuleRegistry } from './plugin'; export interface KibanaRouteOptions { path: string; @@ -25,7 +27,12 @@ export interface KibanaServer extends Server { route: (options: KibanaRouteOptions) => void; } -export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCorePlugins) => { +export const initServerWithKibana = ( + server: UptimeCoreSetup, + plugins: UptimeCorePlugins, + ruleRegistry: UptimeRuleRegistry, + logger: Logger +) => { const { features } = plugins; const libs = compose(server); @@ -86,5 +93,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor }, }); - initUptimeServer(server, libs, plugins); + initUptimeServer(server, libs, plugins, ruleRegistry, logger); }; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index d1bbbc1d1856..d5b938d78c86 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -11,9 +11,11 @@ import type { ISavedObjectsRepository, IScopedClusterClient, } from 'src/core/server'; +import { ObservabilityPluginSetup } from '../../../../../observability/server'; import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { MlPluginSetup as MlSetup } from '../../../../../ml/server'; +import { RuleRegistryPluginSetupContract } from '../../../../../rule_registry/server'; import { UptimeESClient } from '../../lib'; import type { UptimeRouter } from '../../../types'; @@ -37,8 +39,10 @@ export interface UptimeCorePlugins { features: PluginSetupContract; alerting: any; elasticsearch: any; + observability: ObservabilityPluginSetup; usageCollection: UsageCollectionSetup; ml: MlSetup; + ruleRegistry: RuleRegistryPluginSetupContract; } export interface UMBackendFrameworkAdapter { diff --git a/x-pack/plugins/uptime/server/lib/alerts/common.ts b/x-pack/plugins/uptime/server/lib/alerts/common.ts index 29f2c0bde208..6bf9d28c2da9 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/common.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/common.ts @@ -6,6 +6,7 @@ */ import { isRight } from 'fp-ts/lib/Either'; +import Mustache from 'mustache'; import { UptimeCommonState, UptimeCommonStateType } from '../../../common/runtime_types'; export type UpdateUptimeAlertState = ( @@ -55,3 +56,7 @@ export const updateState: UpdateUptimeAlertState = (state, isTriggeredNow) => { isTriggered: isTriggeredNow, }; }; + +export const generateAlertMessage = (messageTemplate: string, fields: Record) => { + return Mustache.render(messageTemplate, { state: { ...fields } }); +}; diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts new file mode 100644 index 000000000000..ce13ae4ce6ce --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { durationAnomalyAlertFactory } from './duration_anomaly'; +import { DURATION_ANOMALY } from '../../../common/constants/alerts'; +import { AnomaliesTableRecord, AnomalyRecordDoc } from '../../../../ml/common/types/anomalies'; +import { DynamicSettings } from '../../../common/runtime_types'; +import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; +import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; +import { Ping } from '../../../common/runtime_types/ping'; +import { + ALERT_SEVERITY_LEVEL, + ALERT_SEVERITY_VALUE, + ALERT_EVALUATION_VALUE, + ALERT_EVALUATION_THRESHOLD, +} from '@kbn/rule-data-utils/target/technical_field_names'; + +interface MockAnomaly { + severity: AnomaliesTableRecord['severity']; + source: Partial; + actualSort: AnomaliesTableRecord['actualSort']; + typicalSort: AnomaliesTableRecord['typicalSort']; + entityValue: AnomaliesTableRecord['entityValue']; +} + +interface MockAnomalyResult { + anomalies: MockAnomaly[]; +} + +const monitorId = 'uptime-monitor'; +const mockUrl = 'https://elastic.co'; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param dynamic the expiration and aging thresholds received at alert creation time + * @param params the params received at alert creation time + * @param state the state the alert maintains + */ +const mockOptions = ( + dynamicCertSettings?: { + certExpirationThreshold: DynamicSettings['certExpirationThreshold']; + certAgeThreshold: DynamicSettings['certAgeThreshold']; + }, + state = {}, + params = { + timerange: { from: 'now-15m', to: 'now' }, + monitorId, + severity: 'warning', + } +): any => { + const { services } = createRuleTypeMocks(dynamicCertSettings); + + return { + params, + state, + services, + }; +}; + +const mockAnomaliesResult: MockAnomalyResult = { + anomalies: [ + { + severity: 25, + source: { + timestamp: 1622137799, + 'monitor.id': 'uptime-monitor', + bucket_span: 900, + }, + actualSort: 200000, + typicalSort: 10000, + entityValue: 'harrisburg', + }, + { + severity: 10, + source: { + timestamp: 1622137799, + 'monitor.id': 'uptime-monitor', + bucket_span: 900, + }, + actualSort: 300000, + typicalSort: 20000, + entityValue: 'fairbanks', + }, + ], +}; + +const mockPing: Partial = { + url: { + full: mockUrl, + }, +}; + +describe('duration anomaly alert', () => { + let toISOStringSpy: jest.SpyInstance; + const mockDate = 'date'; + beforeAll(() => { + Date.now = jest.fn().mockReturnValue(new Date('2021-05-13T12:33:37.000Z')); + jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => ({ + format: jest.fn(), + formatToParts: jest.fn(), + resolvedOptions: () => ({ + locale: '', + calendar: '', + numberingSystem: '', + timeZone: 'UTC', + }), + })); + toISOStringSpy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + describe('alert executor', () => { + it('triggers when aging or expiring alerts are found', async () => { + toISOStringSpy.mockImplementation(() => mockDate); + const mockResultServiceProviderGetter: jest.Mock<{ + getAnomaliesTableData: jest.Mock; + }> = jest.fn(); + const mockGetAnomliesTableDataGetter: jest.Mock = jest.fn(); + const mockGetLatestMonitorGetter: jest.Mock> = jest.fn(); + + mockGetLatestMonitorGetter.mockReturnValue(mockPing); + mockGetAnomliesTableDataGetter.mockReturnValue(mockAnomaliesResult); + mockResultServiceProviderGetter.mockReturnValue({ + getAnomaliesTableData: mockGetAnomliesTableDataGetter, + }); + const { server, libs, plugins } = bootstrapDependencies( + { getLatestMonitor: mockGetLatestMonitorGetter }, + { + ml: { + resultsServiceProvider: mockResultServiceProviderGetter, + }, + } + ); + const alert = durationAnomalyAlertFactory(server, libs, plugins); + const options = mockOptions(); + const { + services: { alertWithLifecycle }, + } = options; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetAnomliesTableDataGetter).toHaveBeenCalledTimes(1); + expect(alertWithLifecycle).toHaveBeenCalledTimes(2); + expect(mockGetAnomliesTableDataGetter).toBeCalledWith( + ['uptime_monitor_high_latency_by_geo'], + [], + [], + 'auto', + options.params.severity, + 1620909217000, + 1620909217000, + 'UTC', + 500, + 10, + undefined + ); + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); + mockAnomaliesResult.anomalies.forEach((anomaly, index) => { + const slowestResponse = Math.round(anomaly.actualSort / 1000); + const typicalResponse = Math.round(anomaly.typicalSort / 1000); + expect(alertWithLifecycle).toBeCalledWith({ + fields: { + 'monitor.id': options.params.monitorId, + 'url.full': mockPing.url?.full, + 'anomaly.start': mockDate, + 'anomaly.bucket_span.minutes': anomaly.source.bucket_span, + 'observer.geo.name': anomaly.entityValue, + [ALERT_EVALUATION_VALUE]: anomaly.actualSort, + [ALERT_EVALUATION_THRESHOLD]: anomaly.typicalSort, + [ALERT_SEVERITY_LEVEL]: getSeverityType(anomaly.severity), + [ALERT_SEVERITY_VALUE]: anomaly.severity, + reason: `Abnormal (${getSeverityType( + anomaly.severity + )} level) response time detected on uptime-monitor with url ${ + mockPing.url?.full + } at date. Anomaly severity score is ${anomaly.severity}. +Response times as high as ${slowestResponse} ms have been detected from location ${ + anomaly.entityValue + }. Expected response time is ${typicalResponse} ms.`, + }, + id: `${DURATION_ANOMALY.id}${index}`, + }); + expect(alertInstanceMock.replaceState).toBeCalledWith({ + firstCheckedAt: 'date', + firstTriggeredAt: undefined, + lastCheckedAt: 'date', + lastResolvedAt: undefined, + isTriggered: false, + anomalyStartTimestamp: 'date', + currentTriggerStarted: undefined, + expectedResponseTime: `${typicalResponse} ms`, + lastTriggeredAt: undefined, + monitor: monitorId, + monitorUrl: mockPing.url?.full, + observerLocation: anomaly.entityValue, + severity: getSeverityType(anomaly.severity), + severityScore: anomaly.severity, + slowestAnomalyResponse: `${slowestResponse} ms`, + bucketSpan: anomaly.source.bucket_span, + }); + }); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(2); + expect(alertInstanceMock.scheduleActions).toBeCalledWith(DURATION_ANOMALY.id); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index 981a7e7ca392..2388a789f3b8 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -8,8 +8,14 @@ import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import moment from 'moment'; import { schema } from '@kbn/config-schema'; +import { + ALERT_SEVERITY_LEVEL, + ALERT_SEVERITY_VALUE, + ALERT_EVALUATION_VALUE, + ALERT_EVALUATION_THRESHOLD, +} from '@kbn/rule-data-utils/target/technical_field_names'; import { ActionGroupIdsOf } from '../../../../alerting/common'; -import { updateState } from './common'; +import { updateState, generateAlertMessage } from './common'; import { DURATION_ANOMALY } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; @@ -18,8 +24,10 @@ import { UptimeCorePlugins } from '../adapters/framework'; import { UptimeAlertTypeFactory } from './types'; import { Ping } from '../../../common/runtime_types/ping'; import { getMLJobId } from '../../../common/lib'; -import { getLatestMonitor } from '../requests/get_latest_monitor'; -import { uptimeAlertWrapper } from './uptime_alert_wrapper'; + +import { DurationAnomalyTranslations as CommonDurationAnomalyTranslations } from '../../../common/translations'; + +import { createUptimeESClient } from '../lib'; export type ActionGroupIds = ActionGroupIdsOf; @@ -33,6 +41,7 @@ export const getAnomalySummary = (anomaly: AnomaliesTableRecord, monitorInfo: Pi slowestAnomalyResponse: Math.round(anomaly.actualSort / 1000) + ' ms', expectedResponseTime: Math.round(anomaly.typicalSort / 1000) + ' ms', observerLocation: anomaly.entityValue, + bucketSpan: anomaly.source.bucket_span, }; }; @@ -65,61 +74,83 @@ const getAnomalies = async ( export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = ( _server, - _libs, + libs, plugins -) => - uptimeAlertWrapper({ - id: 'xpack.uptime.alerts.durationAnomaly', - name: durationAnomalyTranslations.alertFactoryName, - validate: { - params: schema.object({ - monitorId: schema.string(), - severity: schema.number(), - }), +) => ({ + id: 'xpack.uptime.alerts.durationAnomaly', + producer: 'uptime', + name: durationAnomalyTranslations.alertFactoryName, + validate: { + params: schema.object({ + monitorId: schema.string(), + severity: schema.number(), + }), + }, + defaultActionGroupId: DURATION_ANOMALY.id, + actionGroups: [ + { + id: DURATION_ANOMALY.id, + name: DURATION_ANOMALY.name, }, - defaultActionGroupId: DURATION_ANOMALY.id, - actionGroups: [ - { - id: DURATION_ANOMALY.id, - name: DURATION_ANOMALY.name, - }, - ], - actionVariables: { - context: [], - state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], - }, - minimumLicenseRequired: 'basic', - isExportable: true, - async executor({ options, uptimeEsClient, savedObjectsClient, dynamicSettings }) { - const { - services: { alertInstanceFactory }, - state, - params, - } = options; + ], + actionVariables: { + context: [], + state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], + }, + isExportable: true, + minimumLicenseRequired: 'platinum', + async executor({ + params, + services: { alertWithLifecycle, scopedClusterClient, savedObjectsClient }, + state, + }) { + const uptimeEsClient = createUptimeESClient({ + esClient: scopedClusterClient.asCurrentUser, + savedObjectsClient, + }); + const { anomalies } = + (await getAnomalies(plugins, savedObjectsClient, params, state.lastCheckedAt as string)) ?? + {}; - const { anomalies } = - (await getAnomalies(plugins, savedObjectsClient, params, state.lastCheckedAt)) ?? {}; + const foundAnomalies = anomalies?.length > 0; - const foundAnomalies = anomalies?.length > 0; + if (foundAnomalies) { + const monitorInfo = await libs.requests.getLatestMonitor({ + uptimeEsClient, + dateStart: 'now-15m', + dateEnd: 'now', + monitorId: params.monitorId, + }); - if (foundAnomalies) { - const monitorInfo = await getLatestMonitor({ - uptimeEsClient, - dateStart: 'now-15m', - dateEnd: 'now', - monitorId: params.monitorId, + anomalies.forEach((anomaly, index) => { + const summary = getAnomalySummary(anomaly, monitorInfo); + + const alertInstance = alertWithLifecycle({ + id: DURATION_ANOMALY.id + index, + fields: { + 'monitor.id': params.monitorId, + 'url.full': summary.monitorUrl, + 'observer.geo.name': summary.observerLocation, + 'anomaly.start': summary.anomalyStartTimestamp, + 'anomaly.bucket_span.minutes': summary.bucketSpan, + [ALERT_EVALUATION_VALUE]: anomaly.actualSort, + [ALERT_EVALUATION_THRESHOLD]: anomaly.typicalSort, + [ALERT_SEVERITY_LEVEL]: summary.severity, + [ALERT_SEVERITY_VALUE]: summary.severityScore, + reason: generateAlertMessage( + CommonDurationAnomalyTranslations.defaultActionMessage, + summary + ), + }, }); - anomalies.forEach((anomaly, index) => { - const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); - const summary = getAnomalySummary(anomaly, monitorInfo); - alertInstance.replaceState({ - ...updateState(state, false), - ...summary, - }); - alertInstance.scheduleActions(DURATION_ANOMALY.id); + alertInstance.replaceState({ + ...updateState(state, false), + ...summary, }); - } + alertInstance.scheduleActions(DURATION_ANOMALY.id); + }); + } - return updateState(state, foundAnomalies); - }, - }); + return updateState(state, foundAnomalies); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts index 743e9f6bc75a..dbb199a2e07d 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts @@ -10,47 +10,99 @@ import { statusCheckAlertFactory, getStatusMessage, getUniqueIdsByLoc, + getInstanceId, } from './status_check'; -import { - AlertType, - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, -} from '../../../../alerting/server'; -import { UMServerLibs } from '../lib'; -import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; -import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; -import { alertsMock, AlertServicesMock } from '../../../../alerting/server/mocks'; import { GetMonitorStatusResult } from '../requests/get_monitor_status'; import { makePing } from '../../../common/runtime_types/ping'; import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; -import type { UptimeRouter } from '../../types'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { DefaultUptimeAlertInstance } from './types'; +import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; -/** - * The alert takes some dependencies as parameters; these are things like - * kibana core services and plugins. This function helps reduce the amount of - * boilerplate required. - * @param customRequests client tests can use this paramter to provide their own request mocks, - * so we don't have to mock them all for each test. - */ -const bootstrapDependencies = (customRequests?: any) => { - const router = {} as UptimeRouter; - // these server/libs parameters don't have any functionality, which is fine - // because we aren't testing them here - const server: UptimeCoreSetup = { router }; - const plugins: UptimeCorePlugins = {} as any; - const libs: UMServerLibs = { requests: {} } as UMServerLibs; - libs.requests = { ...libs.requests, ...customRequests }; - return { server, libs, plugins }; +const mockMonitors = [ + { + monitorId: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + monitorInfo: { + ...makePing({ + id: 'first', + location: 'harrisburg', + url: 'localhost:8080', + }), + error: { + message: 'error message 1', + }, + }, + }, + { + monitorId: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + monitorInfo: { + ...makePing({ + id: 'first', + location: 'fairbanks', + url: 'localhost:5601', + }), + error: { + message: 'error message 2', + }, + }, + }, +]; + +const mockCommonAlertDocumentFields = (monitorInfo: GetMonitorStatusResult['monitorInfo']) => ({ + 'agent.name': monitorInfo.agent?.name, + 'error.message': monitorInfo.error?.message, + 'monitor.id': monitorInfo.monitor.id, + 'monitor.name': monitorInfo.monitor.name || monitorInfo.monitor.id, + 'monitor.type': monitorInfo.monitor.type, + 'url.full': monitorInfo.url?.full, + 'observer.geo.name': monitorInfo.observer?.geo?.name, +}); + +const mockStatusAlertDocument = ( + monitor: GetMonitorStatusResult, + isAutoGenerated: boolean = false +) => { + const { monitorInfo } = monitor; + return { + fields: { + ...mockCommonAlertDocumentFields(monitor.monitorInfo), + reason: `Monitor first with url ${monitorInfo?.url?.full} is down from ${ + monitorInfo.observer?.geo?.name + }. The latest error message is ${monitorInfo.error?.message || ''}`, + }, + id: getInstanceId( + monitorInfo, + `${isAutoGenerated ? '' : monitorInfo?.monitor.id + '-'}${monitorInfo.observer?.geo?.name}` + ), + }; +}; + +const mockAvailabilityAlertDocument = (monitor: GetMonitorAvailabilityResult) => { + const { monitorInfo } = monitor; + return { + fields: { + ...mockCommonAlertDocumentFields(monitor.monitorInfo), + reason: `Monitor ${monitorInfo.monitor.name || monitorInfo.monitor.id} with url ${ + monitorInfo?.url?.full + } is below threshold with ${(monitor.availabilityRatio! * 100).toFixed( + 2 + )}% availability expected is 99.34% from ${ + monitorInfo.observer?.geo?.name + }. The latest error message is ${monitorInfo.error?.message || ''}`, + }, + id: getInstanceId(monitorInfo, `${monitorInfo?.monitor.id}-${monitorInfo.observer?.geo?.name}`), + }; }; /** * This function aims to provide an easy way to give mock props that will * reduce boilerplate for tests. * @param params the params received at alert creation time - * @param services the core services provided by kibana/alerting platforms * @param state the state the alert maintains */ const mockOptions = ( @@ -60,7 +112,6 @@ const mockOptions = ( timerange: { from: 'now-15m', to: 'now' }, shouldCheckStatus: true, }, - services = alertsMock.createAlertServices(), state = {}, rule = { schedule: { @@ -68,19 +119,12 @@ const mockOptions = ( }, } ): any => { - services.scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - services.scopedClusterClient.asCurrentUser = (jest.fn() as unknown) as any; + const { services } = createRuleTypeMocks(); - services.savedObjectsClient.get.mockResolvedValue({ - id: '', - type: '', - references: [], - attributes: DYNAMIC_SETTINGS_DEFAULTS, - }); return { params, - services, state, + services, rule, }; }; @@ -98,100 +142,69 @@ describe('status check alert', () => { }); describe('executor', () => { it('does not trigger when there are no monitors down', async () => { - expect.assertions(4); + expect.assertions(5); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); const alert = statusCheckAlertFactory(server, libs, plugins); // @ts-ignore the executor can return `void`, but ours never does - const state: Record = await alert.executor(mockOptions()); + const options = mockOptions(); + const state: Record = await alert.executor(options); + const { + services: { alertWithLifecycle }, + } = options; expect(state).not.toBeUndefined(); expect(state?.isTriggered).toBe(false); + expect(alertWithLifecycle).not.toHaveBeenCalled(); expect(mockGetter).toHaveBeenCalledTimes(1); - expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": undefined, - "locations": Array [], - "numTimes": 5, - "timespanRange": Object { - "from": "now-15m", - "to": "now", - }, - "timestampRange": Object { - "from": 1620821917000, - "to": "now", - }, - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, + expect(mockGetter.mock.calls[0][0]).toEqual( + expect.objectContaining({ + filters: undefined, + locations: [], + numTimes: 5, + timespanRange: { + from: 'now-15m', + to: 'now', }, - ] - `); + timestampRange: { + from: 1620821917000, + to: 'now', + }, + }) + ); }); it('triggers when monitors are down and provides expected state', async () => { toISOStringSpy.mockImplementation(() => 'foo date string'); const mockGetter: jest.Mock = jest.fn(); - mockGetter.mockReturnValue([ - { - monitorId: 'first', - location: 'harrisburg', - count: 234, - status: 'down', - monitorInfo: makePing({ - id: 'first', - location: 'harrisburg', - }), - }, - { - monitorId: 'first', - location: 'fairbanks', - count: 234, - status: 'down', - monitorInfo: makePing({ - id: 'first', - location: 'fairbanks', - }), - }, - ]); + mockGetter.mockReturnValue(mockMonitors); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions(); - const alertServices: AlertServicesMock = options.services; + const { + services: { alertWithLifecycle }, + } = options; // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(options); expect(mockGetter).toHaveBeenCalledTimes(1); - expect(alertServices.alertInstanceFactory).toHaveBeenCalledTimes(2); - expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": undefined, - "locations": Array [], - "numTimes": 5, - "timespanRange": Object { - "from": "now-15m", - "to": "now", - }, - "timestampRange": Object { - "from": 1620821917000, - "to": "now", - }, - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, + expect(alertWithLifecycle).toHaveBeenCalledTimes(2); + mockMonitors.forEach((monitor) => { + expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + }); + expect(mockGetter.mock.calls[0][0]).toEqual( + expect.objectContaining({ + filters: undefined, + locations: [], + numTimes: 5, + timespanRange: { + from: 'now-15m', + to: 'now', }, - ] - `); - const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + }) + ); + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -203,13 +216,14 @@ describe('status check alert', () => { "lastCheckedAt": "foo date string", "lastResolvedAt": undefined, "lastTriggeredAt": "foo date string", - "latestErrorMessage": undefined, + "latestErrorMessage": "error message 1", "monitorId": "first", "monitorName": "first", "monitorType": "myType", - "monitorUrl": undefined, + "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", + "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", "statusMessage": "down", }, ] @@ -218,9 +232,71 @@ describe('status check alert', () => { expect(alertInstanceMock.scheduleActions.mock.calls[0]).toMatchInlineSnapshot(` Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", - Object { - "message": "Monitor first with url is down from harrisburg. The latest error message is ", + ] + `); + }); + + it('supports auto generated monitor status alerts', async () => { + toISOStringSpy.mockImplementation(() => 'foo date string'); + const mockGetter: jest.Mock = jest.fn(); + + mockGetter.mockReturnValue(mockMonitors); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); + const options = mockOptions({ + isAutoGenerated: true, + timerange: { from: 'now-15m', to: 'now' }, + numTimes: 5, + }); + const { + services: { alertWithLifecycle }, + } = options; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(alertWithLifecycle).toHaveBeenCalledTimes(2); + mockMonitors.forEach((monitor) => { + expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor, true)); + }); + expect(mockGetter.mock.calls[0][0]).toEqual( + expect.objectContaining({ + filters: undefined, + locations: [], + numTimes: 5, + timespanRange: { + from: 'now-15m', + to: 'now', }, + }) + ); + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); + expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "currentTriggerStarted": "foo date string", + "firstCheckedAt": "foo date string", + "firstTriggeredAt": "foo date string", + "isTriggered": true, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "foo date string", + "latestErrorMessage": "error message 1", + "monitorId": "first", + "monitorName": "first", + "monitorType": "myType", + "monitorUrl": "localhost:8080", + "observerHostname": undefined, + "observerLocation": "harrisburg", + "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", + "statusMessage": "down", + }, + ] + `); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(2); + expect(alertInstanceMock.scheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", ] `); }); @@ -229,29 +305,7 @@ describe('status check alert', () => { toISOStringSpy.mockImplementation(() => '7.7 date'); const mockGetter: jest.Mock = jest.fn(); - mockGetter.mockReturnValue([ - { - monitorId: 'first', - location: 'harrisburg', - count: 234, - status: 'down', - monitorInfo: makePing({ - id: 'first', - location: 'harrisburg', - }), - }, - { - monitorId: 'first', - location: 'fairbanks', - count: 234, - status: 'down', - - monitorInfo: makePing({ - id: 'first', - location: 'fairbanks', - }), - }, - ]); + mockGetter.mockReturnValue(mockMonitors); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), @@ -259,14 +313,19 @@ describe('status check alert', () => { const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 4, - timerange: { from: 'now-14h', to: 'now' }, + timespanRange: { from: 'now-14h', to: 'now' }, locations: ['fairbanks'], filters: '', }); - const alertServices: AlertServicesMock = options.services; + const { + services: { alertWithLifecycle }, + } = options; const state = await alert.executor(options); - const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + mockMonitors.forEach((monitor) => { + expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + }); expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -278,13 +337,14 @@ describe('status check alert', () => { "lastCheckedAt": "7.7 date", "lastResolvedAt": undefined, "lastTriggeredAt": "7.7 date", - "latestErrorMessage": undefined, + "latestErrorMessage": "error message 1", "monitorId": "first", "monitorName": "first", "monitorType": "myType", - "monitorUrl": undefined, + "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", + "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", "statusMessage": "down", }, ] @@ -303,33 +363,11 @@ describe('status check alert', () => { }); it('supports 7.8 alert format', async () => { - expect.assertions(5); + expect.assertions(8); toISOStringSpy.mockImplementation(() => 'foo date string'); const mockGetter: jest.Mock = jest.fn(); - mockGetter.mockReturnValueOnce([ - { - monitorId: 'first', - location: 'harrisburg', - count: 234, - status: 'down', - monitorInfo: makePing({ - id: 'first', - location: 'harrisburg', - }), - }, - { - monitorId: 'first', - location: 'fairbanks', - count: 234, - status: 'down', - - monitorInfo: makePing({ - id: 'first', - location: 'fairbanks', - }), - }, - ]); + mockGetter.mockReturnValueOnce(mockMonitors); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), @@ -347,166 +385,160 @@ describe('status check alert', () => { tags: ['unsecured', 'containers', 'org:google'], }, }); - const alertServices: AlertServicesMock = options.services; const state = await alert.executor(options); - const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + const { + services: { alertWithLifecycle }, + } = options; + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + mockMonitors.forEach((monitor) => { + expect(alertWithLifecycle).toBeCalledWith(mockStatusAlertDocument(monitor)); + }); expect(mockGetter).toHaveBeenCalledTimes(1); - - expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": Object { - "bool": Object { - "filter": Array [ - Object { - "bool": Object { - "filter": Array [ - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "url.port": "12349", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "url.port": "5601", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "url.port": "443", - }, - }, - ], - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "observer.geo.name": "harrisburg", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "monitor.type": "http", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "tags": "unsecured", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "tags": "containers", - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match_phrase": Object { - "tags": "org:google", - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "exists": Object { - "field": "monitor.ip", - }, - }, - ], - }, - }, - ], - }, - }, - "locations": Array [], - "numTimes": 3, - "timespanRange": Object { - "from": "now-15m", - "to": "now", - }, - "timestampRange": Object { - "from": 1620821917000, - "to": "now", - }, - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, + expect(mockGetter.mock.calls[0][0]).toEqual( + expect.objectContaining({ + locations: [], + numTimes: 3, + timespanRange: { + from: 'now-15m', + to: 'now', }, - ] + }) + ); + expect(mockGetter.mock.calls[0][0].filters).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": "12349", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": "5601", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": "443", + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "monitor.type": "http", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "tags": "unsecured", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "tags": "containers", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "tags": "org:google", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "exists": Object { + "field": "monitor.ip", + }, + }, + ], + }, + }, + ], + }, + } `); expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` @@ -519,13 +551,14 @@ describe('status check alert', () => { "lastCheckedAt": "foo date string", "lastResolvedAt": undefined, "lastTriggeredAt": "foo date string", - "latestErrorMessage": undefined, + "latestErrorMessage": "error message 1", "monitorId": "first", "monitorName": "first", "monitorType": "myType", - "monitorUrl": undefined, + "monitorUrl": "localhost:8080", "observerHostname": undefined, "observerLocation": "harrisburg", + "reason": "Monitor first with url localhost:8080 is down from harrisburg. The latest error message is error message 1", "statusMessage": "down", }, ] @@ -567,67 +600,56 @@ describe('status check alert', () => { await alert.executor(options); expect(mockGetter).toHaveBeenCalledTimes(1); - expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": Object { - "bool": Object { - "filter": Array [ - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "match": Object { - "monitor.type": "http", - }, - }, - ], + expect(mockGetter.mock.calls[0][0].filters).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "monitor.type": "http", + }, }, - }, - Object { - "bool": Object { - "minimum_should_match": 1, - "should": Array [ - Object { - "exists": Object { - "field": "url.full", - }, - }, - ], - }, - }, - ], + ], + }, }, - }, - "locations": Array [], - "numTimes": 20, - "timespanRange": Object { - "from": "now-30h", - "to": "now", - }, - "timestampRange": Object { - "from": 1620714817000, - "to": "now", - }, - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "exists": Object { + "field": "url.full", + }, + }, + ], + }, + }, + ], }, - ] + } `); + expect(mockGetter.mock.calls[0][0]).toEqual( + expect.objectContaining({ + locations: [], + numTimes: 20, + timespanRange: { + from: 'now-30h', + to: 'now', + }, + }) + ); }); it('supports availability checks', async () => { - expect.assertions(8); + expect.assertions(13); toISOStringSpy.mockImplementation(() => 'availability test'); const mockGetter: jest.Mock = jest.fn(); mockGetter.mockReturnValue([]); - const mockAvailability: jest.Mock = jest.fn(); - mockAvailability.mockReturnValue([ + const mockAvailabilityMonitors = [ { monitorId: 'foo', location: 'harrisburg', @@ -679,7 +701,9 @@ describe('status check alert', () => { url: 'https://no-name.co', }), }, - ]); + ]; + const mockAvailability: jest.Mock = jest.fn(); + mockAvailability.mockReturnValue(mockAvailabilityMonitors); const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getMonitorStatus: mockGetter, @@ -701,9 +725,14 @@ describe('status check alert', () => { shouldCheckAvailability: true, shouldCheckStatus: false, }); - const alertServices: AlertServicesMock = options.services; + const { + services: { alertWithLifecycle }, + } = options; const state = await alert.executor(options); - const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + mockAvailabilityMonitors.forEach((monitor) => { + expect(alertWithLifecycle).toBeCalledWith(mockAvailabilityAlertDocument(monitor)); + }); expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(4); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -722,6 +751,7 @@ describe('status check alert', () => { "monitorUrl": "https://foo.com", "observerHostname": undefined, "observerLocation": "harrisburg", + "reason": "Monitor Foo with url https://foo.com is below threshold with 99.28% availability expected is 99.34% from harrisburg. The latest error message is ", "statusMessage": "below threshold with 99.28% availability expected is 99.34%", }, ] @@ -731,48 +761,30 @@ describe('status check alert', () => { Array [ Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", - Object { - "message": "Monitor Foo with url https://foo.com is below threshold with 99.28% availability expected is 99.34% from harrisburg. The latest error message is ", - }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", - Object { - "message": "Monitor Foo with url https://foo.com is below threshold with 98.03% availability expected is 99.34% from fairbanks. The latest error message is ", - }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", - Object { - "message": "Monitor Unreliable with url https://unreliable.co is below threshold with 90.92% availability expected is 99.34% from fairbanks. The latest error message is ", - }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", - Object { - "message": "Monitor no-name with url https://no-name.co is below threshold with 90.92% availability expected is 99.34% from fairbanks. The latest error message is ", - }, ], ] `); expect(mockGetter).not.toHaveBeenCalled(); expect(mockAvailability).toHaveBeenCalledTimes(1); - expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"12349\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"5601\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"443\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}", - "range": 35, - "rangeUnit": "d", - "threshold": "99.34", - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, - }, - ] - `); + expect(mockAvailability.mock.calls[0][0].filters).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"12349\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"5601\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":\\"443\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}"` + ); + expect(mockAvailability.mock.calls[0][0]).toEqual( + expect.objectContaining({ + range: 35, + rangeUnit: 'd', + threshold: '99.34', + }) + ); expect(state).toMatchInlineSnapshot(` Object { "currentTriggerStarted": undefined, @@ -787,7 +799,7 @@ describe('status check alert', () => { }); it('supports availability checks with search', async () => { - expect.assertions(2); + expect.assertions(3); toISOStringSpy.mockImplementation(() => 'availability with search'); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); @@ -811,22 +823,16 @@ describe('status check alert', () => { await alert.executor(options); expect(mockAvailability).toHaveBeenCalledTimes(1); - expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": "{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"ur.port\\"}}],\\"minimum_should_match\\":1}}", - "range": 23, - "rangeUnit": "w", - "threshold": "90", - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, - }, - ] - `); + expect(mockAvailability.mock.calls[0][0].filters).toMatchInlineSnapshot( + `"{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"ur.port\\"}}],\\"minimum_should_match\\":1}}"` + ); + expect(mockAvailability.mock.calls[0][0]).toEqual( + expect.objectContaining({ + range: 23, + rangeUnit: 'w', + threshold: '90', + }) + ); }); it('supports availability checks with no filter or search', async () => { @@ -841,12 +847,13 @@ describe('status check alert', () => { getIndexPattern: jest.fn(), }); const alert = statusCheckAlertFactory(server, libs, plugins); + const availability = { + range: 23, + rangeUnit: 'w', + threshold: '90', + }; const options = mockOptions({ - availability: { - range: 23, - rangeUnit: 'w', - threshold: '90', - }, + availability, shouldCheckAvailability: true, shouldCheckStatus: false, }); @@ -854,34 +861,20 @@ describe('status check alert', () => { await alert.executor(options); expect(mockAvailability).toHaveBeenCalledTimes(1); - expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "filters": undefined, - "range": 23, - "rangeUnit": "w", - "threshold": "90", - "uptimeEsClient": Object { - "baseESClient": [MockFunction], - "count": [Function], - "getSavedObjectsClient": [Function], - "search": [Function], - }, - }, - ] - `); + expect(mockAvailability.mock.calls[0][0]).toEqual( + expect.objectContaining({ + filters: undefined, + range: availability.range, + rangeUnit: availability.rangeUnit, + threshold: availability.threshold, + }) + ); }); }); describe('alert factory', () => { // @ts-ignore - let alert: AlertType< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - 'xpack.uptime.alerts.actionGroups.monitorStatus' - >; + let alert: DefaultUptimeAlertInstance; beforeEach(() => { const { server, libs, plugins } = bootstrapDependencies(); @@ -982,7 +975,6 @@ describe('status check alert', () => { search: 'url.full: *', }, undefined, - undefined, { schedule: { interval: '60h' } } ); await alert.executor(options); diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index 364518bba720..249eaa33ec24 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -4,13 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import datemath from '@elastic/datemath'; import { min } from 'lodash'; +import datemath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import Mustache from 'mustache'; import { JsonObject } from '@kbn/common-utils'; -import { ActionGroupIdsOf } from '../../../../alerting/common'; import { UptimeAlertTypeFactory } from './types'; import { esKuery } from '../../../../../../src/plugins/data/server'; import { @@ -19,16 +17,16 @@ import { GetMonitorAvailabilityParams, } from '../../../common/runtime_types'; import { MONITOR_STATUS } from '../../../common/constants/alerts'; -import { updateState } from './common'; +import { updateState, generateAlertMessage } from './common'; import { commonMonitorStateI18, commonStateTranslations, DOWN_LABEL } from './translations'; import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib'; import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; import { GetMonitorStatusResult } from '../requests/get_monitor_status'; import { UNNAMED_LOCATION } from '../../../common/constants'; -import { uptimeAlertWrapper } from './uptime_alert_wrapper'; import { MonitorStatusTranslations } from '../../../common/translations'; import { getUptimeIndexPattern, IndexPatternTitleAndFields } from '../requests/get_index_pattern'; -import { UMServerLibs, UptimeESClient } from '../lib'; +import { UMServerLibs, UptimeESClient, createUptimeESClient } from '../lib'; +import { ActionGroupIdsOf } from '../../../../alerting/common'; export type ActionGroupIds = ActionGroupIdsOf; @@ -134,8 +132,8 @@ export const formatFilterString = async ( search ); -export const getMonitorSummary = (monitorInfo: Ping) => { - return { +export const getMonitorSummary = (monitorInfo: Ping, statusMessage: string) => { + const summary = { monitorUrl: monitorInfo.url?.full, monitorId: monitorInfo.monitor?.id, monitorName: monitorInfo.monitor?.name ?? monitorInfo.monitor?.id, @@ -144,16 +142,26 @@ export const getMonitorSummary = (monitorInfo: Ping) => { observerLocation: monitorInfo.observer?.geo?.name ?? UNNAMED_LOCATION, observerHostname: monitorInfo.agent?.name, }; + const reason = generateAlertMessage(MonitorStatusTranslations.defaultActionMessage, { + ...summary, + statusMessage, + }); + return { + ...summary, + reason, + }; }; -const generateMessageForOlderVersions = (fields: Record) => { - const messageTemplate = MonitorStatusTranslations.defaultActionMessage; - - // Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from - // {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}} - - return Mustache.render(messageTemplate, { state: { ...fields } }); -}; +export const getMonitorAlertDocument = (monitorSummary: Record) => ({ + 'monitor.id': monitorSummary.monitorId, + 'monitor.type': monitorSummary.monitorType, + 'monitor.name': monitorSummary.monitorName, + 'url.full': monitorSummary.monitorUrl, + 'observer.geo.name': monitorSummary.observerLocation, + 'error.message': monitorSummary.latestErrorMessage, + 'agent.name': monitorSummary.observerHostname, + reason: monitorSummary.reason, +}); export const getStatusMessage = ( downMonInfo?: Ping, @@ -194,7 +202,7 @@ export const getStatusMessage = ( return statusMessage + availabilityMessage; }; -const getInstanceId = (monitorInfo: Ping, monIdByLoc: string) => { +export const getInstanceId = (monitorInfo: Ping, monIdByLoc: string) => { const normalizeText = (txt: string) => { // replace url and name special characters with - return txt.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); @@ -209,200 +217,204 @@ const getInstanceId = (monitorInfo: Ping, monIdByLoc: string) => { return `${urlText}_${monIdByLoc}`; }; -export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => - uptimeAlertWrapper({ - id: 'xpack.uptime.alerts.monitorStatus', - name: i18n.translate('xpack.uptime.alerts.monitorStatus', { - defaultMessage: 'Uptime monitor status', +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + producer: 'uptime', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime monitor status', + }), + validate: { + params: schema.object({ + availability: schema.maybe( + schema.object({ + range: schema.number(), + rangeUnit: schema.string(), + threshold: schema.string(), + }) + ), + filters: schema.maybe( + schema.oneOf([ + // deprecated + schema.object({ + 'monitor.type': schema.maybe(schema.arrayOf(schema.string())), + 'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())), + tags: schema.maybe(schema.arrayOf(schema.string())), + 'url.port': schema.maybe(schema.arrayOf(schema.string())), + }), + schema.string(), + ]) + ), + // deprecated + locations: schema.maybe(schema.arrayOf(schema.string())), + numTimes: schema.number(), + search: schema.maybe(schema.string()), + shouldCheckStatus: schema.boolean(), + shouldCheckAvailability: schema.boolean(), + timerangeCount: schema.maybe(schema.number()), + timerangeUnit: schema.maybe(schema.string()), + // deprecated + timerange: schema.maybe( + schema.object({ + from: schema.string(), + to: schema.string(), + }) + ), + version: schema.maybe(schema.number()), + isAutoGenerated: schema.maybe(schema.boolean()), }), - validate: { - params: schema.object({ - availability: schema.maybe( - schema.object({ - range: schema.number(), - rangeUnit: schema.string(), - threshold: schema.string(), - }) - ), - filters: schema.maybe( - schema.oneOf([ - // deprecated - schema.object({ - 'monitor.type': schema.maybe(schema.arrayOf(schema.string())), - 'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())), - tags: schema.maybe(schema.arrayOf(schema.string())), - 'url.port': schema.maybe(schema.arrayOf(schema.string())), - }), - schema.string(), - ]) - ), - // deprecated - locations: schema.maybe(schema.arrayOf(schema.string())), - numTimes: schema.number(), - search: schema.maybe(schema.string()), - shouldCheckStatus: schema.boolean(), - shouldCheckAvailability: schema.boolean(), - timerangeCount: schema.maybe(schema.number()), - timerangeUnit: schema.maybe(schema.string()), - // deprecated - timerange: schema.maybe( - schema.object({ - from: schema.string(), - to: schema.string(), - }) - ), - version: schema.maybe(schema.number()), - isAutoGenerated: schema.maybe(schema.boolean()), - }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, }, - defaultActionGroupId: MONITOR_STATUS.id, - actionGroups: [ + ], + actionVariables: { + context: [ { - id: MONITOR_STATUS.id, - name: MONITOR_STATUS.name, + name: 'message', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description', + { + defaultMessage: 'A generated message summarizing the currently down monitors', + } + ), + }, + { + name: 'downMonitorsWithGeo', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description', + { + defaultMessage: + 'A generated summary that shows some or all of the monitors detected as "down" by the alert', + } + ), }, ], - actionVariables: { - context: [ - { - name: 'message', - description: i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description', - { - defaultMessage: 'A generated message summarizing the currently down monitors', - } - ), - }, - { - name: 'downMonitorsWithGeo', - description: i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description', - { - defaultMessage: - 'A generated summary that shows some or all of the monitors detected as "down" by the alert', - } - ), - }, - ], - state: [...commonMonitorStateI18, ...commonStateTranslations], + state: [...commonMonitorStateI18, ...commonStateTranslations], + }, + isExportable: true, + minimumLicenseRequired: 'basic', + async executor({ + params: rawParams, + state, + services: { savedObjectsClient, scopedClusterClient, alertWithLifecycle }, + rule: { + schedule: { interval }, }, - minimumLicenseRequired: 'basic', - isExportable: true, - async executor({ - options: { - params: rawParams, - state, - services: { alertInstanceFactory }, - rule: { - schedule: { interval }, - }, - }, - uptimeEsClient, - }) { - const { - filters, - search, + }) { + const { + filters, + search, + numTimes, + timerangeCount, + timerangeUnit, + availability, + shouldCheckAvailability, + shouldCheckStatus, + isAutoGenerated, + timerange: oldVersionTimeRange, + } = rawParams; + const uptimeEsClient = createUptimeESClient({ + esClient: scopedClusterClient.asCurrentUser, + savedObjectsClient, + }); + + const filterString = await formatFilterString(uptimeEsClient, filters, search, libs); + + const timespanInterval = `${String(timerangeCount)}${timerangeUnit}`; + + // Range filter for `monitor.timespan`, the range of time the ping is valid + const timespanRange = oldVersionTimeRange || { + from: `now-${timespanInterval}`, + to: 'now', + }; + + // Range filter for `@timestamp`, the time the document was indexed + const timestampRange = getTimestampRange({ + ruleScheduleLookback: `now-${interval}`, + timerangeLookback: timespanRange.from, + }); + + let downMonitorsByLocation: GetMonitorStatusResult[] = []; + + // if oldVersionTimeRange present means it's 7.7 format and + // after that shouldCheckStatus should be explicitly false + if (!(!oldVersionTimeRange && shouldCheckStatus === false)) { + downMonitorsByLocation = await libs.requests.getMonitorStatus({ + uptimeEsClient, + timespanRange, + timestampRange, numTimes, - timerangeCount, - timerangeUnit, - availability, - shouldCheckAvailability, - shouldCheckStatus, - isAutoGenerated, - timerange: oldVersionTimeRange, - } = rawParams; - const filterString = await formatFilterString(uptimeEsClient, filters, search, libs); - - const timespanInterval = `${String(timerangeCount)}${timerangeUnit}`; - - // Range filter for `monitor.timespan`, the range of time the ping is valid - const timespanRange = oldVersionTimeRange || { - from: `now-${timespanInterval}`, - to: 'now', - }; - - // Range filter for `@timestamp`, the time the document was indexed - const timestampRange = getTimestampRange({ - ruleScheduleLookback: `now-${interval}`, - timerangeLookback: timespanRange.from, + locations: [], + filters: filterString, }); + } - let downMonitorsByLocation: GetMonitorStatusResult[] = []; + if (isAutoGenerated) { + for (const monitorLoc of downMonitorsByLocation) { + const monitorInfo = monitorLoc.monitorInfo; - // if oldVersionTimeRange present means it's 7.7 format and - // after that shouldCheckStatus should be explicitly false - if (!(!oldVersionTimeRange && shouldCheckStatus === false)) { - downMonitorsByLocation = await libs.requests.getMonitorStatus({ - uptimeEsClient, - timespanRange, - timestampRange, - numTimes, - locations: [], - filters: filterString, + const statusMessage = getStatusMessage(monitorInfo); + const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + + const alert = alertWithLifecycle({ + id: getInstanceId(monitorInfo, monitorLoc.location), + fields: getMonitorAlertDocument(monitorSummary), }); - } - if (isAutoGenerated) { - for (const monitorLoc of downMonitorsByLocation) { - const monitorInfo = monitorLoc.monitorInfo; - - const alertInstance = alertInstanceFactory( - getInstanceId(monitorInfo, monitorLoc.location) - ); - - const monitorSummary = getMonitorSummary(monitorInfo); - const statusMessage = getStatusMessage(monitorInfo); - - alertInstance.replaceState({ - ...state, - ...monitorSummary, - statusMessage, - ...updateState(state, true), - }); - - alertInstance.scheduleActions(MONITOR_STATUS.id); - } - return updateState(state, downMonitorsByLocation.length > 0); - } - - let availabilityResults: GetMonitorAvailabilityResult[] = []; - if (shouldCheckAvailability) { - availabilityResults = await libs.requests.getMonitorAvailability({ - uptimeEsClient, - ...availability, - filters: JSON.stringify(filterString) || undefined, - }); - } - - const mergedIdsByLoc = getUniqueIdsByLoc(downMonitorsByLocation, availabilityResults); - - mergedIdsByLoc.forEach((monIdByLoc) => { - const availMonInfo = availabilityResults.find( - ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc - ); - - const downMonInfo = downMonitorsByLocation.find( - ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc - )?.monitorInfo; - - const monitorInfo = downMonInfo || availMonInfo?.monitorInfo!; - - const monitorSummary = getMonitorSummary(monitorInfo); - const statusMessage = getStatusMessage(downMonInfo!, availMonInfo!, availability); - - const alertInstance = alertInstanceFactory(getInstanceId(monitorInfo, monIdByLoc)); - - alertInstance.replaceState({ - ...updateState(state, true), + alert.replaceState({ + ...state, ...monitorSummary, statusMessage, + ...updateState(state, true), }); - alertInstance.scheduleActions(MONITOR_STATUS.id, { - message: generateMessageForOlderVersions({ ...monitorSummary, statusMessage }), - }); + alert.scheduleActions(MONITOR_STATUS.id); + } + return updateState(state, downMonitorsByLocation.length > 0); + } + + let availabilityResults: GetMonitorAvailabilityResult[] = []; + if (shouldCheckAvailability) { + availabilityResults = await libs.requests.getMonitorAvailability({ + uptimeEsClient, + ...availability, + filters: JSON.stringify(filterString) || undefined, + }); + } + + const mergedIdsByLoc = getUniqueIdsByLoc(downMonitorsByLocation, availabilityResults); + + mergedIdsByLoc.forEach((monIdByLoc) => { + const availMonInfo = availabilityResults.find( + ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc + ); + + const downMonInfo = downMonitorsByLocation.find( + ({ monitorId, location }) => getMonIdByLoc(monitorId, location) === monIdByLoc + )?.monitorInfo; + + const monitorInfo = downMonInfo || availMonInfo?.monitorInfo!; + + const statusMessage = getStatusMessage(downMonInfo!, availMonInfo!, availability); + const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + + const alert = alertWithLifecycle({ + id: getInstanceId(monitorInfo, monIdByLoc), + fields: getMonitorAlertDocument(monitorSummary), }); - return updateState(state, downMonitorsByLocation.length > 0); - }, - }); + alert.replaceState({ + ...updateState(state, true), + ...monitorSummary, + statusMessage, + }); + + alert.scheduleActions(MONITOR_STATUS.id); + }); + + return updateState(state, downMonitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts new file mode 100644 index 000000000000..8bbf20f3a64a --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'kibana/server'; +import { UMServerLibs } from '../../lib'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../../adapters'; +import type { UptimeRouter } from '../../../types'; +import type { RuleDataClient } from '../../../../../rule_registry/server'; +import { getUptimeESMockClient } from '../../requests/helper'; +import { alertsMock } from '../../../../../alerting/server/mocks'; +import { DynamicSettings } from '../../../../common/runtime_types'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; + +/** + * The alert takes some dependencies as parameters; these are things like + * kibana core services and plugins. This function helps reduce the amount of + * boilerplate required. + * @param customRequests client tests can use this paramter to provide their own request mocks, + * so we don't have to mock them all for each test. + */ +export const bootstrapDependencies = (customRequests?: any, customPlugins: any = {}) => { + const router = {} as UptimeRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { router }; + const plugins: UptimeCorePlugins = customPlugins as any; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs, plugins }; +}; + +export const createRuleTypeMocks = ( + dynamicCertSettings: { + certAgeThreshold: DynamicSettings['certAgeThreshold']; + certExpirationThreshold: DynamicSettings['certExpirationThreshold']; + } = { + certAgeThreshold: DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, + certExpirationThreshold: DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, + } +) => { + const loggerMock = ({ + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown) as Logger; + + const scheduleActions = jest.fn(); + const replaceState = jest.fn(); + + const services = { + ...getUptimeESMockClient(), + ...alertsMock.createAlertServices(), + alertWithLifecycle: jest.fn().mockReturnValue({ scheduleActions, replaceState }), + logger: loggerMock, + }; + + return { + dependencies: { + logger: loggerMock, + ruleDataClient: ({ + getReader: () => { + return { + search: jest.fn(), + }; + }, + getWriter: () => { + return { + bulk: jest.fn(), + }; + }, + } as unknown) as RuleDataClient, + }, + services, + scheduleActions, + replaceState, + }; +}; diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.test.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.test.ts index a77fe10f0b9a..2536056363dd 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.test.ts @@ -4,52 +4,180 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import moment from 'moment'; -import { getCertSummary } from './tls'; -import { Cert } from '../../../common/runtime_types'; + +import { tlsAlertFactory, getCertSummary, DEFAULT_SIZE } from './tls'; +import { TLS } from '../../../common/constants/alerts'; +import { CertResult, DynamicSettings } from '../../../common/runtime_types'; +import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; +import { DEFAULT_FROM, DEFAULT_TO } from '../../rest_api/certs/certs'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; + +import { savedObjectsAdapter, UMSavedObjectsAdapter } from '../saved_objects'; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param state the state the alert maintains + */ +const mockOptions = ( + dynamicCertSettings?: { + certExpirationThreshold: DynamicSettings['certExpirationThreshold']; + certAgeThreshold: DynamicSettings['certAgeThreshold']; + }, + state = {} +): any => { + const { services } = createRuleTypeMocks(dynamicCertSettings); + const params = { + timerange: { from: 'now-15m', to: 'now' }, + }; + + return { + params, + state, + services, + }; +}; + +const mockCertResult: CertResult = { + certs: [ + { + not_after: '2020-07-16T03:15:39.000Z', + not_before: '2019-07-24T03:15:39.000Z', + issuer: 'Sample issuer', + common_name: 'Common-One', + monitors: [{ name: 'monitor-one', id: 'monitor1' }], + sha256: 'abc', + }, + { + not_after: '2020-07-18T03:15:39.000Z', + not_before: '2019-07-20T03:15:39.000Z', + issuer: 'Sample issuer', + common_name: 'Common-Two', + monitors: [{ name: 'monitor-two', id: 'monitor2' }], + sha256: 'bcd', + }, + { + not_after: '2020-07-19T03:15:39.000Z', + not_before: '2019-07-22T03:15:39.000Z', + issuer: 'Sample issuer', + common_name: 'Common-Three', + monitors: [{ name: 'monitor-three', id: 'monitor3' }], + sha256: 'cde', + }, + { + not_after: '2020-07-25T03:15:39.000Z', + not_before: '2019-07-25T03:15:39.000Z', + issuer: 'Sample issuer', + common_name: 'Common-Four', + monitors: [{ name: 'monitor-four', id: 'monitor4' }], + sha256: 'def', + }, + ], + total: 4, +}; describe('tls alert', () => { + let toISOStringSpy: jest.SpyInstance; + let savedObjectsAdapterSpy: jest.SpyInstance< + ReturnType + >; + const mockDate = 'date'; + beforeAll(() => { + Date.now = jest.fn().mockReturnValue(new Date('2021-05-13T12:33:37.000Z')); + }); + + describe('alert executor', () => { + beforeEach(() => { + toISOStringSpy = jest.spyOn(Date.prototype, 'toISOString'); + savedObjectsAdapterSpy = jest.spyOn(savedObjectsAdapter, 'getUptimeDynamicSettings'); + }); + + it('triggers when aging or expiring alerts are found', async () => { + toISOStringSpy.mockImplementation(() => mockDate); + const mockGetter: jest.Mock = jest.fn(); + + mockGetter.mockReturnValue(mockCertResult); + const { server, libs, plugins } = bootstrapDependencies({ getCerts: mockGetter }); + const alert = tlsAlertFactory(server, libs, plugins); + const options = mockOptions(); + const { + services: { alertWithLifecycle }, + } = options; + await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(alertWithLifecycle).toHaveBeenCalledTimes(4); + mockCertResult.certs.forEach((cert) => { + expect(alertWithLifecycle).toBeCalledWith({ + fields: expect.objectContaining({ + 'tls.server.x509.subject.common_name': cert.common_name, + 'tls.server.x509.issuer.common_name': cert.issuer, + 'tls.server.x509.not_after': cert.not_after, + 'tls.server.x509.not_before': cert.not_before, + 'tls.server.hash.sha256': cert.sha256, + }), + id: `${cert.common_name}-${cert.issuer?.replace(/\s/g, '_')}-${cert.sha256}`, + }); + }); + expect(mockGetter).toBeCalledWith( + expect.objectContaining({ + from: DEFAULT_FROM, + to: DEFAULT_TO, + index: 0, + size: DEFAULT_SIZE, + notValidAfter: `now+${DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold}d`, + notValidBefore: `now-${DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold}d`, + sortBy: 'common_name', + direction: 'desc', + }) + ); + const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(4); + mockCertResult.certs.forEach((cert) => { + expect(alertInstanceMock.replaceState).toBeCalledWith( + expect.objectContaining({ + commonName: cert.common_name, + issuer: cert.issuer, + status: 'expired', + }) + ); + }); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(4); + expect(alertInstanceMock.scheduleActions).toBeCalledWith(TLS.id); + }); + + it('handles dynamic settings for aging or expiration threshold', async () => { + toISOStringSpy.mockImplementation(() => mockDate); + const certSettings = { + certAgeThreshold: 10, + certExpirationThreshold: 5, + heartbeatIndices: 'heartbeat-*', + defaultConnectors: [], + }; + savedObjectsAdapterSpy.mockImplementation(() => certSettings); + const mockGetter: jest.Mock = jest.fn(); + + mockGetter.mockReturnValue(mockCertResult); + const { server, libs, plugins } = bootstrapDependencies({ getCerts: mockGetter }); + const alert = tlsAlertFactory(server, libs, plugins); + const options = mockOptions(); + await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter).toBeCalledWith( + expect.objectContaining({ + notValidAfter: `now+${certSettings.certExpirationThreshold}d`, + notValidBefore: `now-${certSettings.certAgeThreshold}d`, + }) + ); + }); + }); + describe('getCertSummary', () => { - let mockCerts: Cert[]; let diffSpy: jest.SpyInstance; beforeEach(() => { diffSpy = jest.spyOn(moment.prototype, 'diff'); - mockCerts = [ - { - not_after: '2020-07-16T03:15:39.000Z', - not_before: '2019-07-24T03:15:39.000Z', - common_name: 'Common-One', - monitors: [{ name: 'monitor-one', id: 'monitor1' }], - sha256: 'abc', - issuer: 'Cloudflare Inc ECC CA-3', - }, - { - not_after: '2020-07-18T03:15:39.000Z', - not_before: '2019-07-20T03:15:39.000Z', - common_name: 'Common-Two', - monitors: [{ name: 'monitor-two', id: 'monitor2' }], - sha256: 'bcd', - issuer: 'Cloudflare Inc ECC CA-3', - }, - { - not_after: '2020-07-19T03:15:39.000Z', - not_before: '2019-07-22T03:15:39.000Z', - common_name: 'Common-Three', - monitors: [{ name: 'monitor-three', id: 'monitor3' }], - sha256: 'cde', - issuer: 'Cloudflare Inc ECC CA-3', - }, - { - not_after: '2020-07-25T03:15:39.000Z', - not_before: '2019-07-25T03:15:39.000Z', - common_name: 'Common-Four', - monitors: [{ name: 'monitor-four', id: 'monitor4' }], - sha256: 'def', - issuer: 'Cloudflare Inc ECC CA-3', - }, - ]; }); afterEach(() => { @@ -59,13 +187,13 @@ describe('tls alert', () => { it('handles positive diffs for expired certs appropriately', () => { diffSpy.mockReturnValueOnce(900); const result = getCertSummary( - mockCerts[0], + mockCertResult.certs[0], new Date('2020-07-20T05:00:00.000Z').valueOf(), new Date('2019-03-01T00:00:00.000Z').valueOf() ); expect(result).toEqual({ - commonName: mockCerts[0].common_name, - issuer: mockCerts[0].issuer, + commonName: mockCertResult.certs[0].common_name, + issuer: mockCertResult.certs[0].issuer, summary: 'expired on Jul 15, 2020 EDT, 900 days ago.', status: 'expired', }); @@ -74,13 +202,13 @@ describe('tls alert', () => { it('handles positive diffs for agining certs appropriately', () => { diffSpy.mockReturnValueOnce(702); const result = getCertSummary( - mockCerts[0], + mockCertResult.certs[0], new Date('2020-07-01T12:00:00.000Z').valueOf(), new Date('2019-09-01T03:00:00.000Z').valueOf() ); expect(result).toEqual({ - commonName: mockCerts[0].common_name, - issuer: mockCerts[0].issuer, + commonName: mockCertResult.certs[0].common_name, + issuer: mockCertResult.certs[0].issuer, summary: 'valid since Jul 23, 2019 EDT, 702 days ago.', status: 'becoming too old', }); @@ -89,13 +217,13 @@ describe('tls alert', () => { it('handles negative diff values appropriately for aging certs', () => { diffSpy.mockReturnValueOnce(-90); const result = getCertSummary( - mockCerts[0], + mockCertResult.certs[0], new Date('2020-07-01T12:00:00.000Z').valueOf(), new Date('2019-09-01T03:00:00.000Z').valueOf() ); expect(result).toEqual({ - commonName: mockCerts[0].common_name, - issuer: mockCerts[0].issuer, + commonName: mockCertResult.certs[0].common_name, + issuer: mockCertResult.certs[0].issuer, summary: 'invalid until Jul 23, 2019 EDT, 90 days from now.', status: 'invalid', }); @@ -106,13 +234,13 @@ describe('tls alert', () => { // negative days are in the future, positive days are in the past .mockReturnValueOnce(-96); const result = getCertSummary( - mockCerts[0], + mockCertResult.certs[0], new Date('2020-07-20T05:00:00.000Z').valueOf(), new Date('2019-03-01T00:00:00.000Z').valueOf() ); expect(result).toEqual({ - commonName: mockCerts[0].common_name, - issuer: mockCerts[0].issuer, + commonName: mockCertResult.certs[0].common_name, + issuer: mockCertResult.certs[0].issuer, summary: 'expires on Jul 15, 2020 EDT in 96 days.', status: 'expiring', }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls.ts b/x-pack/plugins/uptime/server/lib/alerts/tls.ts index 09f5e2fe0f6d..8056fe210bf5 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls.ts @@ -8,18 +8,22 @@ import moment from 'moment'; import { schema } from '@kbn/config-schema'; import { UptimeAlertTypeFactory } from './types'; -import { updateState } from './common'; +import { updateState, generateAlertMessage } from './common'; import { TLS } from '../../../common/constants/alerts'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; import { Cert, CertResult } from '../../../common/runtime_types'; import { commonStateTranslations, tlsTranslations } from './translations'; import { DEFAULT_FROM, DEFAULT_TO } from '../../rest_api/certs/certs'; -import { uptimeAlertWrapper } from './uptime_alert_wrapper'; +import { TlsTranslations } from '../../../common/translations'; + import { ActionGroupIdsOf } from '../../../../alerting/common'; +import { savedObjectsAdapter } from '../saved_objects'; +import { createUptimeESClient } from '../lib'; + export type ActionGroupIds = ActionGroupIdsOf; -const DEFAULT_SIZE = 20; +export const DEFAULT_SIZE = 20; interface TlsAlertState { commonName: string; @@ -93,78 +97,92 @@ export const getCertSummary = ( }; }; -export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, libs) => - uptimeAlertWrapper({ - id: 'xpack.uptime.alerts.tlsCertificate', - name: tlsTranslations.alertFactoryName, - validate: { - params: schema.object({}), +export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ + id: 'xpack.uptime.alerts.tlsCertificate', + producer: 'uptime', + name: tlsTranslations.alertFactoryName, + validate: { + params: schema.object({}), + }, + defaultActionGroupId: TLS.id, + actionGroups: [ + { + id: TLS.id, + name: TLS.name, }, - defaultActionGroupId: TLS.id, - actionGroups: [ - { - id: TLS.id, - name: TLS.name, - }, - ], - actionVariables: { - context: [], - state: [...tlsTranslations.actionVariables, ...commonStateTranslations], - }, - minimumLicenseRequired: 'basic', - isExportable: true, - async executor({ options, dynamicSettings, uptimeEsClient }) { - const { - services: { alertInstanceFactory }, - state, - } = options; + ], + actionVariables: { + context: [], + state: [...tlsTranslations.actionVariables, ...commonStateTranslations], + }, + isExportable: true, + minimumLicenseRequired: 'basic', + async executor({ + services: { alertWithLifecycle, savedObjectsClient, scopedClusterClient }, + state, + }) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); - const { certs, total }: CertResult = await libs.requests.getCerts({ - uptimeEsClient, - from: DEFAULT_FROM, - to: DEFAULT_TO, - index: 0, - size: DEFAULT_SIZE, - notValidAfter: `now+${ - dynamicSettings?.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold - }d`, - notValidBefore: `now-${ - dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold - }d`, - sortBy: 'common_name', - direction: 'desc', - }); + const uptimeEsClient = createUptimeESClient({ + esClient: scopedClusterClient.asCurrentUser, + savedObjectsClient, + }); - const foundCerts = total > 0; + const { certs, total }: CertResult = await libs.requests.getCerts({ + uptimeEsClient, + from: DEFAULT_FROM, + to: DEFAULT_TO, + index: 0, + size: DEFAULT_SIZE, + notValidAfter: `now+${ + dynamicSettings?.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold + }d`, + notValidBefore: `now-${ + dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold + }d`, + sortBy: 'common_name', + direction: 'desc', + }); - if (foundCerts) { - certs.forEach((cert) => { - const absoluteExpirationThreshold = moment() - .add( - dynamicSettings.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, - 'd' - ) - .valueOf(); - const absoluteAgeThreshold = moment() - .subtract( - dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, - 'd' - ) - .valueOf(); - const alertInstance = alertInstanceFactory( - `${cert.common_name}-${cert.issuer?.replace(/\s/g, '_')}-${cert.sha256}` - ); - const summary = getCertSummary(cert, absoluteExpirationThreshold, absoluteAgeThreshold); - alertInstance.replaceState({ - ...updateState(state, foundCerts), - ...summary, - }); - alertInstance.scheduleActions(TLS.id); + const foundCerts = total > 0; + + if (foundCerts) { + certs.forEach((cert) => { + const absoluteExpirationThreshold = moment() + .add( + dynamicSettings.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, + 'd' + ) + .valueOf(); + const absoluteAgeThreshold = moment() + .subtract( + dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, + 'd' + ) + .valueOf(); + const summary = getCertSummary(cert, absoluteExpirationThreshold, absoluteAgeThreshold); + + const alertInstance = alertWithLifecycle({ + id: `${cert.common_name}-${cert.issuer?.replace(/\s/g, '_')}-${cert.sha256}`, + fields: { + 'tls.server.x509.subject.common_name': cert.common_name, + 'tls.server.x509.issuer.common_name': cert.issuer, + 'tls.server.x509.not_after': cert.not_after, + 'tls.server.x509.not_before': cert.not_before, + 'tls.server.hash.sha256': cert.sha256, + reason: generateAlertMessage(TlsTranslations.defaultActionMessage, summary), + }, }); - } + alertInstance.replaceState({ + ...updateState(state, foundCerts), + ...summary, + }); + alertInstance.scheduleActions(TLS.id); + }); + } - return updateState(state, foundCerts); - }, - }); + return updateState(state, foundCerts); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts b/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts index 5bf91b7c5486..812925f22b24 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/tls_legacy.ts @@ -14,11 +14,18 @@ import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; import { Cert, CertResult } from '../../../common/runtime_types'; import { commonStateTranslations, tlsTranslations } from './translations'; import { DEFAULT_FROM, DEFAULT_TO } from '../../rest_api/certs/certs'; -import { uptimeAlertWrapper } from './uptime_alert_wrapper'; import { ActionGroupIdsOf } from '../../../../alerting/common'; +import { AlertInstanceContext } from '../../../../alerting/common'; +import { AlertInstance } from '../../../../alerting/server'; + +import { savedObjectsAdapter } from '../saved_objects'; +import { createUptimeESClient } from '../lib'; + export type ActionGroupIds = ActionGroupIdsOf; +type TLSAlertInstance = AlertInstance, AlertInstanceContext, ActionGroupIds>; + const DEFAULT_SIZE = 20; interface TlsAlertState { @@ -84,74 +91,78 @@ export const getCertSummary = ( }; }; -export const tlsLegacyAlertFactory: UptimeAlertTypeFactory = (_server, libs) => - uptimeAlertWrapper({ - id: 'xpack.uptime.alerts.tls', - name: tlsTranslations.legacyAlertFactoryName, - validate: { - params: schema.object({}), +export const tlsLegacyAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ + id: 'xpack.uptime.alerts.tls', + producer: 'uptime', + name: tlsTranslations.legacyAlertFactoryName, + validate: { + params: schema.object({}), + }, + defaultActionGroupId: TLS_LEGACY.id, + actionGroups: [ + { + id: TLS_LEGACY.id, + name: TLS_LEGACY.name, }, - defaultActionGroupId: TLS_LEGACY.id, - actionGroups: [ - { - id: TLS_LEGACY.id, - name: TLS_LEGACY.name, - }, - ], - actionVariables: { - context: [], - state: [...tlsTranslations.actionVariables, ...commonStateTranslations], - }, - minimumLicenseRequired: 'basic', - isExportable: true, - async executor({ options, dynamicSettings, uptimeEsClient }) { - const { - services: { alertInstanceFactory }, - state, - } = options; + ], + actionVariables: { + context: [], + state: [...tlsTranslations.actionVariables, ...commonStateTranslations], + }, + isExportable: true, + minimumLicenseRequired: 'basic', + async executor({ + services: { alertInstanceFactory, scopedClusterClient, savedObjectsClient }, + state, + }) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); - const { certs, total }: CertResult = await libs.requests.getCerts({ - uptimeEsClient, - from: DEFAULT_FROM, - to: DEFAULT_TO, - index: 0, - size: DEFAULT_SIZE, - notValidAfter: `now+${ - dynamicSettings?.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold - }d`, - notValidBefore: `now-${ - dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold - }d`, - sortBy: 'common_name', - direction: 'desc', + const uptimeEsClient = createUptimeESClient({ + esClient: scopedClusterClient.asCurrentUser, + savedObjectsClient, + }); + const { certs, total }: CertResult = await libs.requests.getCerts({ + uptimeEsClient, + from: DEFAULT_FROM, + to: DEFAULT_TO, + index: 0, + size: DEFAULT_SIZE, + notValidAfter: `now+${ + dynamicSettings?.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold + }d`, + notValidBefore: `now-${ + dynamicSettings?.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold + }d`, + sortBy: 'common_name', + direction: 'desc', + }); + + const foundCerts = total > 0; + + if (foundCerts) { + const absoluteExpirationThreshold = moment() + .add( + dynamicSettings.certExpirationThreshold ?? + DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, + 'd' + ) + .valueOf(); + const absoluteAgeThreshold = moment() + .subtract( + dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, + 'd' + ) + .valueOf(); + const alertInstance: TLSAlertInstance = alertInstanceFactory(TLS_LEGACY.id); + const summary = getCertSummary(certs, absoluteExpirationThreshold, absoluteAgeThreshold); + alertInstance.replaceState({ + ...updateState(state, foundCerts), + ...summary, }); + alertInstance.scheduleActions(TLS_LEGACY.id); + } - const foundCerts = total > 0; - - if (foundCerts) { - const absoluteExpirationThreshold = moment() - .add( - dynamicSettings.certExpirationThreshold ?? - DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, - 'd' - ) - .valueOf(); - const absoluteAgeThreshold = moment() - .subtract( - dynamicSettings.certAgeThreshold ?? DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, - 'd' - ) - .valueOf(); - const alertInstance = alertInstanceFactory(TLS_LEGACY.id); - const summary = getCertSummary(certs, absoluteExpirationThreshold, absoluteAgeThreshold); - alertInstance.replaceState({ - ...updateState(state, foundCerts), - ...summary, - }); - alertInstance.scheduleActions(TLS_LEGACY.id); - } - - return updateState(state, foundCerts); - }, - }); + return updateState(state, foundCerts); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index 6f9ca42e54ad..28f9eba7ab38 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -4,21 +4,27 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; import { UMServerLibs } from '../lib'; -import { AlertType, AlertInstanceState, AlertInstanceContext } from '../../../../alerting/server'; +import { AlertTypeWithExecutor, LifecycleAlertService } from '../../../../rule_registry/server'; +import { AlertInstanceContext } from '../../../../alerting/common'; -export type UptimeAlertTypeParam = Record; -export type UptimeAlertTypeState = Record; -export type UptimeAlertTypeFactory = ( +/** + * Because all of our types are presumably going to list the `producer` as `'uptime'`, + * we should just omit this field from the returned value to simplify the returned alert type. + * + * When we register all the alerts we can inject this field. + */ +export type DefaultUptimeAlertInstance = AlertTypeWithExecutor< + Record, + AlertInstanceContext, + { + alertWithLifecycle: LifecycleAlertService; + } +>; + +export type UptimeAlertTypeFactory = ( server: UptimeCoreSetup, libs: UMServerLibs, plugins: UptimeCorePlugins -) => AlertType< - UptimeAlertTypeParam, - UptimeAlertTypeState, - AlertInstanceState, - AlertInstanceContext, - ActionGroupIds ->; +) => DefaultUptimeAlertInstance; diff --git a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts deleted file mode 100644 index 654f99cb0265..000000000000 --- a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsClientContract } from 'kibana/server'; -import { - AlertExecutorOptions, - AlertInstanceState, - AlertInstanceContext, -} from '../../../../alerting/server'; -import { savedObjectsAdapter } from '../saved_objects'; -import { DynamicSettings } from '../../../common/runtime_types'; -import { createUptimeESClient, UptimeESClient } from '../lib'; -import { UptimeAlertTypeFactory, UptimeAlertTypeParam, UptimeAlertTypeState } from './types'; - -export interface UptimeAlertType - extends Omit>, 'executor' | 'producer'> { - executor: ({ - options, - uptimeEsClient, - dynamicSettings, - }: { - options: AlertExecutorOptions< - UptimeAlertTypeParam, - UptimeAlertTypeState, - AlertInstanceState, - AlertInstanceContext, - ActionGroupIds - >; - uptimeEsClient: UptimeESClient; - dynamicSettings: DynamicSettings; - savedObjectsClient: SavedObjectsClientContract; - }) => Promise; -} - -export const uptimeAlertWrapper = ( - uptimeAlert: UptimeAlertType -) => ({ - ...uptimeAlert, - producer: 'uptime', - executor: async ( - options: AlertExecutorOptions< - UptimeAlertTypeParam, - UptimeAlertTypeState, - AlertInstanceState, - AlertInstanceContext, - ActionGroupIds - > - ) => { - const { - services: { scopedClusterClient: esClient, savedObjectsClient }, - } = options; - - const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( - options.services.savedObjectsClient - ); - - const uptimeEsClient = createUptimeESClient({ - esClient: esClient.asCurrentUser, - savedObjectsClient, - }); - - return uptimeAlert.executor({ options, dynamicSettings, uptimeEsClient, savedObjectsClient }); - }, -}); diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index c0fecf6f19af..5ef5e17d4e33 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -4,30 +4,97 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { once } from 'lodash'; import { PluginInitializerContext, CoreStart, CoreSetup, Plugin as PluginType, ISavedObjectsRepository, + Logger, } from '../../../../src/core/server'; +import { uptimeRuleFieldMap } from '../common/rules/uptime_rule_field_map'; import { initServerWithKibana } from './kibana.index'; import { KibanaTelemetryAdapter, UptimeCorePlugins } from './lib/adapters'; import { umDynamicSettings } from './lib/saved_objects'; +import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map'; +import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../rule_registry/common/assets'; + +export type UptimeRuleRegistry = ReturnType['ruleRegistry']; export class Plugin implements PluginType { private savedObjectsClient?: ISavedObjectsRepository; + private initContext: PluginInitializerContext; + private logger?: Logger; - constructor(_initializerContext: PluginInitializerContext) {} + constructor(_initializerContext: PluginInitializerContext) { + this.initContext = _initializerContext; + } public setup(core: CoreSetup, plugins: UptimeCorePlugins) { - initServerWithKibana({ router: core.http.createRouter() }, plugins); + this.logger = this.initContext.logger.get(); + const { ruleDataService } = plugins.ruleRegistry; + + const ready = once(async () => { + const componentTemplateName = ruleDataService.getFullAssetName('synthetics-mappings'); + const alertsIndexPattern = ruleDataService.getFullAssetName('observability.synthetics*'); + + if (!ruleDataService.isWriteEnabled()) { + return; + } + + await ruleDataService.createOrUpdateComponentTemplate({ + name: componentTemplateName, + body: { + template: { + settings: { + number_of_shards: 1, + }, + mappings: mappingFromFieldMap(uptimeRuleFieldMap), + }, + }, + }); + + await ruleDataService.createOrUpdateIndexTemplate({ + name: ruleDataService.getFullAssetName('synthetics-index-template'), + body: { + index_patterns: [alertsIndexPattern], + composed_of: [ + ruleDataService.getFullAssetName(TECHNICAL_COMPONENT_TEMPLATE_NAME), + componentTemplateName, + ], + }, + }); + + await ruleDataService.updateIndexMappingsMatchingPattern(alertsIndexPattern); + }); + + // initialize eagerly + const initializeRuleDataTemplatesPromise = ready().catch((err) => { + this.logger!.error(err); + }); + + const ruleDataClient = ruleDataService.getRuleDataClient( + 'synthetics', + ruleDataService.getFullAssetName('observability.synthetics'), + () => initializeRuleDataTemplatesPromise + ); + + initServerWithKibana( + { router: core.http.createRouter() }, + plugins, + ruleDataClient, + this.logger + ); core.savedObjects.registerType(umDynamicSettings); KibanaTelemetryAdapter.registerUsageCollector( plugins.usageCollection, () => this.savedObjectsClient ); + + return { + ruleRegistry: ruleDataClient, + }; } public start(core: CoreStart, _plugins: any) { diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 39e5c9bff202..f52b4a806335 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -5,21 +5,47 @@ * 2.0. */ +import { Logger } from 'kibana/server'; +import { createLifecycleRuleTypeFactory, RuleDataClient } from '../../rule_registry/server'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; -import { uptimeAlertTypeFactories } from './lib/alerts'; + +import { statusCheckAlertFactory } from './lib/alerts/status_check'; +import { tlsAlertFactory } from './lib/alerts/tls'; +import { tlsLegacyAlertFactory } from './lib/alerts/tls_legacy'; +import { durationAnomalyAlertFactory } from './lib/alerts/duration_anomaly'; export const initUptimeServer = ( server: UptimeCoreSetup, libs: UMServerLibs, - plugins: UptimeCorePlugins + plugins: UptimeCorePlugins, + ruleDataClient: RuleDataClient, + logger: Logger ) => { restApiRoutes.forEach((route) => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); - uptimeAlertTypeFactories.forEach((alertTypeFactory) => - plugins.alerting.registerType(alertTypeFactory(server, libs, plugins)) - ); + const { + alerting: { registerType }, + } = plugins; + + const statusAlert = statusCheckAlertFactory(server, libs, plugins); + const tlsLegacyAlert = tlsLegacyAlertFactory(server, libs, plugins); + const tlsAlert = tlsAlertFactory(server, libs, plugins); + const durationAlert = durationAnomalyAlertFactory(server, libs, plugins); + + const createLifecycleRuleType = createLifecycleRuleTypeFactory({ + ruleDataClient, + logger, + }); + + registerType(createLifecycleRuleType(statusAlert)); + registerType(createLifecycleRuleType(tlsAlert)); + registerType(createLifecycleRuleType(durationAlert)); + + /* TLS Legacy rule supported at least through 8.0. + * Not registered with RAC */ + registerType(tlsLegacyAlert); };