From d2a8bb90bec3ac23b3cab027d3f2fdfe9df6fa3e Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 19 Jan 2022 10:39:47 -0700 Subject: [PATCH 01/12] [Security Solution] Adds telemetry for legacy notifications and regular notifications at a finer grained level (#123332) ## Summary Related and previous PR: https://github.com/elastic/kibana/pull/122472 This removes the above structure from the PR above and instead opts to use a more finer grained level of telemetry. The new structure adds to each rule these four counters to the telemetry: * legacy_notifications_enabled - The number of legacy notifications on rules that are enabled/active * legacy_notifications_disabled - The number of legacy notifications on rules that are disabled/in-active * notifications_enabled - The number of notifications on rules that are enabled/active * notifications_disabled - The number of notifications on rules that are disabled/in-active For pre-built rules you have these booleans: * has_legacy_notification - True if the pre-built rule has a legacy notification attached, otherwise false. * has_notification - True if the pre-built rule has a notification attached, otherwise false. Note, both those booleans are `false` if the pre-built rule has no notifications attached and both can never be `true` together. These will show up within each rule type like for example on a query rule it will look like: ```json "detection_rule_usage": { "query": { "enabled": 2, "disabled": 1, "cases": 0, "legacy_notifications_enabled": 1, <-- New "legacy_notifications_disabled": 0, <-- New "notifications_enabled": 1, <-- New "notifications_disabled": 1 <-- New } ``` Within the counts/total sections it will show up on both the `elastic` rules and the `custom` rules like so: ```json "elastic_total": { "enabled": 0, "disabled": 0, "alerts": 0, "cases": 0, "legacy_notifications_enabled": 0, <-- New "legacy_notifications_disabled": 0, <-- New "notifications_enabled": 0, <-- New "notifications_disabled": 0 <-- New }, "custom_total": { "enabled": 2, "disabled": 1, "alerts": 7218, "cases": 0, "legacy_notifications_enabled": 1, <-- New "legacy_notifications_disabled": 0, <-- New "notifications_enabled": 1, <-- New "notifications_disabled": 1 <-- New } ``` For pre-built it will be: ```json "detection_rule_detail": [ { "rule_name": "Potential Evasion via Filter Manager", "rule_id": "06dceabf-adca-48af-ac79-ffdf4c3b1e9a", "rule_type": "eql", "rule_version": 8, "enabled": false, "elastic_rule": true, "created_on": "2022-01-19T01:29:25.540Z", "updated_on": "2022-01-19T01:29:25.540Z", "alert_count_daily": 0, "cases_count_total": 0, "has_legacy_notification": false, <-- New "has_notification": false <-- New }, ``` Screen shot of it if you go to "Advanced settings -> cluster data": Screen Shot 2022-01-18 at 6 27 14 PM Screen Shot 2022-01-18 at 6 30 33 PM Follow the manual test instructions on https://github.com/elastic/kibana/pull/122472 for how to test this. The same manual testing applies here for seeing how these work out. You should be able to see a higher granularity with these stats. ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../legacy_notifications/one_action.json | 2 +- .../server/usage/collector.ts | 130 +- .../detections/detection_rule_helpers.test.ts | 216 ++- .../detections/detection_rule_helpers.ts | 171 ++- .../usage/detections/detections.test.ts | 39 +- .../server/usage/detections/types.ts | 18 +- .../schema/xpack_plugins.json | 184 ++- .../tests/telemetry/README.md | 6 +- .../tests/telemetry/detection_rules.ts | 1233 +++++++++++++++-- .../tests/telemetry/index.ts | 1 - .../tests/telemetry/legacy_notifications.ts | 75 - 11 files changed, 1838 insertions(+), 237 deletions(-) delete mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/legacy_notifications.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/legacy_notifications/one_action.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/legacy_notifications/one_action.json index bf980e370e3a38c..cf03a90dfe72b79 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/legacy_notifications/one_action.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/legacy_notifications/one_action.json @@ -3,7 +3,7 @@ "interval": "1m", "actions": [ { - "id": "1fa31c30-3046-11ec-8971-1f3f7bae65af", + "id": "0cae9900-6e54-11ec-a124-bfe603780ab8", "group": "default", "params": { "message": "Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts" diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index d091c2a6e5e8ebd..4530dac725c7b14 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -10,6 +10,8 @@ import { CollectorFetchContext } from '../../../../../src/plugins/usage_collecti import { CollectorDependencies } from './types'; import { fetchDetectionsMetrics } from './detections'; import { SAVED_OBJECT_TYPES } from '../../../cases/common/constants'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; export type RegisterCollector = (deps: CollectorDependencies) => void; export interface UsageData { @@ -19,7 +21,11 @@ export interface UsageData { export async function getInternalSavedObjectsClient(core: CoreSetup) { return core.getStartServices().then(async ([coreStart]) => { // note: we include the "cases" and "alert" hidden types here otherwise we would not be able to query them. If at some point cases and alert is not considered a hidden type this can be removed - return coreStart.savedObjects.createInternalRepository(['alert', ...SAVED_OBJECT_TYPES]); + return coreStart.savedObjects.createInternalRepository([ + 'alert', + legacyRuleActionsSavedObjectType, + ...SAVED_OBJECT_TYPES, + ]); }); } @@ -51,6 +57,22 @@ export const registerCollector: RegisterCollector = ({ type: 'long', _meta: { description: 'Number of cases attached to query detection rule alerts' }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, threshold: { enabled: { @@ -71,6 +93,22 @@ export const registerCollector: RegisterCollector = ({ description: 'Number of cases attached to threshold detection rule alerts', }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, eql: { enabled: { type: 'long', _meta: { description: 'Number of eql rules enabled' } }, @@ -83,6 +121,22 @@ export const registerCollector: RegisterCollector = ({ type: 'long', _meta: { description: 'Number of cases attached to eql detection rule alerts' }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, machine_learning: { enabled: { @@ -103,6 +157,22 @@ export const registerCollector: RegisterCollector = ({ description: 'Number of cases attached to machine_learning detection rule alerts', }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, threat_match: { enabled: { @@ -123,11 +193,21 @@ export const registerCollector: RegisterCollector = ({ description: 'Number of cases attached to threat_match detection rule alerts', }, }, - }, - legacy_notifications: { - total: { + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { type: 'long', - _meta: { description: 'Number of legacy notifications still in use' }, + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, }, }, elastic_total: { @@ -144,6 +224,22 @@ export const registerCollector: RegisterCollector = ({ type: 'long', _meta: { description: 'Number of cases attached to elastic detection rule alerts' }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, custom_total: { enabled: { type: 'long', _meta: { description: 'Number of custom rules enabled' } }, @@ -156,6 +252,22 @@ export const registerCollector: RegisterCollector = ({ type: 'long', _meta: { description: 'Number of cases attached to custom detection rule alerts' }, }, + legacy_notifications_enabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications enabled' }, + }, + legacy_notifications_disabled: { + type: 'long', + _meta: { description: 'Number of legacy notifications disabled' }, + }, + notifications_enabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, + notifications_disabled: { + type: 'long', + _meta: { description: 'Number of notifications enabled' }, + }, }, }, detection_rule_detail: { @@ -198,6 +310,14 @@ export const registerCollector: RegisterCollector = ({ type: 'long', _meta: { description: 'The number of total cases generated by a rule' }, }, + has_legacy_notification: { + type: 'boolean', + _meta: { description: 'True if this rule has a legacy notification' }, + }, + has_notification: { + type: 'boolean', + _meta: { description: 'True if this rule has a notification' }, + }, }, }, }, diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts index 3c35296bafb46ce..c19e7b18f9e72c3 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts @@ -8,13 +8,25 @@ import { initialDetectionRulesUsage, updateDetectionRuleUsage } from './detection_rule_helpers'; import { DetectionRuleMetric, DetectionRulesTypeUsage } from './types'; -const createStubRule = ( - ruleType: string, - enabled: boolean, - elasticRule: boolean, - alertCount: number, - caseCount: number -): DetectionRuleMetric => ({ +interface StubRuleOptions { + ruleType: string; + enabled: boolean; + elasticRule: boolean; + alertCount: number; + caseCount: number; + hasLegacyNotification: boolean; + hasNotification: boolean; +} + +const createStubRule = ({ + ruleType, + enabled, + elasticRule, + alertCount, + caseCount, + hasLegacyNotification, + hasNotification, +}: StubRuleOptions): DetectionRuleMetric => ({ rule_name: 'rule-name', rule_id: 'id-123', rule_type: ruleType, @@ -25,12 +37,22 @@ const createStubRule = ( updated_on: '2022-01-06T20:02:45.306Z', alert_count_daily: alertCount, cases_count_total: caseCount, + has_legacy_notification: hasLegacyNotification, + has_notification: hasNotification, }); describe('Detections Usage and Metrics', () => { describe('Update metrics with rule information', () => { it('Should update elastic and eql rule metric total', async () => { - const stubRule = createStubRule('eql', true, true, 1, 1); + const stubRule = createStubRule({ + ruleType: 'eql', + enabled: true, + elasticRule: true, + alertCount: 1, + caseCount: 1, + hasLegacyNotification: false, + hasNotification: false, + }); const usage = updateDetectionRuleUsage(stubRule, initialDetectionRulesUsage); expect(usage).toEqual({ @@ -40,22 +62,70 @@ describe('Detections Usage and Metrics', () => { cases: 1, disabled: 0, enabled: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, eql: { alerts: 1, cases: 1, disabled: 0, enabled: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }); }); it('Should update based on multiple metrics', async () => { - const stubEqlRule = createStubRule('eql', true, true, 1, 1); - const stubQueryRuleOne = createStubRule('query', true, true, 5, 2); - const stubQueryRuleTwo = createStubRule('query', true, false, 5, 2); - const stubMachineLearningOne = createStubRule('machine_learning', false, false, 0, 10); - const stubMachineLearningTwo = createStubRule('machine_learning', true, true, 22, 44); + const stubEqlRule = createStubRule({ + ruleType: 'eql', + enabled: true, + elasticRule: true, + alertCount: 1, + caseCount: 1, + hasLegacyNotification: false, + hasNotification: false, + }); + const stubQueryRuleOne = createStubRule({ + ruleType: 'query', + enabled: true, + elasticRule: true, + alertCount: 5, + caseCount: 2, + hasLegacyNotification: false, + hasNotification: false, + }); + const stubQueryRuleTwo = createStubRule({ + ruleType: 'query', + enabled: true, + elasticRule: false, + alertCount: 5, + caseCount: 2, + hasLegacyNotification: false, + hasNotification: false, + }); + const stubMachineLearningOne = createStubRule({ + ruleType: 'machine_learning', + enabled: false, + elasticRule: false, + alertCount: 0, + caseCount: 10, + hasLegacyNotification: false, + hasNotification: false, + }); + const stubMachineLearningTwo = createStubRule({ + ruleType: 'machine_learning', + enabled: true, + elasticRule: true, + alertCount: 22, + caseCount: 44, + hasLegacyNotification: false, + hasNotification: false, + }); let usage = updateDetectionRuleUsage(stubEqlRule, initialDetectionRulesUsage); usage = updateDetectionRuleUsage(stubQueryRuleOne, usage); @@ -70,32 +140,152 @@ describe('Detections Usage and Metrics', () => { cases: 12, disabled: 1, enabled: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, elastic_total: { alerts: 28, cases: 47, disabled: 0, enabled: 3, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, eql: { alerts: 1, cases: 1, disabled: 0, enabled: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, machine_learning: { alerts: 22, cases: 54, disabled: 1, enabled: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, query: { alerts: 10, cases: 4, disabled: 0, enabled: 2, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }); }); + + describe('table tests of "ruleType", "enabled", "elasticRule", and "legacyNotification"', () => { + test.each` + ruleType | enabled | hasLegacyNotification | hasNotification | expectedLegacyNotificationsEnabled | expectedLegacyNotificationsDisabled | expectedNotificationsEnabled | expectedNotificationsDisabled + ${'eql'} | ${true} | ${true} | ${false} | ${1} | ${0} | ${0} | ${0} + ${'eql'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'eql'} | ${false} | ${false} | ${true} | ${0} | ${0} | ${0} | ${1} + ${'eql'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'eql'} | ${false} | ${true} | ${false} | ${0} | ${1} | ${0} | ${0} + ${'eql'} | ${false} | ${false} | ${false} | ${0} | ${0} | ${0} | ${0} + ${'query'} | ${true} | ${true} | ${false} | ${1} | ${0} | ${0} | ${0} + ${'query'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'query'} | ${false} | ${false} | ${true} | ${0} | ${0} | ${0} | ${1} + ${'query'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'query'} | ${false} | ${true} | ${false} | ${0} | ${1} | ${0} | ${0} + ${'query'} | ${false} | ${false} | ${false} | ${0} | ${0} | ${0} | ${0} + ${'threshold'} | ${true} | ${true} | ${false} | ${1} | ${0} | ${0} | ${0} + ${'threshold'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'threshold'} | ${false} | ${false} | ${true} | ${0} | ${0} | ${0} | ${1} + ${'threshold'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'threshold'} | ${false} | ${true} | ${false} | ${0} | ${1} | ${0} | ${0} + ${'threshold'} | ${false} | ${false} | ${false} | ${0} | ${0} | ${0} | ${0} + ${'machine_learning'} | ${true} | ${true} | ${false} | ${1} | ${0} | ${0} | ${0} + ${'machine_learning'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'machine_learning'} | ${false} | ${false} | ${true} | ${0} | ${0} | ${0} | ${1} + ${'machine_learning'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'machine_learning'} | ${false} | ${true} | ${false} | ${0} | ${1} | ${0} | ${0} + ${'machine_learning'} | ${false} | ${false} | ${false} | ${0} | ${0} | ${0} | ${0} + ${'threat_match'} | ${true} | ${true} | ${false} | ${1} | ${0} | ${0} | ${0} + ${'threat_match'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'threat_match'} | ${false} | ${false} | ${true} | ${0} | ${0} | ${0} | ${1} + ${'threat_match'} | ${true} | ${false} | ${true} | ${0} | ${0} | ${1} | ${0} + ${'threat_match'} | ${false} | ${true} | ${false} | ${0} | ${1} | ${0} | ${0} + ${'threat_match'} | ${false} | ${false} | ${false} | ${0} | ${0} | ${0} | ${0} + `( + 'expect { "ruleType": $ruleType, "enabled": $enabled, "hasLegacyNotification": $hasLegacyNotification, "hasNotification": $hasNotification } to equal { legacy_notifications_enabled: $expectedLegacyNotificationsEnabled, legacy_notifications_disabled: $expectedLegacyNotificationsDisabled, notifications_enabled: $expectedNotificationsEnabled, notifications_disabled, $expectedNotificationsDisabled }', + ({ + ruleType, + enabled, + hasLegacyNotification, + hasNotification, + expectedLegacyNotificationsEnabled, + expectedLegacyNotificationsDisabled, + expectedNotificationsEnabled, + expectedNotificationsDisabled, + }) => { + const rule1 = createStubRule({ + ruleType, + enabled, + elasticRule: false, + hasLegacyNotification, + hasNotification, + alertCount: 0, + caseCount: 0, + }); + const usage = updateDetectionRuleUsage(rule1, initialDetectionRulesUsage) as ReturnType< + typeof updateDetectionRuleUsage + > & { [key: string]: unknown }; + expect(usage[ruleType]).toEqual( + expect.objectContaining({ + legacy_notifications_enabled: expectedLegacyNotificationsEnabled, + legacy_notifications_disabled: expectedLegacyNotificationsDisabled, + notifications_enabled: expectedNotificationsEnabled, + notifications_disabled: expectedNotificationsDisabled, + }) + ); + + // extra test where we add everything by 1 to ensure that the addition happens with the correct rule type + const rule2 = createStubRule({ + ruleType, + enabled, + elasticRule: false, + hasLegacyNotification, + hasNotification, + alertCount: 0, + caseCount: 0, + }); + const usageAddedByOne = updateDetectionRuleUsage(rule2, usage) as ReturnType< + typeof updateDetectionRuleUsage + > & { [key: string]: unknown }; + + expect(usageAddedByOne[ruleType]).toEqual( + expect.objectContaining({ + legacy_notifications_enabled: + expectedLegacyNotificationsEnabled !== 0 + ? expectedLegacyNotificationsEnabled + 1 + : 0, + legacy_notifications_disabled: + expectedLegacyNotificationsDisabled !== 0 + ? expectedLegacyNotificationsDisabled + 1 + : 0, + notifications_enabled: + expectedNotificationsEnabled !== 0 ? expectedNotificationsEnabled + 1 : 0, + notifications_disabled: + expectedNotificationsDisabled !== 0 ? expectedNotificationsDisabled + 1 : 0, + }) + ); + } + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts index 87e484ae2b3d406..8163a736696745e 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts @@ -15,7 +15,6 @@ import { SAVED_QUERY_RULE_TYPE_ID, } from '@kbn/securitysolution-rules'; import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; -import { LEGACY_NOTIFICATIONS_ID } from '../../../common/constants'; import { CASE_COMMENT_SAVED_OBJECT } from '../../../../cases/common/constants'; import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; @@ -30,6 +29,10 @@ import type { RuleSearchResult, DetectionMetrics, } from './types'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; +// eslint-disable-next-line no-restricted-imports +import { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../lib/detection_engine/rule_actions/legacy_types'; /** * Initial detection metrics initialized. @@ -63,45 +66,70 @@ export const initialDetectionRulesUsage: DetectionRulesTypeUsage = { disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, threshold: { enabled: 0, disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, eql: { enabled: 0, disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, machine_learning: { enabled: 0, disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, threat_match: { enabled: 0, disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, elastic_total: { enabled: 0, disabled: 0, alerts: 0, cases: 0, - }, - legacy_notifications: { - total: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, custom_total: { enabled: 0, disabled: 0, alerts: 0, cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }; @@ -112,6 +140,16 @@ export const updateDetectionRuleUsage = ( ): DetectionRulesTypeUsage => { let updatedUsage = usage; + const legacyNotificationEnabled = + detectionRuleMetric.has_legacy_notification && detectionRuleMetric.enabled; + + const legacyNotificationDisabled = + detectionRuleMetric.has_legacy_notification && !detectionRuleMetric.enabled; + + const notificationEnabled = detectionRuleMetric.has_notification && detectionRuleMetric.enabled; + + const notificationDisabled = detectionRuleMetric.has_notification && !detectionRuleMetric.enabled; + if (detectionRuleMetric.rule_type === 'query') { updatedUsage = { ...usage, @@ -121,6 +159,18 @@ export const updateDetectionRuleUsage = ( disabled: !detectionRuleMetric.enabled ? usage.query.disabled + 1 : usage.query.disabled, alerts: usage.query.alerts + detectionRuleMetric.alert_count_daily, cases: usage.query.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage.query.legacy_notifications_enabled + 1 + : usage.query.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage.query.legacy_notifications_disabled + 1 + : usage.query.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage.query.notifications_enabled + 1 + : usage.query.notifications_enabled, + notifications_disabled: notificationDisabled + ? usage.query.notifications_disabled + 1 + : usage.query.notifications_disabled, }, }; } else if (detectionRuleMetric.rule_type === 'threshold') { @@ -136,6 +186,18 @@ export const updateDetectionRuleUsage = ( : usage.threshold.disabled, alerts: usage.threshold.alerts + detectionRuleMetric.alert_count_daily, cases: usage.threshold.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage.threshold.legacy_notifications_enabled + 1 + : usage.threshold.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage.threshold.legacy_notifications_disabled + 1 + : usage.threshold.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage.threshold.notifications_enabled + 1 + : usage.threshold.notifications_enabled, + notifications_disabled: notificationDisabled + ? usage.threshold.notifications_disabled + 1 + : usage.threshold.notifications_disabled, }, }; } else if (detectionRuleMetric.rule_type === 'eql') { @@ -147,6 +209,18 @@ export const updateDetectionRuleUsage = ( disabled: !detectionRuleMetric.enabled ? usage.eql.disabled + 1 : usage.eql.disabled, alerts: usage.eql.alerts + detectionRuleMetric.alert_count_daily, cases: usage.eql.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage.eql.legacy_notifications_enabled + 1 + : usage.eql.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage.eql.legacy_notifications_disabled + 1 + : usage.eql.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage.eql.notifications_enabled + 1 + : usage.eql.notifications_enabled, + notifications_disabled: notificationDisabled + ? usage.eql.notifications_disabled + 1 + : usage.eql.notifications_disabled, }, }; } else if (detectionRuleMetric.rule_type === 'machine_learning') { @@ -162,6 +236,18 @@ export const updateDetectionRuleUsage = ( : usage.machine_learning.disabled, alerts: usage.machine_learning.alerts + detectionRuleMetric.alert_count_daily, cases: usage.machine_learning.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage.machine_learning.legacy_notifications_enabled + 1 + : usage.machine_learning.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage.machine_learning.legacy_notifications_disabled + 1 + : usage.machine_learning.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage.machine_learning.notifications_enabled + 1 + : usage.machine_learning.notifications_enabled, + notifications_disabled: notificationDisabled + ? usage.machine_learning.notifications_disabled + 1 + : usage.machine_learning.notifications_disabled, }, }; } else if (detectionRuleMetric.rule_type === 'threat_match') { @@ -177,6 +263,18 @@ export const updateDetectionRuleUsage = ( : usage.threat_match.disabled, alerts: usage.threat_match.alerts + detectionRuleMetric.alert_count_daily, cases: usage.threat_match.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage.threat_match.legacy_notifications_enabled + 1 + : usage.threat_match.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage.threat_match.legacy_notifications_disabled + 1 + : usage.threat_match.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage.threat_match.notifications_enabled + 1 + : usage.threat_match.notifications_enabled, + notifications_disabled: notificationDisabled + ? usage.threat_match.notifications_disabled + 1 + : usage.threat_match.notifications_disabled, }, }; } @@ -194,6 +292,18 @@ export const updateDetectionRuleUsage = ( : updatedUsage.elastic_total.disabled, alerts: updatedUsage.elastic_total.alerts + detectionRuleMetric.alert_count_daily, cases: updatedUsage.elastic_total.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? updatedUsage.elastic_total.legacy_notifications_enabled + 1 + : updatedUsage.elastic_total.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? updatedUsage.elastic_total.legacy_notifications_disabled + 1 + : updatedUsage.elastic_total.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? updatedUsage.elastic_total.notifications_enabled + 1 + : updatedUsage.elastic_total.notifications_enabled, + notifications_disabled: notificationDisabled + ? updatedUsage.elastic_total.notifications_disabled + 1 + : updatedUsage.elastic_total.notifications_disabled, }, }; } else { @@ -209,6 +319,18 @@ export const updateDetectionRuleUsage = ( : updatedUsage.custom_total.disabled, alerts: updatedUsage.custom_total.alerts + detectionRuleMetric.alert_count_daily, cases: updatedUsage.custom_total.cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? updatedUsage.custom_total.legacy_notifications_enabled + 1 + : updatedUsage.custom_total.legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? updatedUsage.custom_total.legacy_notifications_disabled + 1 + : updatedUsage.custom_total.legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? updatedUsage.custom_total.notifications_enabled + 1 + : updatedUsage.custom_total.notifications_enabled, + notifications_disabled: notificationDisabled + ? updatedUsage.custom_total.notifications_disabled + 1 + : updatedUsage.custom_total.notifications_disabled, }, }; } @@ -287,18 +409,28 @@ export const getDetectionRuleMetrics = async ( filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: alert`, }); - // We get just 1 per a single page so we can get the total count to add to the rulesUsage. // Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function. - const legacyNotificationsCount = ( - await savedObjectClient.find({ - type: 'alert', + const legacyRuleActions = + await savedObjectClient.find({ + type: legacyRuleActionsSavedObjectType, page: 1, + perPage: MAX_RESULTS_WINDOW, namespaces: ['*'], - perPage: 1, - filter: `alert.attributes.alertTypeId: ${LEGACY_NOTIFICATIONS_ID}`, - }) - ).total; - rulesUsage = { ...rulesUsage, legacy_notifications: { total: legacyNotificationsCount } }; + }); + + const legacyNotificationRuleIds = legacyRuleActions.saved_objects.reduce( + (cache, legacyNotificationsObject) => { + const ruleRef = legacyNotificationsObject.references.find( + (reference) => reference.name === 'alert_0' && reference.type === 'alert' + ); + if (ruleRef != null) { + const enabled = legacyNotificationsObject.attributes.ruleThrottle !== 'no_actions'; + cache.set(ruleRef.id, { enabled }); + } + return cache; + }, + new Map() + ); const casesCache = cases.saved_objects.reduce((cache, { attributes: casesObject }) => { const ruleId = casesObject.rule.id; @@ -320,6 +452,17 @@ export const getDetectionRuleMetrics = async ( const ruleObjects = ruleResults.hits.hits.map((hit) => { const ruleId = hit._id.split(':')[1]; const isElastic = isElasticRule(hit._source?.alert.tags); + + // Even if the legacy notification is set to "no_actions" we still count the rule as having a legacy notification that is not migrated yet. + const hasLegacyNotification = legacyNotificationRuleIds.get(ruleId) != null; + + // We only count a rule as having a notification and being "enabled" if it is _not_ set to "no_actions"/"muteAll" and it has at least one action within its array. + const hasNotification = + !hasLegacyNotification && + hit._source?.alert.actions != null && + hit._source?.alert.actions.length > 0 && + hit._source?.alert.muteAll !== true; + return { rule_name: hit._source?.alert.name, rule_id: hit._source?.alert.params.ruleId, @@ -331,6 +474,8 @@ export const getDetectionRuleMetrics = async ( updated_on: hit._source?.alert.updatedAt, alert_count_daily: alertsCache.get(ruleId) || 0, cases_count_total: casesCache.get(ruleId) || 0, + has_legacy_notification: hasLegacyNotification, + has_notification: hasNotification, } as DetectionRuleMetric; }); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index 7793552510c759f..d08f915e4428f56 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -70,6 +70,8 @@ describe('Detections Usage and Metrics', () => { rule_type: 'query', rule_version: 4, updated_on: '2021-03-23T17:15:59.634Z', + has_legacy_notification: false, + has_notification: false, }, ], detection_rule_usage: { @@ -79,15 +81,20 @@ describe('Detections Usage and Metrics', () => { disabled: 1, alerts: 3400, cases: 1, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, elastic_total: { alerts: 3400, cases: 1, disabled: 1, enabled: 0, - }, - legacy_notifications: { - total: 4, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }, }, @@ -118,15 +125,20 @@ describe('Detections Usage and Metrics', () => { cases: 1, disabled: 1, enabled: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, query: { alerts: 800, cases: 1, disabled: 1, enabled: 0, - }, - legacy_notifications: { - total: 4, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }, }, @@ -144,7 +156,7 @@ describe('Detections Usage and Metrics', () => { savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); - expect(result).toEqual({ + expect(result).toEqual({ ...getInitialDetectionMetrics(), detection_rules: { detection_rule_detail: [ @@ -159,6 +171,8 @@ describe('Detections Usage and Metrics', () => { rule_type: 'query', rule_version: 4, updated_on: '2021-03-23T17:15:59.634Z', + has_legacy_notification: false, + has_notification: false, }, ], detection_rule_usage: { @@ -168,15 +182,20 @@ describe('Detections Usage and Metrics', () => { cases: 1, disabled: 1, enabled: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, query: { alerts: 0, cases: 1, disabled: 1, enabled: 0, - }, - legacy_notifications: { - total: 4, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, }, }, }, diff --git a/x-pack/plugins/security_solution/server/usage/detections/types.ts b/x-pack/plugins/security_solution/server/usage/detections/types.ts index ed0e8a4e5e99f21..b2a9cf7af486136 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/types.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/types.ts @@ -30,7 +30,9 @@ export interface RuleSearchResult { tags: string[]; createdAt: string; updatedAt: string; + muteAll: boolean | undefined | null; params: DetectionRuleParms; + actions: unknown[]; }; } @@ -55,8 +57,11 @@ interface FeatureTypeUsage { disabled: number; alerts: number; cases: number; + legacy_notifications_enabled: number; + legacy_notifications_disabled: number; + notifications_enabled: number; + notifications_disabled: number; } - export interface DetectionRulesTypeUsage { query: FeatureTypeUsage; threshold: FeatureTypeUsage; @@ -65,7 +70,6 @@ export interface DetectionRulesTypeUsage { threat_match: FeatureTypeUsage; elastic_total: FeatureTypeUsage; custom_total: FeatureTypeUsage; - legacy_notifications: LegacyNotifications; } export interface MlJobsUsage { @@ -129,6 +133,8 @@ export interface DetectionRuleMetric { updated_on: string; alert_count_daily: number; cases_count_total: number; + has_legacy_notification: boolean; + has_notification: boolean; } export interface AlertsAggregationResponse { @@ -162,11 +168,3 @@ export interface DetectionRuleAdoption { detection_rule_detail: DetectionRuleMetric[]; detection_rule_usage: DetectionRulesTypeUsage; } - -/** - * The legacy notifications that are still in use. - * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function - */ -export interface LegacyNotifications { - total: number; -} diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 02ad35bd0916cf4..799b183f7bbd64c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -6986,6 +6986,30 @@ "_meta": { "description": "Number of cases attached to query detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } }, @@ -7014,6 +7038,30 @@ "_meta": { "description": "Number of cases attached to threshold detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } }, @@ -7042,6 +7090,30 @@ "_meta": { "description": "Number of cases attached to eql detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } }, @@ -7070,6 +7142,30 @@ "_meta": { "description": "Number of cases attached to machine_learning detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } }, @@ -7098,15 +7194,29 @@ "_meta": { "description": "Number of cases attached to threat_match detection rule alerts" } - } - } - }, - "legacy_notifications": { - "properties": { - "total": { + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { "type": "long", "_meta": { - "description": "Number of legacy notifications still in use" + "description": "Number of notifications enabled" } } } @@ -7136,6 +7246,30 @@ "_meta": { "description": "Number of cases attached to elastic detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } }, @@ -7164,6 +7298,30 @@ "_meta": { "description": "Number of cases attached to custom detection rule alerts" } + }, + "legacy_notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications enabled" + } + }, + "legacy_notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of legacy notifications disabled" + } + }, + "notifications_enabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } + }, + "notifications_disabled": { + "type": "long", + "_meta": { + "description": "Number of notifications enabled" + } } } } @@ -7232,6 +7390,18 @@ "_meta": { "description": "The number of total cases generated by a rule" } + }, + "has_legacy_notification": { + "type": "boolean", + "_meta": { + "description": "True if this rule has a legacy notification" + } + }, + "has_notification": { + "type": "boolean", + "_meta": { + "description": "True if this rule has a notification" + } } } } diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/README.md b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/README.md index cfcf13e1d9f707b..2760001a20626ab 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/README.md +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/README.md @@ -1,8 +1,6 @@ These are tests for the telemetry rules within "security_solution/server/usage" * detection_rules -* legacy_notifications -Detection rules are tests around each of the rule types to affirm they work such as query, eql, etc... -Legacy notifications are tests around the legacy notification telemetry. Once legacy notifications are removed, -these tests can be removed too. +Detection rules are tests around each of the rule types to affirm they work such as query, eql, etc... This includes +legacy notifications. Once legacy notifications are moved, those tests can be removed too. diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts index bafc36c329eab33..561c8bc35647661 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts @@ -8,26 +8,30 @@ import expect from '@kbn/expect'; import { DetectionMetrics } from '../../../../../plugins/security_solution/server/usage/detections/types'; import { - EqlCreateSchema, - QueryCreateSchema, ThreatMatchCreateSchema, ThresholdCreateSchema, } from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { + createLegacyRuleAction, + createNewAction, createRule, createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, getEqlRuleForSignalTesting, + getRule, getRuleForSignalTesting, + getRuleWithWebHookAction, getSimpleMlRule, + getSimpleRule, getSimpleThreatMatch, getStats, getThresholdRuleForSignalTesting, installPrePackagedRules, waitForRuleSuccessOrStatus, waitForSignalsToBePresent, + updateRule, } from '../../../utils'; import { getInitialDetectionMetrics } from '../../../../../plugins/security_solution/server/usage/detections/detection_rule_helpers'; @@ -64,11 +68,769 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"kql" rule type', () => { - it('should show stats for active rule', async () => { - const rule: QueryCreateSchema = getRuleForSignalTesting(['telemetry']); + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { + const rule = getRuleForSignalTesting(['telemetry'], 'rule-1', false); + await createRule(supertest, log, rule); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "enabled"/"active" rule that does not have any actions', async () => { + const rule = getRuleForSignalTesting(['telemetry']); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getRuleForSignalTesting(['telemetry']); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, false, rule); + await createRule(supertest, log, ruleToCreate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + notifications_disabled: 1, + disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + notifications_disabled: 1, + disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + const rule = getRuleForSignalTesting(['telemetry']); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, true, rule); + const { id } = await createRule(supertest, log, ruleToCreate); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getRuleForSignalTesting(['telemetry'], 'rule-1', false); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + disabled: 1, + legacy_notifications_disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + legacy_notifications_disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "enabled"/"active"', async () => { + const rule = getRuleForSignalTesting(['telemetry']); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + query: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + }); + + describe('"eql" rule type', () => { + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry'], 'rule-1', false); + await createRule(supertest, log, rule); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "enabled"/"active" rule that does not have any actions', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry']); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry']); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, false, rule); + await createRule(supertest, log, ruleToCreate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + notifications_disabled: 1, + disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + notifications_disabled: 1, + disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry']); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, true, rule); + const { id } = await createRule(supertest, log, ruleToCreate); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry'], 'rule-1', false); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + disabled: 1, + legacy_notifications_disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + legacy_notifications_disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "enabled"/"active"', async () => { + const rule = getEqlRuleForSignalTesting(['telemetry']); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + eql: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.eql, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + }); + + describe('"threshold" rule type', () => { + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry'], 'rule-1', false), + threshold: { + field: 'keyword', + value: 1, + }, + }; + await createRule(supertest, log, rule); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "enabled"/"active" rule that does not have any actions', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry']), + threshold: { + field: 'keyword', + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry'], 'rule-1', false), + threshold: { + field: 'keyword', + value: 1, + }, + }; + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, false, rule); + await createRule(supertest, log, ruleToCreate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + notifications_disabled: 1, + disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + notifications_disabled: 1, + disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry']), + threshold: { + field: 'keyword', + value: 1, + }, + }; + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, true, rule); + const { id } = await createRule(supertest, log, ruleToCreate); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + alerts: 4, + notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "disabled"/"in-active"', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry'], 'rule-1', false), + threshold: { + field: 'keyword', + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + disabled: 1, + legacy_notifications_disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + legacy_notifications_disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "enabled"/"active"', async () => { + const rule: ThresholdCreateSchema = { + ...getThresholdRuleForSignalTesting(['telemetry']), + threshold: { + field: 'keyword', + value: 1, + }, + }; const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); await waitForRuleSuccessOrStatus(supertest, log, id); await waitForSignalsToBePresent(supertest, log, 4, [id]); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + threshold: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threshold, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + }); + + // Note: We don't actually find signals with these tests as we don't have a good way of signal finding with ML rules. + describe('"ml" rule type', () => { + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { + const rule = getSimpleMlRule(); + await createRule(supertest, log, rule); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "enabled"/"active" rule that does not have any actions', async () => { + const rule = getSimpleMlRule('rule-1', true); + await createRule(supertest, log, rule); + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, + enabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + enabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getSimpleMlRule(); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, false, rule); + await createRule(supertest, log, ruleToCreate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + const expected: DetectionMetrics = { + ...getInitialDetectionMetrics(), + detection_rules: { + ...getInitialDetectionMetrics().detection_rules, + detection_rule_usage: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, + notifications_disabled: 1, + disabled: 1, + }, + custom_total: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + notifications_disabled: 1, + disabled: 1, + }, + }, + }, + }; + expect(stats).to.eql(expected); + }); + }); + + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + const rule = getSimpleMlRule('rule-1', true); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, true, rule); + await createRule(supertest, log, ruleToCreate); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -77,15 +839,16 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - query: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, enabled: 1, - alerts: 4, + notifications_enabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, enabled: 1, - alerts: 4, + notifications_enabled: 1, }, }, }, @@ -94,9 +857,12 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show stats for in-active rule', async () => { - const rule: QueryCreateSchema = getRuleForSignalTesting(['telemetry'], 'rule-1', false); - await createRule(supertest, log, rule); + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getSimpleMlRule(); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -105,13 +871,16 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - query: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, disabled: 1, + legacy_notifications_disabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, disabled: 1, + legacy_notifications_disabled: 1, }, }, }, @@ -119,14 +888,13 @@ export default ({ getService }: FtrProviderContext) => { expect(stats).to.eql(expected); }); }); - }); - describe('"eql" rule type', () => { - it('should show stats for active rule', async () => { - const rule: EqlCreateSchema = getEqlRuleForSignalTesting(['telemetry']); + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "enabled"/"active"', async () => { + const rule = getSimpleMlRule('rule-1', true); const { id } = await createRule(supertest, log, rule); - await waitForRuleSuccessOrStatus(supertest, log, id); - await waitForSignalsToBePresent(supertest, log, 4, [id]); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -135,15 +903,16 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - eql: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + machine_learning: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage + .machine_learning, enabled: 1, - alerts: 4, + legacy_notifications_enabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, enabled: 1, - alerts: 4, + legacy_notifications_enabled: 1, }, }, }, @@ -151,9 +920,11 @@ export default ({ getService }: FtrProviderContext) => { expect(stats).to.eql(expected); }); }); + }); - it('should show stats for in-active rule', async () => { - const rule: EqlCreateSchema = getEqlRuleForSignalTesting(['telemetry'], 'rule-1', false); + describe('"indicator_match/threat_match" rule type', () => { + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "disabled"/"in-active" rule that does not have any actions', async () => { + const rule = getSimpleThreatMatch(); await createRule(supertest, log, rule); await retry.try(async () => { const stats = await getStats(supertest, log); @@ -163,13 +934,21 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - eql: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + threat_match: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, disabled: 1, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, }, }, }, @@ -177,16 +956,23 @@ export default ({ getService }: FtrProviderContext) => { expect(stats).to.eql(expected); }); }); - }); - describe('"threshold" rule type', () => { - it('should show stats for active rule', async () => { - const rule: ThresholdCreateSchema = { - ...getThresholdRuleForSignalTesting(['telemetry']), - threshold: { - field: 'keyword', - value: 1, - }, + it('should show "notifications_enabled", "notifications_disabled" "legacy_notifications_enabled", "legacy_notifications_disabled", all to be "0" for "enabled"/"active" rule that does not have any actions', async () => { + const rule: ThreatMatchCreateSchema = { + ...getSimpleThreatMatch('rule-1', true), + index: ['telemetry'], + threat_index: ['telemetry'], + threat_mapping: [ + { + entries: [ + { + field: 'keyword', + value: 'keyword', + type: 'mapping', + }, + ], + }, + ], }; const { id } = await createRule(supertest, log, rule); await waitForRuleSuccessOrStatus(supertest, log, id); @@ -199,15 +985,23 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - threshold: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + threat_match: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, enabled: 1, alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, enabled: 1, alerts: 4, + notifications_enabled: 0, + notifications_disabled: 0, + legacy_notifications_disabled: 0, + legacy_notifications_enabled: 0, }, }, }, @@ -216,15 +1010,12 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show stats for in-active rule', async () => { - const rule: ThresholdCreateSchema = { - ...getThresholdRuleForSignalTesting(['telemetry'], 'rule-1', false), - threshold: { - field: 'keyword', - value: 1, - }, - }; - await createRule(supertest, log, rule); + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getSimpleThreatMatch(); + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, false, rule); + await createRule(supertest, log, ruleToCreate); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -233,12 +1024,14 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - threshold: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + threat_match: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, + notifications_disabled: 1, disabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, + notifications_disabled: 1, disabled: 1, }, }, @@ -247,13 +1040,30 @@ export default ({ getService }: FtrProviderContext) => { expect(stats).to.eql(expected); }); }); - }); - describe('"ml" rule type', () => { - // Note: We don't actually find signals with this test as we don't have a good way of signal finding with ML rules. - it('should show stats for active rule', async () => { - const rule = getSimpleMlRule('rule-1', true); - await createRule(supertest, log, rule); + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + const rule: ThreatMatchCreateSchema = { + ...getSimpleThreatMatch('rule-1', true), + index: ['telemetry'], + threat_index: ['telemetry'], + threat_mapping: [ + { + entries: [ + { + field: 'keyword', + value: 'keyword', + type: 'mapping', + }, + ], + }, + ], + }; + const hookAction = await createNewAction(supertest, log); + const ruleToCreate = getRuleWithWebHookAction(hookAction.id, true, rule); + const { id } = await createRule(supertest, log, ruleToCreate); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 4, [id]); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -262,13 +1072,17 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - machine_learning: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + threat_match: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, enabled: 1, + alerts: 4, + notifications_enabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, enabled: 1, + alerts: 4, + notifications_enabled: 1, }, }, }, @@ -277,9 +1091,12 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show stats for in-active rule', async () => { - const rule = getSimpleMlRule(); - await createRule(supertest, log, rule); + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "disabled"/"in-active"', async () => { + const rule = getSimpleThreatMatch(); + const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -288,13 +1105,15 @@ export default ({ getService }: FtrProviderContext) => { ...getInitialDetectionMetrics().detection_rules, detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - machine_learning: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, + threat_match: { + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, disabled: 1, + legacy_notifications_disabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, disabled: 1, + legacy_notifications_disabled: 1, }, }, }, @@ -302,10 +1121,8 @@ export default ({ getService }: FtrProviderContext) => { expect(stats).to.eql(expected); }); }); - }); - describe('"indicator_match/threat_match" rule type', () => { - it('should show stats for active rule', async () => { + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" legacy action(s) and the alert is "enabled"/"active"', async () => { const rule: ThreatMatchCreateSchema = { ...getSimpleThreatMatch('rule-1', true), index: ['telemetry'], @@ -323,8 +1140,11 @@ export default ({ getService }: FtrProviderContext) => { ], }; const { id } = await createRule(supertest, log, rule); + const hookAction = await createNewAction(supertest, log); + await createLegacyRuleAction(supertest, id, hookAction.id); await waitForRuleSuccessOrStatus(supertest, log, id); await waitForSignalsToBePresent(supertest, log, 4, [id]); + await retry.try(async () => { const stats = await getStats(supertest, log); const expected: DetectionMetrics = { @@ -334,40 +1154,16 @@ export default ({ getService }: FtrProviderContext) => { detection_rule_usage: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, threat_match: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, - enabled: 1, + ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.threat_match, alerts: 4, - }, - custom_total: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, enabled: 1, - alerts: 4, - }, - }, - }, - }; - expect(stats).to.eql(expected); - }); - }); - - it('should show stats for in-active rule', async () => { - const rule = getSimpleThreatMatch(); - await createRule(supertest, log, rule); - await retry.try(async () => { - const stats = await getStats(supertest, log); - const expected: DetectionMetrics = { - ...getInitialDetectionMetrics(), - detection_rules: { - ...getInitialDetectionMetrics().detection_rules, - detection_rule_usage: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage, - threat_match: { - ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.query, - disabled: 1, + legacy_notifications_enabled: 1, }, custom_total: { ...getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total, - disabled: 1, + alerts: 4, + enabled: 1, + legacy_notifications_enabled: 1, }, }, }, @@ -377,7 +1173,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('"pre-packaged" rules', async () => { + describe('"pre-packaged"/"immutable" rules', async () => { it('should show stats for totals for in-active pre-packaged rules', async () => { await installPrePackagedRules(supertest, log); await retry.try(async () => { @@ -385,12 +1181,33 @@ export default ({ getService }: FtrProviderContext) => { expect(stats.detection_rules.detection_rule_usage.elastic_total.enabled).above(0); expect(stats.detection_rules.detection_rule_usage.elastic_total.disabled).above(0); expect(stats.detection_rules.detection_rule_usage.elastic_total.enabled).above(0); - expect(stats.detection_rules.detection_rule_usage.custom_total.enabled).equal(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_enabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_enabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_disabled + ).to.eql(0); expect(stats.detection_rules.detection_rule_detail.length).above(0); + expect(stats.detection_rules.detection_rule_usage.custom_total).to.eql({ + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }); }); }); - it('should show stats for the detection_rule_details for pre-packaged rules', async () => { + it('should show stats for the detection_rule_details for a specific pre-packaged rule', async () => { await installPrePackagedRules(supertest, log); await retry.try(async () => { const stats = await getStats(supertest, log); @@ -402,7 +1219,7 @@ export default ({ getService }: FtrProviderContext) => { (rule) => rule.rule_id === '9a1a2dae-0b5f-4c3d-8305-a268d404c306' ); if (foundRule == null) { - throw new Error('Found rule should not be null'); + throw new Error('Found rule should not be null. Please change this end to end test.'); } const { created_on: createdOn, @@ -418,7 +1235,227 @@ export default ({ getService }: FtrProviderContext) => { elastic_rule: true, alert_count_daily: 0, cases_count_total: 0, + has_notification: false, + has_legacy_notification: false, + }); + }); + }); + + it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + await installPrePackagedRules(supertest, log); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json + const immutableRule = await getRule(supertest, log, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest, log); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); + await updateRule(supertest, log, ruleToUpdate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + + // We have to search by "rule_name" since the "rule_id" it is storing is the Saved Object ID and not the rule_id + const foundRule = stats.detection_rules.detection_rule_detail.find( + (rule) => rule.rule_id === '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + if (foundRule == null) { + throw new Error('Found rule should not be null. Please change this end to end test.'); + } + const { + created_on: createdOn, + updated_on: updatedOn, + rule_id: ruleId, + ...omittedFields + } = foundRule; + expect(omittedFields).to.eql({ + rule_name: 'Simple Rule Query', + rule_type: 'query', + rule_version: 3, + enabled: false, + elastic_rule: true, + alert_count_daily: 0, + cases_count_total: 0, + has_notification: true, + has_legacy_notification: false, + }); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_disabled + ).to.eql(1); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_enabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_enabled + ).to.eql(0); + expect(stats.detection_rules.detection_rule_usage.custom_total).to.eql( + getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total + ); + }); + }); + + it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + await installPrePackagedRules(supertest, log); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json + const immutableRule = await getRule(supertest, log, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest, log); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, true, newRuleToUpdate); + await updateRule(supertest, log, ruleToUpdate); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + + // We have to search by "rule_name" since the "rule_id" it is storing is the Saved Object ID and not the rule_id + const foundRule = stats.detection_rules.detection_rule_detail.find( + (rule) => rule.rule_id === '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + if (foundRule == null) { + throw new Error('Found rule should not be null. Please change this end to end test.'); + } + const { + created_on: createdOn, + updated_on: updatedOn, + rule_id: ruleId, + ...omittedFields + } = foundRule; + expect(omittedFields).to.eql({ + rule_name: 'Simple Rule Query', + rule_type: 'query', + rule_version: 3, + enabled: true, + elastic_rule: true, + alert_count_daily: 0, + cases_count_total: 0, + has_notification: true, + has_legacy_notification: false, + }); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_enabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_enabled + ).to.eql(1); + expect(stats.detection_rules.detection_rule_usage.custom_total).to.eql( + getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total + ); + }); + }); + + it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + await installPrePackagedRules(supertest, log); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json + const immutableRule = await getRule(supertest, log, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest, log); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, false); + await updateRule(supertest, log, newRuleToUpdate); + await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + // We have to search by "rule_name" since the "rule_id" it is storing is the Saved Object ID and not the rule_id + const foundRule = stats.detection_rules.detection_rule_detail.find( + (rule) => rule.rule_id === '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + if (foundRule == null) { + throw new Error('Found rule should not be null. Please change this end to end test.'); + } + const { + created_on: createdOn, + updated_on: updatedOn, + rule_id: ruleId, + ...omittedFields + } = foundRule; + expect(omittedFields).to.eql({ + rule_name: 'Simple Rule Query', + rule_type: 'query', + rule_version: 3, + enabled: false, + elastic_rule: true, + alert_count_daily: 0, + cases_count_total: 0, + has_notification: false, + has_legacy_notification: true, + }); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_enabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_disabled + ).to.eql(1); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_enabled + ).to.eql(0); + expect(stats.detection_rules.detection_rule_usage.custom_total).to.eql( + getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total + ); + }); + }); + + it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + await installPrePackagedRules(supertest, log); + // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: + // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json + const immutableRule = await getRule(supertest, log, '9a1a2dae-0b5f-4c3d-8305-a268d404c306'); + const hookAction = await createNewAction(supertest, log); + const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, true); + await updateRule(supertest, log, newRuleToUpdate); + await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); + + await retry.try(async () => { + const stats = await getStats(supertest, log); + // We have to search by "rule_name" since the "rule_id" it is storing is the Saved Object ID and not the rule_id + const foundRule = stats.detection_rules.detection_rule_detail.find( + (rule) => rule.rule_id === '9a1a2dae-0b5f-4c3d-8305-a268d404c306' + ); + if (foundRule == null) { + throw new Error('Found rule should not be null. Please change this end to end test.'); + } + const { + created_on: createdOn, + updated_on: updatedOn, + rule_id: ruleId, + ...omittedFields + } = foundRule; + expect(omittedFields).to.eql({ + rule_name: 'Simple Rule Query', + rule_type: 'query', + rule_version: 3, + enabled: true, + elastic_rule: true, + alert_count_daily: 0, + cases_count_total: 0, + has_notification: false, + has_legacy_notification: true, }); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_enabled + ).to.eql(1); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.legacy_notifications_disabled + ).to.eql(0); + expect( + stats.detection_rules.detection_rule_usage.elastic_total.notifications_enabled + ).to.eql(0); + expect(stats.detection_rules.detection_rule_usage.custom_total).to.eql( + getInitialDetectionMetrics().detection_rules.detection_rule_usage.custom_total + ); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/index.ts index 21676d614bb2064..cf9db6373033afb 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/index.ts @@ -13,7 +13,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => { describe('', function () { this.tags('ciGroup11'); loadTestFile(require.resolve('./detection_rules')); - loadTestFile(require.resolve('./legacy_notifications')); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/legacy_notifications.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/legacy_notifications.ts deleted file mode 100644 index aa406519e24439b..000000000000000 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/legacy_notifications.ts +++ /dev/null @@ -1,75 +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 expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { - createRule, - createSignalsIndex, - deleteAllAlerts, - deleteSignalsIndex, - getSimpleRule, - getStats, - getWebHookAction, -} from '../../../utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext) => { - const supertest = getService('supertest'); - const log = getService('log'); - const retry = getService('retry'); - - describe('legacy notification telemetry', async () => { - beforeEach(async () => { - await createSignalsIndex(supertest, log); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest, log); - await deleteAllAlerts(supertest, log); - }); - - it('should have 1 legacy notification when there is a rule on the default', async () => { - // create an connector/action - const { body: hookAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send(getWebHookAction()) - .expect(200); - - // create a rule without actions - const createRuleBody = await createRule(supertest, log, getSimpleRule('rule-1')); - - // attach the legacy notification - await supertest - .post(`/internal/api/detection/legacy/notifications?alert_id=${createRuleBody.id}`) - .set('kbn-xsrf', 'true') - .send({ - name: 'Legacy notification with one action', - interval: '1h', - actions: [ - { - id: hookAction.id, - group: 'default', - params: { - message: - 'Hourly\nRule {{context.rule.name}} generated {{state.signals_count}} alerts', - }, - actionTypeId: hookAction.actionTypeId, - }, - ], - }) - .expect(200); - - await retry.try(async () => { - const stats = await getStats(supertest, log); - // NOTE: We have to do "above 0" until this bug is fixed: https://github.com/elastic/kibana/issues/122456 because other tests are accumulating non-cleaned up legacy actions/notifications and this number isn't reliable at the moment - expect(stats.detection_rules.detection_rule_usage.legacy_notifications.total).to.above(0); - }); - }); - }); -}; From ec38f00bb192d35e12d4bda280ff9d80f7b3e377 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Wed, 19 Jan 2022 18:03:06 +0000 Subject: [PATCH 02/12] [APM] Show rows per page option tables pagination (#122658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add rows per page on tables * Set page options and default size at shared ManagedTable component * Fixed TransactionsTable, the rows option wasn’t working, use ManagedTable * Fix i18n * Make hidePerPageOptions false by default * fix snapshot * Improvements after PR review --- .../agent_configurations/List/index.tsx | 1 - .../custom_link/custom_link_table.tsx | 1 - .../error_group_list/index.tsx | 1 - .../service_inventory/service_list/index.tsx | 1 - .../app/service_node_overview/index.tsx | 2 - .../components/app/service_overview/index.tsx | 2 + .../index.tsx | 3 + .../app/trace_overview/trace_list.tsx | 1 - .../shared/dependencies_table/index.tsx | 4 +- .../__snapshots__/managed_table.test.tsx.snap | 14 ++++- .../components/shared/managed_table/index.tsx | 8 ++- .../shared/transactions_table/get_columns.tsx | 10 +--- .../shared/transactions_table/index.tsx | 57 +++++-------------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 15 files changed, 43 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx index 15efd28756b0b65..fc9250973fc3072 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/List/index.tsx @@ -245,7 +245,6 @@ export function AgentConfigurationList({ items={configurations} initialSortField="service.name" initialSortDirection="asc" - initialPageSize={20} /> ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/custom_link/custom_link_table.tsx b/x-pack/plugins/apm/public/components/app/Settings/custom_link/custom_link_table.tsx index 5ce98f8b10884ca..d2603538d84bfac 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/custom_link/custom_link_table.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/custom_link/custom_link_table.tsx @@ -119,7 +119,6 @@ export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { } items={filteredItems} columns={columns} - initialPageSize={10} initialSortField="@timestamp" initialSortDirection="desc" /> diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx index 65681a398d8e657..adeb3b175044474 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx @@ -233,7 +233,6 @@ function ErrorGroupList({ })} items={mainStatistics} columns={columns} - initialPageSize={25} initialSortField="occurrences" initialSortDirection="desc" sortItems={false} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 2d83f1f46bd3881..4617daac2ddcf93 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -315,7 +315,6 @@ export function ServiceList({ noItemsMessage={noItemsMessage} initialSortField={initialSortField} initialSortDirection="desc" - initialPageSize={50} sortFn={(itemsToSort, sortField, sortDirection) => { // For healthStatus, sort items by healthStatus first, then by TPM return sortField === 'healthStatus' diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 0436c27cdd6b7b0..379632d33a80839 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -25,7 +25,6 @@ import { truncate, unit } from '../../../utils/style'; import { ServiceNodeMetricOverviewLink } from '../../shared/links/apm/service_node_metric_overview_link'; import { ITableColumn, ManagedTable } from '../../shared/managed_table'; -const INITIAL_PAGE_SIZE = 25; const INITIAL_SORT_FIELD = 'cpu'; const INITIAL_SORT_DIRECTION = 'desc'; @@ -170,7 +169,6 @@ function ServiceNodeOverview() { })} items={items} columns={columns} - initialPageSize={INITIAL_PAGE_SIZE} initialSortField={INITIAL_SORT_FIELD} initialSortDirection={INITIAL_SORT_DIRECTION} /> diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 9e5508a5810df7f..2c30027770f4351 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -118,6 +118,7 @@ export function ServiceOverview() { isSingleColumn={isSingleColumn} start={start} end={end} + hidePerPageOptions={true} /> @@ -164,6 +165,7 @@ export function ServiceOverview() { {i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index cbf60b7b59e4d83..255dfbdeb427a93 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -24,12 +24,14 @@ interface ServiceOverviewDependenciesTableProps { fixedHeight?: boolean; isSingleColumn?: boolean; link?: ReactNode; + hidePerPageOptions?: boolean; } export function ServiceOverviewDependenciesTable({ fixedHeight, isSingleColumn = true, link, + hidePerPageOptions = false, }: ServiceOverviewDependenciesTableProps) { const { urlParams: { comparisonEnabled, comparisonType, latencyAggregationType }, @@ -139,6 +141,7 @@ export function ServiceOverviewDependenciesTable({ )} status={status} link={link} + hidePerPageOptions={hidePerPageOptions} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx index e5f3c7bcbee4e78..fe09a4784239e25 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx @@ -169,7 +169,6 @@ export function TraceList({ items = [], isLoading, isFailure }: Props) { initialSortField="impact" initialSortDirection="desc" noItemsMessage={noItemsMessage} - initialPageSize={25} /> ); } diff --git a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx index 7c2bc722ac1e6b0..844957defe67dbe 100644 --- a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx @@ -45,6 +45,7 @@ interface Props { nameColumnTitle: React.ReactNode; status: FETCH_STATUS; compact?: boolean; + hidePerPageOptions?: boolean; } export function DependenciesTable(props: Props) { @@ -57,6 +58,7 @@ export function DependenciesTable(props: Props) { nameColumnTitle, status, compact = true, + hidePerPageOptions = false, } = props; // SparkPlots should be hidden if we're in two-column view and size XL (1200px) @@ -210,8 +212,8 @@ export function DependenciesTable(props: Props) { noItemsMessage={noItemsMessage} initialSortField="impactValue" initialSortDirection="desc" - initialPageSize={5} pagination={true} + hidePerPageOptions={hidePerPageOptions} /> diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap index 15613af4daf98a8..3559ee4afbb82c9 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/managed_table/__snapshots__/managed_table.test.tsx.snap @@ -44,9 +44,14 @@ exports[`ManagedTable should render a page-full of items, with defaults 1`] = ` onChange={[Function]} pagination={ Object { - "hidePerPageOptions": true, + "hidePerPageOptions": false, "pageIndex": 0, - "pageSize": 10, + "pageSize": 25, + "pageSizeOptions": Array [ + 10, + 25, + 50, + ], "totalItemCount": 3, } } @@ -102,6 +107,11 @@ exports[`ManagedTable should render when specifying initial values 1`] = ` "hidePerPageOptions": false, "pageIndex": 1, "pageSize": 2, + "pageSizeOptions": Array [ + 10, + 25, + 50, + ], "totalItemCount": 3, } } diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 16ab8cb1d920217..37d55887cd182b7 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -47,6 +47,9 @@ interface Props { tableLayout?: 'auto' | 'fixed'; } +const PAGE_SIZE_OPTIONS = [10, 25, 50]; +const INITIAL_PAGE_SIZE = 25; + function defaultSortFn( items: T[], sortField: string, @@ -61,10 +64,10 @@ function UnoptimizedManagedTable(props: Props) { items, columns, initialPageIndex = 0, - initialPageSize = 10, + initialPageSize = INITIAL_PAGE_SIZE, initialSortField = props.columns[0]?.field || '', initialSortDirection = 'asc', - hidePerPageOptions = true, + hidePerPageOptions = false, noItemsMessage, sortItems = true, sortFn = defaultSortFn, @@ -128,6 +131,7 @@ function UnoptimizedManagedTable(props: Props) { totalItemCount: items.length, pageIndex: page, pageSize, + pageSizeOptions: PAGE_SIZE_OPTIONS, }; }, [hidePerPageOptions, items, page, pageSize, pagination]); diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx index b6e02b1b08c3cb8..49d5e95344ea43a 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx @@ -5,12 +5,7 @@ * 2.0. */ -import { - EuiBasicTableColumn, - EuiFlexGroup, - EuiFlexItem, - RIGHT_ALIGNMENT, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, RIGHT_ALIGNMENT } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { ValuesType } from 'utility-types'; @@ -25,6 +20,7 @@ import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { ImpactBar } from '../impact_bar'; import { TransactionDetailLink } from '../links/apm/transaction_detail_link'; import { ListMetric } from '../list_metric'; +import { ITableColumn } from '../managed_table'; import { TruncateWithTooltip } from '../truncate_with_tooltip'; import { getLatencyColumnLabel } from './get_latency_column_label'; @@ -51,7 +47,7 @@ export function getColumns({ comparisonEnabled?: boolean; shouldShowSparkPlots?: boolean; comparisonType?: TimeRangeComparisonType; -}): Array> { +}): Array> { return [ { field: 'name', diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index f943cf4da4b059f..a98eda2d3b96169 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -5,12 +5,7 @@ * 2.0. */ -import { - EuiBasicTable, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { orderBy } from 'lodash'; import React, { useState } from 'react'; @@ -28,6 +23,7 @@ import { OverviewTableContainer } from '../overview_table_container'; import { getColumns } from './get_columns'; import { ElasticDocsLink } from '../links/elastic_docs_link'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; +import { ManagedTable } from '../managed_table'; type ApiResponse = APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>; @@ -60,6 +56,7 @@ interface Props { hideViewTransactionsLink?: boolean; isSingleColumn?: boolean; numberOfTransactionsPerPage?: number; + hidePerPageOptions?: boolean; showAggregationAccurateCallout?: boolean; environment: string; fixedHeight?: boolean; @@ -73,13 +70,14 @@ export function TransactionsTable({ hideViewTransactionsLink = false, isSingleColumn = true, numberOfTransactionsPerPage = 5, + hidePerPageOptions = false, showAggregationAccurateCallout = false, environment, kuery, start, end, }: Props) { - const [tableOptions, setTableOptions] = useState<{ + const [tableOptions] = useState<{ pageIndex: number; sort: { direction: SortDirection; @@ -228,13 +226,6 @@ export function TransactionsTable({ const isLoading = status === FETCH_STATUS.LOADING; const isNotInitiated = status === FETCH_STATUS.NOT_INITIATED; - const pagination = { - pageIndex, - pageSize: numberOfTransactionsPerPage, - totalItemCount: transactionGroupsTotalItems, - hidePerPageOptions: true, - }; - return ( @@ -309,7 +300,14 @@ export function TransactionsTable({ transactionGroupsTotalItems === 0 && isNotInitiated } > - { - setTableOptions({ - pageIndex: newTableOptions.page?.index ?? 0, - sort: newTableOptions.sort - ? { - field: newTableOptions.sort.field as SortField, - direction: newTableOptions.sort.direction, - } - : DEFAULT_SORT, - }); - }} + hidePerPageOptions={hidePerPageOptions} /> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9a3bdd0ad0df571..42fd880311a5892 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6231,7 +6231,6 @@ "xpack.apm.transactionsTable.cardinalityWarning.body": "一意のトランザクション名の数が構成された値{bucketSize}を超えています。エージェントを再構成し、類似したトランザクションをグループ化するか、{codeBlock}の値を増やしてください。", "xpack.apm.transactionsTable.cardinalityWarning.docsLink": "詳細はドキュメントをご覧ください", "xpack.apm.transactionsTable.cardinalityWarning.title": "このビューには、報告されたトランザクションのサブセットが表示されます。", - "xpack.apm.transactionsTable.errorMessage": "取得できませんでした", "xpack.apm.transactionsTable.linkText": "トランザクションを表示", "xpack.apm.transactionsTable.loading": "読み込み中...", "xpack.apm.transactionsTable.noResults": "トランザクショングループが見つかりません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index eaf7f9f33edadf4..2fe7805f64259ae 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6275,7 +6275,6 @@ "xpack.apm.transactionsTable.cardinalityWarning.body": "唯一事务名称的数目超过 {bucketSize} 的已配置值。尝试重新配置您的代理以对类似的事务分组或增大 {codeBlock} 的值", "xpack.apm.transactionsTable.cardinalityWarning.docsLink": "在文档中了解详情", "xpack.apm.transactionsTable.cardinalityWarning.title": "此视图显示已报告事务的子集。", - "xpack.apm.transactionsTable.errorMessage": "无法提取", "xpack.apm.transactionsTable.linkText": "查看事务", "xpack.apm.transactionsTable.loading": "正在加载……", "xpack.apm.transactionsTable.noResults": "未找到事务组", From 8dc224432154d703af50f68c4b33904883a286a3 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Wed, 19 Jan 2022 18:14:10 +0000 Subject: [PATCH 03/12] Create flag for the infrastructure view (#123159) * Create flag for the infrastructure view * added keys to schema and types * update telemetry schema * Change in copy, import key to the apm-services-template * fix import --- .../server/collectors/management/schema.ts | 4 ++++ .../server/collectors/management/types.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 6 ++++++ .../templates/apm_service_template/index.tsx | 5 ++++- x-pack/plugins/observability/common/index.ts | 1 + .../observability/common/ui_settings_keys.ts | 1 + x-pack/plugins/observability/public/index.ts | 7 +++++-- x-pack/plugins/observability/server/ui_settings.ts | 14 +++++++++++++- 8 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index c2a4f18218dd475..27e44cba1094f00 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -428,6 +428,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableInfrastructureView': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'banners:placement': { type: 'keyword', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 69ed647f0845a0e..8776bad89f8a609 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -39,6 +39,7 @@ export interface UsageStats { 'observability:enableInspectEsQueries': boolean; 'observability:maxSuggestions': number; 'observability:enableComparisonByDefault': boolean; + 'observability:enableInfrastructureView': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 4641d271b3e4f6e..9c2c71898dee727 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7790,6 +7790,12 @@ "description": "Non-default value of setting." } }, + "observability:enableInfrastructureView": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "banners:placement": { "type": "keyword", "_meta": { diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx index 962fbb4eb6be6da..93c222164f02607 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/index.tsx @@ -15,6 +15,7 @@ import { import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import React from 'react'; +import { enableInfrastructureView } from '../../../../../../observability/public'; import { isIosAgentName, isJavaAgentName, @@ -159,7 +160,8 @@ export function isJVMsTabHidden({ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { const { agentName, runtimeName } = useApmServiceContext(); - const { config } = useApmPluginContext(); + const { config, core } = useApmPluginContext(); + const showInfraTab = core.uiSettings.get(enableInfrastructureView); const router = useApmRouter(); @@ -250,6 +252,7 @@ function useTabs({ selectedTab }: { selectedTab: Tab['key'] }) { label: i18n.translate('xpack.apm.home.infraTabLabel', { defaultMessage: 'Infrastructure', }), + hidden: !showInfraTab, }, { key: 'service-map', diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 03860fd3cd1227a..4f303390e1e1b28 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -10,6 +10,7 @@ export { enableInspectEsQueries, maxSuggestions, enableComparisonByDefault, + enableInfrastructureView, } from './ui_settings_keys'; export const casesFeatureId = 'observabilityCases'; diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 4d34e216a017c0e..ea8a2f20ea4e780 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -8,3 +8,4 @@ export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; export const maxSuggestions = 'observability:maxSuggestions'; export const enableComparisonByDefault = 'observability:enableComparisonByDefault'; +export const enableInfrastructureView = 'observability:enableInfrastructureView'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index e502cf7fb37e0cd..d855d0178192ed4 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -23,7 +23,11 @@ export type { ObservabilityPublicPluginsSetup, ObservabilityPublicPluginsStart, }; -export { enableInspectEsQueries } from '../common/ui_settings_keys'; +export { + enableInspectEsQueries, + enableComparisonByDefault, + enableInfrastructureView, +} from '../common/ui_settings_keys'; export { uptimeOverviewLocatorID } from '../common'; export interface ConfigSchema { @@ -94,7 +98,6 @@ export type { AddInspectorRequest } from './context/inspector/inspector_context' export { InspectorContextProvider } from './context/inspector/inspector_context'; export { useInspectorContext } from './context/inspector/use_inspector_context'; -export { enableComparisonByDefault } from '../common/ui_settings_keys'; export type { SeriesConfig, ConfigProps } from './components/shared/exploratory_view/types'; export { ReportTypes, diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index ad0aa31542e8c71..8d37398b8a07b0c 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -13,6 +13,7 @@ import { enableComparisonByDefault, enableInspectEsQueries, maxSuggestions, + enableInfrastructureView, } from '../common/ui_settings_keys'; /** @@ -48,7 +49,18 @@ export const uiSettings: Record> = { }), value: true, description: i18n.translate('xpack.observability.enableComparisonByDefaultDescription', { - defaultMessage: 'Enable the comparison feature on APM UI', + defaultMessage: 'Enable the comparison feature in APM app', + }), + schema: schema.boolean(), + }, + [enableInfrastructureView]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.enableInfrastructureView', { + defaultMessage: 'Infrastructure feature', + }), + value: true, + description: i18n.translate('xpack.observability.enableInfrastructureViewDescription', { + defaultMessage: 'Enable the Infrastruture view feature in APM app', }), schema: schema.boolean(), }, From d7dbf159194d752622bf6bad1395f68b6e881be0 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 19 Jan 2022 13:20:32 -0500 Subject: [PATCH 04/12] [Fleet] Validate package policy on create or update APIs (#123261) --- .../fleet/server/services/package_policy.ts | 53 +++++++------ .../log/agent/stream/stream.yml.hbs | 1 + .../0.1.0/data_stream/log/fields/fields.yml | 16 ++++ .../0.1.0/data_stream/log/manifest.yml | 15 ++++ .../0.1.0/docs/README.md | 5 ++ .../0.1.0/img/logo.svg | 7 ++ .../img/screenshots/metricbeat_dashboard.png | Bin 0 -> 94863 bytes .../0.1.0/manifest.yml | 41 ++++++++++ .../apis/package_policy/create.ts | 75 ++++++++++++++++++ .../apis/package_policy/upgrade.ts | 8 +- 10 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/agent/stream/stream.yml.hbs create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/fields/fields.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/logo.svg create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/screenshots/metricbeat_dashboard.png create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 5591c165bd7063b..1ad4ff1adbdd09c 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -157,8 +157,10 @@ class PackagePolicyService { ); } } + validatePackagePolicyOrThrow(packagePolicy, pkgInfo); const registryPkgInfo = await Registry.fetchInfo(pkgInfo.name, pkgInfo.version); + inputs = await this._compilePackagePolicyInputs( registryPkgInfo, pkgInfo, @@ -392,6 +394,8 @@ class PackagePolicyService { pkgVersion: packagePolicy.package.version, }); + validatePackagePolicyOrThrow(packagePolicy, pkgInfo); + const registryPkgInfo = await Registry.fetchInfo(pkgInfo.name, pkgInfo.version); inputs = await this._compilePackagePolicyInputs( registryPkgInfo, @@ -865,6 +869,31 @@ class PackagePolicyService { } } +function validatePackagePolicyOrThrow(packagePolicy: NewPackagePolicy, pkgInfo: PackageInfo) { + const validationResults = validatePackagePolicy(packagePolicy, pkgInfo, safeLoad); + if (validationHasErrors(validationResults)) { + const responseFormattedValidationErrors = Object.entries(getFlattenedObject(validationResults)) + .map(([key, value]) => ({ + key, + message: value, + })) + .filter(({ message }) => !!message); + + if (responseFormattedValidationErrors.length) { + throw new PackagePolicyValidationError( + i18n.translate('xpack.fleet.packagePolicyInvalidError', { + defaultMessage: 'Package policy is invalid: {errors}', + values: { + errors: responseFormattedValidationErrors + .map(({ key, message }) => `${key}: ${message}`) + .join('\n'), + }, + }) + ); + } + } +} + function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) { return { ...input, @@ -1314,29 +1343,7 @@ export function preconfigurePackageInputs( inputs, }; - const validationResults = validatePackagePolicy(resultingPackagePolicy, packageInfo, safeLoad); - - if (validationHasErrors(validationResults)) { - const responseFormattedValidationErrors = Object.entries(getFlattenedObject(validationResults)) - .map(([key, value]) => ({ - key, - message: value, - })) - .filter(({ message }) => !!message); - - if (responseFormattedValidationErrors.length) { - throw new PackagePolicyValidationError( - i18n.translate('xpack.fleet.packagePolicyInvalidError', { - defaultMessage: 'Package policy is invalid: {errors}', - values: { - errors: responseFormattedValidationErrors - .map(({ key, message }) => `${key}: ${message}`) - .join('\n'), - }, - }) - ); - } - } + validatePackagePolicyOrThrow(resultingPackagePolicy, packageInfo); return resultingPackagePolicy; } diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/agent/stream/stream.yml.hbs b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/agent/stream/stream.yml.hbs new file mode 100644 index 000000000000000..2870385f21f9591 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/agent/stream/stream.yml.hbs @@ -0,0 +1 @@ +config.version: "2" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/fields/fields.yml new file mode 100644 index 000000000000000..6e003ed0ad14769 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/manifest.yml new file mode 100644 index 000000000000000..0b1ae9c6cb9959a --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/data_stream/log/manifest.yml @@ -0,0 +1,15 @@ +title: Test stream +type: logs +streams: + - input: test_input + vars: + - name: test_var_required + type: string + title: Test Var + required: true + show_user: true + - name: test_var + type: string + title: Test Var + required: false + show_user: true diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/docs/README.md new file mode 100644 index 000000000000000..d6cfcce90527cb0 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/docs/README.md @@ -0,0 +1,5 @@ +# filetest + +This package contains randomly collected files from other packages to be used in API integration tests. + +It also serves as an example how to serve a package from the fixtures directory with the package registry docker container. For this, also see the `x-pack/test/fleet_api_integration/config.ts` how the `test_packages` directory is mounted into the docker container, and `x-pack/test/fleet_api_integration/apis/fixtures/package_registry_config.yml` how to pass the directory to the registry. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/logo.svg b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/logo.svg new file mode 100644 index 000000000000000..15b49bcf28aec26 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/screenshots/metricbeat_dashboard.png b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/img/screenshots/metricbeat_dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..76d414b86c4ab447ba7334e7a4797ae7e137a4f4 GIT binary patch literal 94863 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

pKP@oJqhb%^lSm%Gaw$mvo9c$gO z?D%N`1V%V7=w(Ax)cV0=c>$HSC=O+$F3 z#E-(Ma4srRzR>u4DOTO{&1errjgr3Gos`$wdI9+%$B63cDWDE2zv=)L|8Q#qu}YanlskPMWvNCk&iqZO47&dv zMTCb}Ht*hUTtAc-Z>p6?_$y+d46k&Nki!POI@grLogg}F(+?WZy1kg z9?hOiGhzn80t&bd!>sZ#BU$a;Gqq0)ZH-Rne=uJ0Cv!O)(fZZ}xacm;?}HIbjLGrk zIv*u>>|EXI&R3{rACMAdiq~Elt*i7J<}t;|7LF&=Px+#ig1|*y%NEGjlvcR3+(#oH zN6hVjZ-Ns}1PJMs3fU3tWvQf~_jX&R(dc8z;J~%Vc!Q&+4E*ByN%!&l1=C&wRo7su zl6vFgJ7>e24)N6VVQguL^~1bL1PkD1@iSc1T}yKJNpWm$Mf*4Ajyf#q;=gJ2bt$j+ zrnb6^>-Ak8_3hzA>Jr3x2bDmRBhnHzqDD4~F|kd-+FO}&LMVG~Z>d}o%1@+1#tKeU{rT3$5k7JDDS2%gTr54bZax6XXL zvQ7Ul1(=0fEe4T0sWsL*h7cz`TSQSa9kgxu3)w1}Nb4NV-m>1TM$~GSqdN4$z;LgE zDw!tLewW@P=j4ehFsyi_RA_}uIbjT6oafq*hx=f$!rh%r#w>kElenM)FM6mLe3;`f zk%rrz;ZG_!Urg?(mzZGnTkKn!Pof?>%kp_Lv!JWGtvmg`5u=_#pea|8Qh>K?{-Zo2 zY>~#iQ#;cKPY~1@`&v8$i)53t2|!KCZ0(ggl6r97vCXc=OP$#ZD|MIIt*v=lWxSfh zz73l}n{k*?1UXD|yFC0&i`YWQ5JXYH6V zQ39<^0Fyk^ND$wXu4bsP^GK7hK%tbpozs*v4X_VkBK6OVpd5d2dV{Kx`oE- z;hs5fwNbPFw0)c0nFY>=jKPE~)HC&cEj5g5W5lCiwS3V<=5Rbk zh~1~|^wDZ%x+4P-cZtO=Q3uhenaHJB>plz}t<73}pkc%?w3t!ZpJhd9)s#8gP?CLH z10d#2-1lR${*)thVNKk0Nef#lBUWRjdImFK=+Ublnj*9(IUihT%-8E~Ae@vUbktO+ zqy|c7qgVKv(Hqfuwm|@^(J+?C*8k~MApEth>1eRDu)W9HOQ(-E$mWARmH+n^kP> zB}WNjtjGtU6+v=hjk^2WMEfTlC5z{-ZAC%+i9z3xhPH2kHLG*=dvp5@a9*KO(9x?g z_f$beC>=kK39|Ml^^zuvyOBltn-iju2yrFw^6J+hZ$ z0>2W`n6WIbY{VkH<++q8X|H*2jM2lpe&wxfd6Dvt`F1IO#CdvR*jY(O-5R9+;jzVs z(+Il{EGK)YgoRqq3!j{e`{Pe~7}wncUul+$%ss}kO`~&lVKXG*?4^AQgH?B$STSne zy)${NUdk(Lpa*_nx;K>Z)o3sPh>05T$AI*8TCOcS3pe`I`<2rVW!JSKiQvG&1-M4`fT>+|GcbJJYWZpW<(LPPGg#9VpRlsm}PYI1OYALttn zT=)6T1?3ltLRKKl9@$l^3i;E@2GzlO7irS=eeUTGu+xJ#OJu>?_6d_m6B+)fLpi4V zwZ>3GL6O+l1TZdOId>o#W*BTB0gcD0tVrQt>VjJvbwm7}Io}kC!H~8ZjZSe8^ z80XZQ(@K4xVpN6Lh&+vZi3~EHOP;FwJnjS5TJ^>#Q~S5_tEwsN23FPS7>5>y4F;H? z*KQ3w(}tcVzI`dhv!GE#)ssbYi8$|0u=e_s-CBS6s4;|f`{)B4l;=!tNMW~DYSQp} ztPr=M%G0(Va~Yt*-P(>Eg$LB^?stG2vpbR5sXA@TQhY;ez@`z8gV#&KH-pDlAT4$g zGH!KbX0HyRn+EEUGVx1Nc?bf72%$=gSZzAD?%H4=pIjkf;G@Q(pVE=@#+WZfwAm9o zut+_RRfqx(OgDi)JWX#UqHuJzRw5SPV=m9e!{4EARKy4wy6DC4m1;C?6mD0h z4(pC`8MqFY-#A_US+GZxQ4l@OZfIvWRNIz;AYQY&l|hhzcB5;5mUW6+kUwhTBmZ67yBW))!4VX z%N;c2MlPmqAQWck5m55ez8w;DA(jzzr*3#1G(bCJjj-mB1;s4LV( zqpo?9cEb^9N4U_nrOAF2$j_#7;>-x4;Dl=ZqK2vNO^*m>{otKf0(4=GVYfdf`B&Tv zT=k~ar$|t~g-gI>iZ^e}DtAV0wuA*>r6hCXG-1UOYf`i@QU$Io@Vq7+K60}K$DZ!m zP&&wYgVxkL&V{86|2ERuJPpZaHtQRXTcY*Z@U ze8x=*P{SsbKeC}r54pxHv<-!VV}Y7d-K7x+kZ3BZXxg+ZQys-Kx=1)^n(0S`LC>Ev z$I~L!U52p6Q&eU&=L60i-vZaVaF)62)#=MOf871}qbBknha#2DssG>aLQ_BXXJuuT z#Ga&jYVVvMaQin3oN8=IVNTBaH>l!&tWN0RlPOfbh`u?wz+Gk=c+PD?l@Yd{|uX{0;IKw&^z|tO&riH#avSssaA~ zR(G!h{mdcuh>EJPR*CIa-sk)C%;MjRTVYGNlTuT!Z$#W$&G?7Rcl{LhW49dy!yDC=l(cko;J{%TGw!wXohPVL zJ>GvS19tnIS@*RPN;^Gqd;iIA1FcgqdCksJ891~jgd7Ug@ea*dF5q(|;`qz;^N9NT z039Gw;psv?My%+wUE({lSDjXi&F`o`{B9w~q<(YW%GJNa*_7L!o!f8g5WRjr#56;V zL;hC$;krlrKmiTa(=?l}L49U-j~fFV*GCZ>MXrVXOWUdF9Z8)2X(7YZe{xH^Y3LMH&tnLo`}E`-c{Mml_V@)$r&kAeGJg99qWiaCiZ`Ox7f%IU zfSRbUEPcLJ((DFoS&-0_<8UUH$@T8_)3oB4e|;|;ZP-A?04(dr{w5}%Qxa2Ci(kBW z@o*#gLTSqhs=FDR6FX=aVQ^4*NJO@l;U|AD_|9ROU7)Ku$31v= z=lt@7_>;r_^-Xf3!W|g3lbzX=@|7RkR(KdeP4C~OXG)cgO-_XW9Ca48lYtw) zxc}DDx*p_*+pwbliRO%HBmW32sJZiJSU;qGB(S0%*p{J2S0sPke7p zAJ6qvJy!r~3T5<`k2$HRsV!@2U^JA@%w_-{ZIXZE**{M#uCB%^emPG0)ZV3ZZ}!38 zGTE4BV}Gw(xPgJ(o*p#=lpg*0uuj<@v2`ucS0f$+r`vE~Mv<>yGb%G|CZcI132K^} z5PyG(J&xn|+$?-MlR?3U$$ciCjLIHJdb~*@cB?2W1K#R3-4E2qNm9Q7Cy&#fKb@4E zJaY7S^})=KLH^WH(6EWigbT*8%{QL`EvTHo1y>Lyy{R1gQvyHU7?R7uL=5Pk)VC^7>f^uP1!_ zS;wo+m7MNBnpQ*CN=%pYhglT5mH3Z(2W{{`KSP?DOp;>OW9R&9Dv!o6^X;PRfXD-` z!n1xqJiKab(~m`I5cq<%WxFAqZI!+A$YZR;)%)1seQHr%se*PDUJz{aQ-m}Qy!2^y zuDayI6r@9>i9#8c9qB$)$CcymbtSJ$|AL={s$?^IVgyCbi1ACf#(i9i=!ZR1bGvh9e*;^pD6mF(mLaqq=XA?0!G{QN zx#Se+(xyl)ZI=ns`kr#RT_9GodC{@&j_&uP{ag~0kv%K(`fx{Jn zu8mJNV>@d@Sqw#t7p3!@krgx!QpG0qJIJcfe8C;J_RG&YuM#xRiX*X(bIij5a-{mk z#YDCOJK|^)radyx`C{-mD`#gk?W=!)p74u8l_$>x%O)WMBH){8GSBeY01pBl_JOSIV|4fprdX zcHuj0xK?tQUWnUurEqddXr$l#&m4sn!dtm;cb3tS@N-u5+c^27K8H~m4C*BCKdh9~ z2ahH0Z3$i2-#Z?JsUtQ)O@2xDXC88NC!MiZ=jgOji3iSxuX)$8bo|_mA??*v5z@bz zfz-9BWdBSmz{f6lLXP{+J!N-0>sq;hxREZ9DcVkA7Sj9e^jJ3wT#i-rz#`ncespnt zuKJFq3wRBebIr$aYv^i0BfcJ7;gM3_C1!`0^D8&ZRaNJrPAUy}bmJ^zc_@Q!t!213 zm~U{9mReb3CD^{Y0ohbA2eIXvkt$}wBP*TogNP?e41;cG>`I4J_a(62OB=Y^yPeZc zT6u)a^-pGtKUJ+kHk;PxKgG^f%2peHwoPdFGY-|Q-e;+-a4TRw8ky!U-y42nmts1( z4p-BZ&KZa?nkkb;t_`6^3DVkE>PP3w-WusP*k>@i2ZO?00~Cz*!yCOWc=Vdc;i*MS z9H1y>O4~i>E+OSCIr6#(#!mBa1E-rW7)X8Ft%O`~4NIs~CivGpNY+MAbdjW1q>XQD z?k!F(UDu)H_#3$55zpIHf&xmjSpd>LwYMIb%LwUDmii00qe6qGw#2>-edaDY+ofU+ z%IZ?p(KQ0!4qn5R;=7VYpXg(=CQltQFgZUd4lIy6JB%Qe$?v!tqNJjf%kS&f!IDa` z;b6k(gJMwTsijR{`L~SsGI)b%n@Vpb(TH0KE0@#QS(liswoRVMEYiUC1tujaa)t-3nXU0F}G-yk>GnY%Z%Z;jdoW3`d@ zoOQL6m4(K1%ls2Y#2v6lcF`6yWo`8PPe!E_>T8iBND-vZNuv^#4JijH*|vUjedb}x zl>EwS1JvkV%HxDxZM4Zqmk_N8vz|J^`tM;x9k6ze)WrI>?dZptd|6!!cU75rcubYM zg6QTs+t!ER+H=?(Hu6DnUW4(rEmC)dYuF(pzch)v8;@)iWlq9Wb%H!}FU0ClMtPNE zF1UMVeLfRRXPFPi%4Y*uVJBVC4(p}^ZRu|`Saz- z&FXKA-EL<(ke>>%0*l1CEM0IXH@MWK0N$XTm9nMlILe%&Cv#XsB2!CEPS@Qao)_1Z zDNe~(_p9|5zwvqiR~m0wGx8<8cJ}R;TQ3F!Ycb&=@OJDU9eO z1|2xJ{JAuO+)^;?SV4n8>7lTXjW*a84AI97_p~PC++N{h<{xr2Tnz$EvQ5{dTx-x&P#Qi`|jLP%ZA+QDOez8TDv!>im(Ik#*? zOt1r9S^}$I1ojI#D^WwN32e5+3jeCNK0$iV&aUk1dUce*hp9XL^usex85-Qb`T0hx z8>Xzd*aCToF4Z0m=fUpOYa+@mqN?=HLvC&s`;WSaLuAnHXVEwd2!vqC^1AJ2k1zz*gk^>HAwm|rFF3PXbK6NF(%TG~ zobPBED6eTrZcB-)&4CG*mTJeoh4zgi-Wld%8zYKo;N1&MW%5}guKWqMJKoZJGYS6N z1rkJAQnB8Sb=;`0@fblJ*%j@IF5o)Z)Y7`M`v1(annj5x1LpK zC@z6cpWhmCjQ)qm%`}J^{|seqG;(*L1g+B;l*FyV*wxjwc*La-&apns!$C(gzy>P1 zdhe&y1?0pWsp4ehet;AP7PWDoNbrLnLoeIkhxf?ZymE$Q`)Rh z?;{Q_x6ebbZi=r?8zz_Jsv&S&h(%*ot{Ls=cP6+wUY(C!g6I7t9hT|FP;}z5#YsGD zp&U|wGZQ_oM-(Vsls4q(&&ou2IyjB?m8&6>YrzzUBnK1`5_XQcf9}|xG&4*U29!%- zsf%ZQ{AWnMGYd!Snp3S+z$2yPTU zUzB$785F5!sBF1!J-xK7C%OvL2yU!jQ$ukZ?-w%@^x3)rJPh2Mm4fYF02k?4^&1s> z#i1b&qxqzV_q~RelGi`FB4ims5zLhOUT)?cH+61b->7@dWDIWW{pj$FSZ5X_cfVRH ze?V~Ll44Y&9WV|0xurC144agraJh40V~S4B4GM>h3O3wdihGe&j_s-#hq0;{%NlPE z+9bX~9@+c6OJOl55Bb{m}U)1_S#=)|mj*1z|ZWgFP_YZAoHKzoRS>mh1 zv2v~m2atrJ!NEKww%jN8d8_fL_wV2Pw)?0XhkMVnPzwy^#bLnDJf=*&PIL^&6zS$$z8FKvfYovib5C|$mC~Qk-5Z~vx_IIr=q*hV1&6=B){`D=; zk@ySaVO%3dJCxQs*Fylv@ian=LQ?-(jq6T&)B!xfdDN?u1*49cBvp8+J593VMA%u{ zgbNy(T>Fd+TZ~Z{1mY>#Im$pv!_5ED)a+BXC z=%-Ke(lrm?zTeUMK$9>38@o?MRdM3~+#UDd{{~7t2QbDTCEwk8?|wLkBww2oqPPY( zso6tN9>YxqjqNx#`oyL{?1(@=+-8rCKhoGIOuaEYs4Erk) z0gU@_SBu=9IxZ#PTv-eLi>E`l@7%fWA!)O5*WfOYDkEv1Qxydpmkc}*i5(mcK% z@6Ej4EoSDnIK@aAPJR@`A3QY>qBRX`tgO(nZP1CdEMWHEG^fRLl=^*5_j-*OxZFKK z-nbFXu*veC?FK(2Nd#?73%vTT@Ycx3xAI)XuzR!0nS{3{ws9idQf6=6)*HFMkS_3b zXl9hjsoOLylt{9=K7313Yh^lk;Vl!O&yHsXMOddgU-GFy?}{4__#W)Kd3nW9(!76= zw}$!K6MX=e7+PuDUh8`$(J15PeVSH9gp1udtCXwN+M;!yw8*JBJa20oPG=Qlo3Yhc zbYjsT{rKW+oaK{L^MrS&8_5F?$L>kzHS-8{dd5^oFz2n;{z(saeXjw}cOBY%tp1hEPjMb$%_I+6V9r|dH zmmpOVR@8*{u$7b33+->YYejWUr7|!^7gHu;tY0|=XMr3kLHIs zq57YoRB*OnxdPM4T4>BS8Uqhi-RsP$&%%ba_AFOW5(2Ir#SPeuO*{E`-k4zowsa&p z*X~{_7}k7trx9%!T+*d{=`w_FqH0~h%ZOkEMZvb~Dq+(?1%8Lc1jYotpv9{y^{kpD z!$mXaZ9C037p+XR#L!h;sS-^@puH`dqHcSI!E3S~cmqwxzFu_LZC9~w{4UOZ(U=hv zC_FQiVM{ zdo*s}B1xtB=!J^sBY~j(vSs4Ho+0J{7MECU8t1|fPJZMdRRgL=yw>SJwsByrdWUiC z*&_<4VQHQ}7TrBvD6TgtLL$L)tExAzCKaz9?&&<5!8Led;Er4`B{~f!4HYI8TesqCTf3+E#iwCbI$Ndho;jMHTEAOsRgy zsPfxF5!i}^oGn;E@K30G^N2hSGB!Ic;Z-OnOD+Pz^Dzh2?1n-QH#k1=uV~2EUypjj z&m6F(a?3#lqHSM7Qcu~Ol;j+`vW!O?G0nx5NMMbLVg`!7x5RGRZs^C}_*7z}9Mowy ztvzf6S-R1vIh0Q7($y=2U@7=(#ihIg5)AQNnvUJe{;PH&k3!AU)J|r95$&`)%OmwZ zq~(EeKK~%pDY~iQiA?VMf{lGh!8O$9Yf)jVLX$tolhEhALeMioVups7T0-EURW%Q> zhm{H4J&efZ9do*UVPomj;ss=Q_DS7S+&n8hA8okA`tuE|p#y%k6=wr>D(+ObiNyh?W^wh&af%R1x?TAGlqEE`@G@PWFCYDZ`mYGX65LfJ9_cjX@bv- zEC&j1;{wfE@TrJ#X-9dsPee#;_qmB)P*Q3PJ_zX(s8Nm5vADQsB4nPaAqIRJA(@Txro~Lr>dev4+Yi(6@^_pTC<#2Xn}zYm+=S z$j#vDflX-~)2XE1{CV#n4sn8~p37)Ozzz1aN~ett|6I=pmVP&|#LpqPYTNLvaiZ3k zTt?+v-x_5ZRC(O}G`mP%2$N*~tC^0JR&btwaES^pb7si~k84s;e%Re-Z9>Mv0Q_Qu zlQuayxus^-E#2BsI8i(4VtcdH_OMBB>0ToW;}QTpBDu04SBHpyOVOeKDj^ytJU^Kt z!ZcdXX+#T8Oif2hcBMETwz!4YO-T5B7JHOoUsv(uy!_Ip=Y)6}*%I3^wbLbh^Iov; zSL#gCE~a54K*G8_zhmmjosVN|kRultQGe&I+x^G3*wg#z(SaTjLgzq=`UIZF$7!Hc zR|)Q_1w~nMJZv*>?IM5Rb~$b7NFgdX&z6~IAPr*rnYI6wI{Ny`!%6)mXCh8(bp$Rv?+@O;;x6*_RKo0?L6(wW!(-_Edn=3k+< z-^r4!AiA7e%#$_M1A-8-UZ#{yX#brF8y$P)!d2t=k^#8dSm}Y>A$f0R+f>?Av%0}H zZhm4_(i7rVrN&iTmnWqyx;8X#Ve1(c>m65@B+)Y%qYy+ocg}P0t%rp4vetu$I_v3M z6KNu5+LO-Qo2#D>!-S-b&9plJ+jr@a%0fnn`~?J#P)v$B79^F;T`21tXY-|^R31rU zo6%|)ucgf(U?y8YP~Y9D!E%6HfU+q{zh{$P-t=9(fN4KXBCJ{*#52l?Bd)JrZs4Wdw^;c+1df}N7Qn>JrJEnMid68J0^ho-~+P^w=Rb#{pzseFWbEbd;M zga<1%*~5v4u1H@>E|RWYfN;Lnv2ue*aSvA$O4CUUKC(%|vvuYq(E|IyOeOda)ak?l zM`SaiO!?%gjY3TKW+9c`R`5T@GzE2NoUB z(gCd7^tT6~h7jlG;{5jgHfGf=L;$G1K6^#HDEq)8WVMD*F8C;Tl5XwE-S*+^rcxh* z>u~*AE!b#|sgj>>_zQt&6QF39k|sl8(pX7F1?bxs1L94&qlpEu$uM4G!fh*=%TUX` zT7~X0r{UjKuRqnVWr7raJe{YWOL{^(547j7^A0W$;^c;e)*8eyMLVrl4?LcY6#dwl zZj^nALp3O5wZ|uCUqb&1H<{<>Wj(b?VeQ^)Vj`cX;-S)n_=)jPI+8 zj%I@(kaW0vN|)PFNEerP&s*{9at^y6y;r(%rHZ4oowt+^C01}vM%$cOdLKW?Hp|$4 z=n~s^*HDgpkvHt)4{U*TxAlOGJqOR<+TnZY!S+}DLG}b?@dfgD%HURQ1oe$$PPyBw?$VT&3d%hN<-bhO z51nyZO!P`Kp-l6HHjugt4-$RbM16kFf#Tcd_34JsRjSh}>!c(nzfpzss0uGm2Dk$U zGl;|ULADa)fKb@(nK+Hig-<2IAuIwY@y}=h*RIj223@{R%_zk$gIc*l*94Nc0yD|v zf%NheM2)~@X9=`VTqet_mnuS%mAhf)XKwyjEeZIcKp%4lF6}E5x@>8ySEgevq!I;{Tu4X~7*sP(2sBFE#2KJy(Rl*a8bWzSJyz0MJs+@f{ zYw+##wPA5P%L+OgLhfGS?u#Gpr__fXVnx78c`P%&N@CKy$KY6TN@ZstMELGB^8M@qMb%U(4obICkQPBY7^S{K@h5-4l%)c?PEnL?8}aVX9wPE=kwB4VQA@| zGzfm7uHX~G=qxHEdH=vXQ|1QW3s~S}QCM7FsCJxKe%)XS*oRDd1)Np(PLXn|J7P00 zSVA=PWB;8BuV;}ebj3l~ z`rL0xRKZ@y29~IIr{GTW?U+&{zfH#;GUsBLYVx&;i+r5p@?ZQN`BlZJgs$~Zte^80 zb&mMs_+kJXVc6raxq56t^iTyg%CPKi4sG8*Sttwy6sbfGf^}Z=D;owMouGU#3QlAT z+Um}JIaa@E_*!C6u-N1{Kd`es^v5um4LmG^84a^3lPk{9wTUyBkqzP5g=?}t`_aH5 zOh2xxai%>$dJkZdh3C_D3ya7~iuZl~`3x`#(!YIQqVuYG@S=QD`ge7zz3DSzi;Q{F|xW6QCe&m`{xPq~$@J{nhD zxuc)K7BY~686gu51~=KLsA^#QlMQUj2>m5M`utOYyA)v4`kaT`c%IDy#tItmg_izc z%(NJ#MDP02Cho1D|JQTb@vZa2@xcKgMR!>YsmHhd997GQjlEWX3Ti$udr79^=6{+a zCJh8h^bN#_&(1qeOO5fFpSq>9dmw(w)%aGpHoVl(&}wL*V$xUPELBUs-?Wc9JRk3> z4q1Jkcpq3U`SThiZ1TdWXG#Xw&6@Ie2lIZ#0-O~ppoXUO+*3V5SVhUpxY{y?oTM@% z9XJsDln2QB^S`%XvHZW-d+(?wv$k&(E2E;!GopY}bOezm9i*#_V(3z(1W-!oK{^Bo z>HuQ{4ZTLBcOejZ5eASNX(BC=mPkv0&_YSR9dw@eIqUu2^_@S?I{%!r*RbN`&b{xw z_tk&bb?+^u8`-dY&S~I@Lf`kCN_3ot;Vze-vy)TeQXX*he2{rt3oY4tqr@a1ijem- z??GN1JiF(Y!K!U;zxhva{QYh6$vDyM1xS$e(Zm00z!HQZ79DS@nDa03T0nuoYw>bS+57m^uE>VbWg)}Uf>pyMO=>1{tF>xPA)TtSZqc1aU)Fj9b9C- zXwZDEB^2EfO7N|GsFRZWM+^k&UX04xJS*qLuPU}E)v2Q8 z3hr8!6MxB+M-$)N>x(^#XhZSi#WV>vICJ2(AByVKggY9xx@lh)%kfr4Dg2CxAb~qU zFW)^x>rd&Se^*1dJkbkRzAn17B|I=sff6$QB9wk;(zpyDTMlxy%IppC$TJ0VpFdvi zJ{@nIB(p3Ha}*W6Vni%*1LNpF?Cfaj1Kr%awOfJCyN_6A?W(2nWzR_@#V$U5PEuM! zKn%a6ZT+qI8w*Q2*dP-~A}?c{ zQ9{0%`&&#;*Z$JoJ5LY!R@R*JspD|-75?sG`ZpzpK#5`O)%1YJB z!4EBG8zq&KBivAJ|F^pi)+fnrKJ$#tMW3o`-~IB7iFti{OK1VeiQ}0?ceKydE}57W zYCp8mr1*(nP+B3M1BZi|5qW!Go&Qq%><~0>uTr2BRd=tuX&)&{E12?=s{cU9-JGEm zF1Omb?0!?FNmvcXC`jF3Ajb#QW z(lx5f(o7Vx^LIj1LHMGYI(IHBOXAFrc`PmRuKDpc9BebyS+E;FH-bMWWFX(o(*|`(PW1(2BZu(BrV>AWqq7tYwhM{inuB}50 z4*@F2SqIli!|OEWtMF>}`_mF`QKKcDE-{@hMhnv^cU3|w-c5RCOD_23uZ}bs=-&{7 zXvuO7oqhhQc&k)+zTkach(SgLU>r62T+=nhj*Mw{T-2BhH;d#CXXe7T@7yuS06KUl z;s)5p4zxjS!rq(s`nLV<^C6)zeU$y|!@YVAlN*q1&V*~feK$5kq|}aC3=iYC26GG$ zmW;*5xk+dyr$%0tm`PR#HgAZ>9B7k${#p=_o@7bnW%B5+uqQYFfY;9f&xTA7e;jpCtL7LnvB7>O2!2oR{);PjgfrI#;f0y5C>uW=c3_kLUfl5f;a(7|3rQB)IxWfL65#KopxCOy$o zBm_Y_70-y%;5OW{dlD?RJ51;DW-4z$9aH_pOH>N`f1FhdJgB&R!xnFx{lE|?1OLx! z^|<{SwXrhjWt+&gFY0-~B8`q8=`Sw9KlCr{@t$8mr^wE3{DDZtRz?La)gt%O7wD^q zvtE3vsNas)<7!CO*%D3AB*AQqOP?XIws*A)#A+1Uttcu6`7-JRVQS&w!oZ^kp0C z@F(|tXh5Eg>FE!&lflFSUAoEFTOryRe?3P0ip4cu+!7^jDQL^RCThW)tI4jo-7wvz8IkPa=*i==U&e1Zk>9b7k`V?a$7HF1Qnk(Ug zS%!yzBE#YJd3xt@NdILADS2X1s#NcO;G^BDxu7lexI}wXGE70_qtg=a)f#{LS3JIX z5_;)lkQE}w+uFy3q#PwoKX23Y%m2(}iG9_5k@G;ACZg7*lhGnfMvUf9l-eG4b zZK;_dJA4Wia*S>?IlMPEg71Jd*~f{kV7AV|0oG~?f& zdz(@5=;1#>!pM8Lo}qZpMc?t87$XXNw*XKR(8+ZwWN|p=9lpjsvz2l@Iqe0Ijlk7< zC5d!?+E+m2z8;jYd-|9@UxGl|K22{9zEds@x8xR&9m8CmRXJpv^X@<<>gn-DYREsT z@Q?n>B!k$=+$&1MpKhD0)R$`L6=nC8FL^4eyPsya`|^E5>G%#Y&NshsINP(s0r+F= zql}&s^aA+XY|`)g!2bQ#*m#O;W|e|7hVv|U@L#G@3GS=r+kNXf+}+8slC7CyRbq*3xVZ*XLWj`g=^F%+)gs( z+zFp95tRp?mm{?QM!Ej)`mI_$>Ad>+ED$6ATAU>K|00K`eRb{SO!X9 z9>@29i*)Z`J}g;(c$bTvqS~7$jx@RTM>M^m-psb5G>Ug5pJZA#h#{~cwPWK$bIwL) zmW8!ryP;!2NQ-kW@EFR>ahjS3*J?HJ^L%Ct883nUvgYd99+NlfAR8aJe@kgqs+9iwXboM@j$*b-$rwiCW+ zGvI)%q9wz)4*(J)H$n=hAY_qj`RhNEzH0Wu?ujAKv!)}RP{upG{q@KNH$Rcx5`7W> z{bx)^7O1TKNw2xex;ux=sDDx4ZbBq444*xtjY=*EEd-XaYx==8Z_}tAb49Hriotuu zz=6JV&Fjn;tHXqS=#O&M@;0sk-4uep5Lj~hV4=gnu5!saDOGM4D~jOOhT~x8$2uEQ z+udV9r!sGuVGfr#9?=EEaaX|@YJ<~tA@khQ z*}XBq7I>TL^#m{#%e!odxj$Z}jPpoh>%lng;DhSSfi`MWu#Bs_BzO=NgN5!<0@D4j z$Q)fQoPl9b1W{c0f?tZQ_TalIUx|7;1x)xwiO#(@A5)9&y)y#QAox#-v6}cuTUMys zzEZgJg7}-`me943iA#{K+Qk?V{N~bEeU9c2Kc2Z0Q4jUES*p46Lw0^e5|?PwFdPMm zYzOy&1_Y!T;{`X`C;F;%xTai&Bms0m1LWnd2O8C$4=R z5ODU4HUu%a{;K(54CetAZyIUe>`-$vD`;yD21bS1Wd4$p(^Y_v%G(DTI>w`ol?-a`#* zFEOYrO+ITeO+D%5zBhh00JU4QIx~Ovn6pUr@7tB(RHp=i zhW!$41@ZL6-^b=LYQEbo70Sn!kJr@-W&N{b)J-=q0^5l1qQP2*krGAhE~;lk*~Zc) z{BUVo`T_sEcoQZH=+Mk|A3IResyT_uGVzDGw!%2|?$sgaGfxZf&KZ-!PFvJPum9HZ z0|5PkTP*wur^j{z`v0u7G)yf4n6&@|V8%}V5f%TuQpNS+VC4Lv9J~>}zUF;@R;ilb zQDlzfCl1H*Ot4)Upc>z6YioVW`$QoC!+Pxvav49Gjy`iD$G5)^IY9dP{*33)Nd0F^ zWft2YrgJIN9EtqvOdw)W9Q!k_0C)ZX{v5;3G70gcam5|f+}z!||9| z+X(93eK?Ow{#Ba?AMgD^oT#guGI|*+@09t$suJ!@DU*jB&z7($-sr~I^ZYE5F}}ii zB+Qw-q=;5J@~>W;yr?LZ0i)pG7dDbrJ|fSiUAp|_SF=OXe_8%|3v2jAwk+-9i8GK7 zGX)>xhF+K{8qL`U`Q|8zx|-rzReHu%dKVIsBGXHf%%PJddbKgv&88P@FLvB$^bJ1Y zm-NSj7k7SDIC3|v`WFH1?bHF4U)cOE5#P7=&rt)Xm#7TJmOkQPR8=F4qFOuvlGZwI zzOQLRll=M`P!t<+LzAz2GgBM$@T1@en-6R;Drt1R_qF+h@3>7(ti!H2GFjiu#%?$D zStT`Hum?VE>hQH|T=pRdEYSnfm#e|5c5KK<93(iU|@)HaEo2 zucoUax^{xWv+kZyKh$0}Bh)wdsXs%fG{39M!IwW2=xDRj94S{bQ&PX(vrg2@LRr|* zh~xJ1M}f>j#mM8VMo{(rN88Zf(?5zsvvhuePme0TF){&w&E{U%oKW+P zO?)7_MV}>1O3G)YQf18xt`+W_uO)4_95GQ<^k$kosrY8I=N_t7ZghyQgWcN}63&U` zb{&CHp9<7`@dK0cke_09(;iM$3kMp;?$O=u2$i9=-Q~zL@#R6_rION%7V@)}2{mMk zalp{kHGiU6W`=6K8L{Ma83?Mf&^_fii-Df5&9+6d2$We;tmic@%txPN7hj~2vbmgoqPY|UZSBd?xIW+LF!1_kww~MJib_M@t z!6aPL3NdVkOQTz#ziyMu-s3AxPIG9&czl?{)A<+m>s_wPB)*`dfqg zj@n4eMt?@~kN_lzI@D^35gis55H^;;<*<@<9aA@MFvc#Y0f3J~v`+OHJ%fRSfM9g&d>(*?JU1jR`D|JR< zSE&}1o_1_I)pGi@fB+M!k5`6lp^kZ?^Sy&W2w`v zka3r6DPI=K7A9KrVQ#tDbwSkXhik5*a#lf3tfz0xq;g(GDp9PC>$jy*OzzV=hb9sg z@CVF$LFB)FE4n9k29{?K5}GnjUti+Wo2eb!f|fJc9zWLKe}8k0#bXGtnfs3zoMpu0 zQ8-#iiTcTClDn_MSkU=Fomxt~P7(j1Z|w66Wc^3umIq5y%{Qa0m^vIENp70mc)A(9 z_;K?^+z|skIGwE1*RZQ^9;EHQ01GW*Y9Y{?|1`p`cz^$n_gPkty^`yS@q$_=Yv+1l zYcEq5+Wqk-4TL$d^$opDJp-C2vYZ{SJ_x^tjFK-ixAepEPZNtf`|{})N&y`9%F zkbii(q3hSsGcenCBCm^!ozvC#_XY+Yy=a^M>VHfU!lSmPw}ay_Qpdb$JC$oz`y)0< zeAD$|*D;fhN7!{9-QhhsXP(_NhpyHGeM#>x#{8RFRt^tn z=IG5j%BSgpsigpS{~a%|b*10j&GWFMOjb&(|MR9DpQ~!(Uf+F-=s@2%?03cY&O<8bQfty|=!i zNvUuIynJ3p;jnF4P^WsF;o<42hz^l;Ra3+Kn;R@?Icv$?g1bLlnavZlyt-G%uNFh#;k|CA zz;YIU@gG^2x{o!wK`Nj(|L@sh+5GZbH_9CpH$1lJt-bn2%Bw8y&7 z!A_bajg+e~W^5)>B=CkkQ+AZtV$rbYOD)){|I$QPx<0I7F1!0rzRd_9&Xib;Ck}3@ zzd4syN}LPXNRry*J*@Kp@Da48=z8T~p@`Lw;6TUC;TLsSuEXRKdxZ(?;UWxEg>I7Zi5*!z3oAUSYPy|nZWogG5 z+L)`(tG_Rc8w!cyV4KQ7E&RZN>`lYI~7K zditQM^1$Pw*cx~LoXYF-h+TU_1=Y|t_BTK)7(q~2(?VdB)Fy1tEF(l!HCg|CH@2Zz zuiK1_d)AHDvUz9?7BlC~6i0joY_LGk`2tgTQ@8qdt*(Ile6>Ye41$`{Cu0`i@0e_v zGT><>3M9t1URYy0Da!y&{PIP+Z?j0xNIT`**>Ck}O}@b9HdkIytCor8t#3&&>0a|? z>a*rpv-Fj-VWLX@Wb_)5a@S1k0qq2f=SBf%NiWIL(T3i8TjK)!2gx*A^utn$T+NWE zEQh%sofOlE%!ETMSMyu;OlZb+s^MS>s>`MKzU&)V@6aTuJB=~i7j`3$=mfx132U3C zO!v$x+c~Mu=~gn)*sPZMrfqwTbkUwlg2tpWrqC6JHC3>0TZpfyEP-^tx9HY6Gz~OQ zn5)@6U0+|9lO}Q3fpZb|xP&cm7Uj0ro-i=cpq!=}iGh(y%fX+aNz*0q!jY_EUlY?E z>~+a-=r8KUf3AFjc`NhZE|joFA;O2D|ikQ1c}OU<7gx z2Df~V^fq%oXxXXO8X@IdRpCHY_0OsCSVFLJ;es|#&!%MU4H>TFXgm{M1w+K~U z`-Ho=Hn}@ue^>D&3s){afVvlmKG4dZ#S!e}9y`|3702Q7U?_+(SS07-F7HjGXflYM zOCo}u6|yDu;>+&E=5=m|0yXZ%Og^_Tk+4d^Rare8 zAO8<30{v8IsDL#8Qi;t8*%;_4r0riPl~~1$7n0gb=}+>BEnT?rA|{VHhSLhy@;7s| z0GU~uZRdss%P>wq7Si@;46Bd6_=8Xp!2r@>A6TM; zu9upNwz06#DSN=PTf%bn%_(3XfkVw~*Ir7yA;#b2C}z7M$ek*WE97&vTZR1^6U9|E zjpUHJ7djd>!XCp{(hU75swo2I7#GLzLQp#J^Sdr6poQ zb)Z|xC$SG5A299oJyK>$rVZ_FVXf z*n+VLSqay!S5N4vx(zHGiC*}C!9d_?E0idV+l(g265g^>L}tS^m3}T*P|#q)fv=cT zEiI-VBx=b|a|qJfaJH_z3K)(yTM)>-5PFqCR&Pkuup?YG-dfrIvyr>l?nC#3QZqraxK_P!)hq_9fZc0<~W-Y#coeHD2&}D>&OLcph z4imZIGT=C_Bd48{jk>U;^q#YtuHLn>sgFo6M+dxRO{gPTWdn174GkUx{!MV48BveH zL^mcDtJ5D^i`2&qwhj(dZ@#3B?Z}zm0H*nsYh6&^>_i;DAwRPPs5K(BtT)m_lpdo z?odl-6@*?0$7v;1`f12l;dYxh+IT3z@|J$7I)-r(*o~S}ck7csLqz$z-en(olmc$B zYMBKfwvEpKU1Y4=Jz6&Zt8L5IGOGP=qCgvZOeC%QPc(>N9q>?bGD5Ld=2 z+EhH5O`y|NdB7WYgmzbZ`CR(_xSA@Y9J_d)-ir=uPqd9rN0&NeH=9u?|2eH#+0k)O zLBa81hWa@FX#8^3p0gS>3}5=kHKnOw!F?Cg+JU7Wie zwkGqE5{ReH4z~346pK54J7O~{&ZqKbis)%NGA2Am9E;8>bJvI36jRIHtIAGi1c9wgO-P!+s!sN2#jj-e|GyrbUIkl~)5G$V9; ze4DUJe@f%*+7u~U&p+2LAW%NkHk>&6#5+IT_wk&QuDkp3GmSBr8tmpDOr`CKc`kaW z%Lz8}GvM)@$;mDWl8mg|azwhw__)D$w4QO3B?PmRxJ9nE#iH29f~Y-42+SH8 zf%&lHD}>lwsyWdB27PJo=(WsKqUvzyf$@L*+Sz9OP1HzwAG?1Pc1YYRb-D~cG`vY1 z)vU^KT`6Y6(O|S^j4czD^kw~e)O4CnZ!YH^y&~Xlh z)U>GDP5AbGur_5T|8wLtTiiS(xveLRU}v)*>rhLs6vwI@VX1x`F19zZPa~H%jCwCw z6%b`}97gNyZEu86RLeU7E)R zJQ~*iUN9fw3m^`X1K=I#WUI5a4f-}98wl_{sd0kORfcQi*wrf~jzCaJ!WG!;oSCw{ z?|&c~cW;hIqU#Pzp78NW(?S)|wtJY7gJcqip^;gNd22XFq<&8B?yQsd@RHkJH^rVB zIT2DYLvx$-zlw?4mt{DvxA!#c7t-$TlX>nT90eeg$!;#ltCpdAF&su+uPPTYfwxdr zt^g%uQDBB5csHbj%|NgwUwz>lPQLkjhMy+xV2l>#b!~p%JDT&SLg*sXWhwL9+d$7w zk)j>9pTUibyGELtq{R8Xz`o*WBGQ$yzo~(XV?rx!iwDR`;MYnlbE4kB%<~-RQT6d; zB8|1r-{7ZJYA;J8wc;!f@R=;2x>b`O?Uzpso2KlSHpX#RiEBR|Y3zBxps}A;W4y_c z2o~41Gi5>?acli$Nc)30lEjQ7yLY{SQpNXiV&;Z3^OKuyyyg#5Z=U_!FLxmnTU?3f zWwCW*a}2oQY*D3l@k?=6eSx#H%f1`gQb9GXY2xin+;w)qB`-uZw*Tiav!80jar~=FLFpunRu#E4fyMEQ=!!4>K zWqi^5ZWgA)xwMAOM^O~AR>ydoteANm)?s4~Vtq(tsS_48Tgp#{p!Npug}($k*!V?K z<&Mng1854juzfox{RaE>u>_6mB=fSMKpuIppf|bKI%MYO@ktsTLDZ!cW#J)-&e}Tl zm@jnBwN~*-;Jb|YWs%ZZ*IvhusjsBcebo2uvFlZHh^E?Vvo_5K#Xx!%6xt$X%CHljHn!$ZV)D5snSJ_}9Hh#Xd^N6CX z!Mp{SGgoxix+U%ZhH5-H{@ZZwMmG|rf5v}~6%KX#w=(8xaCFzc98F%t<&uUHNF6NI zXS7om$}tRM;7!jW88+}j9}pJC+!L0284r7`Z$_VjBV(7Tt`d4Kh4J>S`^(M+_JLq5 z#>^PYIlqvvXNue~iXLO*9ZzW-u#MQ&hB!M$ynsk$XKh~&ALc+!ZQ1x8;J(lak*XiK zF9e8adt_q_shnDs8xt&m+%ILAI}Rg@1_TeW==|OgIA2r3b6v1ApGPgAGxWB}pcfWk zZ@{2?#9rQW)7c%1@TuSQ39a_7$o80;=7`?NDZwh>)N19R&)^OLed^ssWfT>lvV~iX z4wdxHjN|oC!Q{Tcr%Z6RN+pG{FfMFO?yd+g7@AlQVy-J3;6mriSG>1Kb={kexH9kj zfh_p=j7wnLaN<4p>6V9K>yWxvW zxh(JcEtC(rFBG^<%jBkiyJ%9lwn2gGbZ@9td+5qns>g_{(;+W}9ook>M(e+|yBy;Z zjaCo20a@#?DO)(p@=hh_-!nE0D|HoV|Maxe6iALAb+W%hXGwDa)mNx@Do} zWS7ETx*4JC)p&e%wX2O|5o53N7#cIS_IaNIcX1kh1BdztoiyOf-6iyTOYmuIBzy9b^SP4aEVkt?_Xbl9H&wRm(*pu+^b#Tl#{rO69|XKw&fNd- zYGFAgOt2u}MZTWzl(CXE`Cdn(jPM4!bN--XtcqOi+;W4Bh4GXY&<~iM3POnH`GhmD z4%fQD1aHL;7*W>p8yCM++28#~4}Mr)uCKs~ci$|Kj*Y009U%^xj)&t)5I zedJh7*O50JalCsnX*FEE@7c@EIRj8>TV$OcanLazLB4judoba=DM0YUI>`oD#$GXp zg+;mFaI)+Vl(Rie`cHHuW*N?}KeC6LwNd*VA8|V**>YS*>j}i!DKAzC&8-Fe~`lxrAw$+5Uumi#lYa3OTkhI5YZLvv^l%isiG~bhCDPt ze@0A2W9V{T95H@;j~`~nlVQT8Y*R2eH~?ETQXAqW63L!kyKOoQusMM2 zh&;m4pZHMJVrs__iHlt5=f(}xcSz{fe(weL+KD$s0!{H~(#LNBxN zv5I21E|nN&y4Em$MMjvTz(}P`0lLdR_vt_5`FpzNK}V};=|SXss99GVEC`*Odks}# z5D%Mils;BW-)OS!gyUmd$+lXHpCD$}Z+HG@9KDxYtohHab!r1QmzYCu2h8z~H}qhZ zzsD2PSrZz!3T}9+sH(zqGQItSqU0Q@TMF6PPluTyU!a2`)q7WT7e!QF`vwiKU%97= zx=p;EoMxyG-}w!o9#g9HbwCM3lRXn&?e)35va~RfK)wPqR3ccbBK;hVE0_gy?au(m zwQ_UiY0bbupvq{boq2H}-GHgLz8Zw?x91NTBV}!N_MRR9e8<>`2AY~!e{v&NfT^xN z)K^-v{>DYdG(OlRO6DM9)ZQNbKcE9C3ov~?J(SzFm7R-A0TU%|HvXKFkUstv8eU@_ zGR|w>;NonGmzD^@siyM#>2`~KWIAr7xQ7w{148i9{jabSlvVdkOMIyN%=-g>w~cMf zfkA(1qbAB`_5nyKOM7;CuqyU{AEq|GI;G<&;h~;B2*dY^zsx0_EnF03qFEeez)DR= z4>q{^E7khvGI2FmQfXh4zX+5UA>%rS5-XCK2N+!9LYJhBO6;;aQZzh1z6e@*wbzbt z=w=&Wz3&A$MyQd|K|pJ95|cboTOwS%_yzWTJ!5@oK~Ix}Us4Pdfi?w5LviBWz%LdS zkGl?bOAUJ=qOl!#eYeiv-fSewtvEg99Dwo~+)Yfc=ZWK_jlxp0D6=*7YtbP<;5;5Y zV7in!CCOV)Kihx*JHuzoCpWXMD-{@lFt47|3HE~ul-@4#5I5->sSAi27dY~svu)W#);p97TXjbVtK_X=bgOPsi;evt<_?tADROUA_#a+ zX}gaD6%r4UH*ta*LYu_w1RH%#+>}qfig;l-y7}nFHntV8p282C3zoilE*3U#Sf(Bc zHblbg`j1vpob(b<#=815<=IIl@n;ToE%GD1r5idT25&SeRS}4yKkL!>fIO?b(PS*o z%!>#@Ouz=Sadl%~a@-w^h{LZ!;4BKQGvjxw>i1_#WletOMZalvm9Jrf`cM;%SV6R# zd3sLmODG5HaigNb*K8b#_l_Tb8MxV&c`&{LrWR$6FAlgfR>=rHs~vpd0ZLh)6xBC5 zulOagmq3V(2U(nVZdm#>5POV&Pj}Hyv`O`k19}ghM!o%Qbht)hQnN41r?DZ3zSMI~ zGm^z&-3F}^`**<)+>DFTF1g3klMxl=MwtkaS}96SuU)XW4R|b1Z)>0prIyI(Mo$bQql8U z@ZqzcXU5M6K^h%_z%$z`gdO_c8Kw{vyyRtz4rFIhX!~q1-#}HP&`?b00JQcxDK_;4 z7}aL)ajpNdp5_*LeQD3cZ+UN+wxyR{Xa{#JwnBybnuZ-d+d3%0ql+rq%~W(wwJ~Bc z8)#wdGC2_Yho(jrBXZfklRIo_YO(3i$@%XSDXK#H*^8X|Ig2$8v>l+h9K@85U5n{J z^G&X8S~H;q(3<}=z&a$#QI2JFetxboPoCG)^f#d?9p%zMY_iYx64E-0lK8uK^l_z3 zSD1N&Nwu)KeQ#<`u$P{ZS`FE8{kZ4MfnlWM05C0Z=IJkLGV%^d9L+rSkl&JQ3{r~Fx;NlN2wf8685)DAgGU2qa_y8-c{w6`&HW6V}<-J);Xg=mBFBjpH+Ic~!Wm-Y^~-%V*s7N89+dmEZr za$#V==LJoHpn3fG@w+H{dwYQ^SFQluPWb%G`NhTVh2cU`C?SzHO(cmx4@*Y%Iw!`X z_agPpYS~z>zV*ik3bj32)hJC-^d{=&9rs9w*7-S*Cu+bx!}$dTU7BiZZv~QusJ$1< zV7T<62W9V>$t=kyiEwEXs7GVq!@eYe?CCm4&gU-N{VNVZ@uZ*ry ziRc0Y@HM9gTLr=n^TuGBi49y46m2WC$p4{Za@KBq{Qna4_EH*bdRF^j7>ABd0TX8@ zOZ4s-G263unZFMU^6yrnRNWkrai)YcQ|58-P^>r6Zs?EQyx%h#la5Cgu;g8bU#+sk*yUTEh!?Dn*|O z#x=(p=5fDPEv>l!^#HIl*~Kp=kBzadH+laoA~N$n3Vz4pd1P~G)M%brb&_+GnIL1a zdh|boW;Q=SGlzpZg&X^IDytI|FL}tre$)`}MB0@$fq8wiEze|leiP9Tbmh@_0=tG$ z&{wUOt20cyjk>+D<`(p=Pu8SX3|zfhFs_MR&=k(GKRGgEI&BVJ1EhRoC|+zRj(bNd>h`10VcC2TGr*7G7iZVG$s^vM06&D1@Q9WV&EVrvLQ`o}L~K#|aoe%lfLLmxV;2%tHW-l4}5mHdjk! z00I1XgxkY9E<9WyA2$|%J7{ym>_G5yB)v_5rdqYT9gc>v_GJfw!$eD6U0h{8GxUxf zWc3SV%#H!PNd4;MzXX^$0k_>(+%jHEU&{(Z`zoz~K#webEweK(dvqte8`PC)c@MKA z08m?!K)Y%*{O3vaf0rW?@^~-s==kohZ$Z7Y8E>|hMrAVSSI|xyda~_R^{?Q8+=Mmh z#yN-J?(S_po!jh*+gBH?hDmps-%DYCd>3paGwPu(+)#06&L*sUu0tI_bUn;EFHYmyYhZzLWDh3)*bbC}K`t=i zaPhbjrpR8GKA1EA)gh*xIA~^TZ^2pJZw0uyxjR37x~G+G_@eF6&dyGG(8hw+2rF3U zqvhb(_ntImh49;#O-xK^pET7_0>l(>_r#m-UImFB#h5JPV1?&#KdKFZ_G6|U58Bo6 z+u5fQRdxpV*JDNp2=SE3rmJ!g>7kSnG*Ylh@xD&JaaU30#qydE{4VQ0J5**UWw|Lp zFm1mr>w}67{VpOIKLV5LP)7I0^9scnXJ@#FkX=&}TF#9Nm6!1mrk0Stmg%Fasadft zd%iUk?e4*qMzz?e!a8vinBX4e?(TX_J^1KI1s{st z9l>W4TbK?A^RF*mRoRQ!ll0aoSMN2CdT4MKIH=&yLf@d-5yl2ZGqiTU<}H#DLx`&P z&Y-2)UD*wayvx`lm%X*w`^JBlzB*rPeeVeZ%siS>m#e9l z-mvn>c9x$(ZhE!%-hXz%!McV7&U;Q{)&jJD+O}qFTlY4!Z|!t!wTwqH#y=rLm-62- zwqs5)Os!geu0S5!%p&zP7c1C!UNeecUCnD@DF3YttnXE-D&kA9cTXZBJ~|K3a3{eldI^1xp#N;(HF{=mlN~!7ZVr)DQ37(MwN*RF+1?q z#&`578JQ|ziBpknv7=~>C^3) zvBV&&V_QW5HYZERn4Ezb|J zq|+m#K`!%#%$NePk$u87SYnm6y=wc5DD5M)Zg2!$WQBxVEP&~(Zf&iVJPs};TF)S! zy8YTuePMU3AY ze2~QWsk*izXG8I9pfH=5-(ViN)nJ||5I$-?x)xc=EpnRV#;B2n<74$lIK02tpdDRd zc6?3^0hIidU87evBT|jRn>mHvk}t={2UPYPvHm{xNGfd5$}Sj*i+lxt*qIji{iAYB z2EM++!7{y|NgdrdfH~Q|wPn?@jmKoIWB>`Ime>WWtMA>$j^LBvVj==Z5>&dk>5YqA zgwtw-68y&A?uR@apo+2M|3nopY=d~!^r{>+u`y(R{=2RX4!c(uiHv2C-kM<; z-wJ6{`pRCjsP5PBl3fZQL7`6?Q;2}p-U17Lu>4FaNRh-O02QPNM{`)FV2bCWNgG1e zJ*%6qX=|tj?J|*=tCclhfS0dJ z$WyWeLuA_~gmk=JA-o{=cwa-+a_eVLsqh7n8AN^|7h(D)G6Ijt{HFw7H2Upo!}sIu zTP|d|#u4R4Z))c*f6o?u?Md*K1pg=hrn2k3RYl#;wyGR5zz^@zLsn>;Qy#MfRy3T& zD5eI;_<_VT1wUF5$iH`dH0`S7ov)kNS9-oG=;GZz`!8i^A_452BE7X)N_-Lrp9{AE zn=6~i=LBTCj;W{X`fm{X`5DlN7~@`ecSvz%=lmpPQaSU#CoC>;Pa^ldmSeh4zlM*e znO9N5_r7UDF1KZc>dJ>^t8ssqY$QQ@x8?bQ#iQ#dQVbtj6rg}1yu2kYI{U)TXGeSn zfq(sql0Txp2c+c+5?N~7ylw4i2Z*xVI`L`y3r9%fA)PzK^f|j95)TAl4M@C0ANWXV zHeUUI$~J;GF%Ar3807Zx_m#aOO8-4XN6wpb=LR{(QzqlitdpjnLHj2$u5z zYzTYa6&$JuYXCm5T~(CJ$W7P zM#=A5*vxjmhq~?V*z5dhVrML6mz~7AnE2<5+$~}_$wq(VV`D0x-h!iq-Z-bceQWuc zK<8+cb!WVdk0TN(EJrnSm@!jBe^FFx1OqtH9_%wA#eXcUv$IRzc7aPNHaB$d6N7-? z-@ogN59C1(Dn5!RQ{5JlCq`h2!=J9Sw@pR5zmjTiVr5spLJ5EU>{-W?SpK@rU0(-Y zRgxlsB&8G?jfVAkf?d{z<5hXKJNEbNDUL3;(`1_%+EG3^xhZ~V_3nPE8CNax(lyu! zT|se0izahAg?(6x{n+TC65mNdxP#n9*RB&0%8LH89G#DCmvFND9#h@MmsbM>Z`q)t zqbj_pft=zH6xc1YRb>Z>3N%J#(K?H@Q0M=W?X=UZ`E~5?CzJQDr>gYTDNyvt6~&`H zjobXRl?SIb)JKnUsu7>PymIsD7G3U;=tUZ-;qsXV@286<%Cz~5i_c%#Oi%jjy&0{D zDM+zd2{e20&gop8--uKwUABpG;Yq#L1)E(pG z`oFT+J83h7_Q%Wj8E|WDoXNK>$|PwyVmeL0aWJrMVr*@ zx}qqkT)6%5l7YRc4uQ34*)MUeo{3#wVfF#$z*` z>Ypb>^`4#X ziKLVhXV5#|eU!JIqkd7VlOrK(W;}ZMD@{eIsP8?Y&o1?T|17DXe70iK{)E}^+9N(T z{DsTikF;Le-MmR#J8b(W{z=a(s(0^Hs_$!?sg(?jz)nk1-ssKAu501_ho7#B$|3`j z|FHS1Ao;>{*}r~+sSynpSJpRzB+_%M@}i(AB21osT=aUJ#m=3F?vBFy95PuHIZ<2U zIddNzGXp`%B9ks6wa!+iniZujpLm<;KFl*SJ}epN zBLAQb9F+6NvfESvNktvW_+8^>-Zj0!!eNfXlcQq4{w}L{=V*rH8^M^yj#ruwBzJin zL5*+W<55AS0fTa9c6fu~&Tv!QtRTv$W2?#Vq}`oY^j+tYDyW0!bsu{)-sFMt>VHb7p;X_ccZH*)^j6_Q^v}ZM_sctPjPQi>UCvVQDuK zF26}hV&5K3V%5_pT=|XA-k6S%t-7ihbggs##pxwNRa(I)cFXa&ld^ZbH*XcU4y@Cj zd?+Kyov$p%dFs3Iiq-9h99()b$qzS${)obQzlK|kUid;BGqEu4#y5y^>23@JHHL$n z@6DZM{?$j1(k6eDb((`0CxfP|ja6vcs({GRF%b#+G=<-Eow;6R%Pkv%Q zlK-$h@nm*cOMc)39r}=pL)dEBx-mN3X!+!W3y+lC)V@$GfQ}rfx8)0|&T%I8bB82p z3POUUt@dIoE*%Yg_|)e`zo%T?!q_(DO4B!(*h61kx$I8dc2(iAqVdUlCM@jXRW&L@ zvWXk*7fxuNIFx;{N#96CCFFmtNF$1aqoqMV=OACJp0xy%giZ|b1wJh`Sc1ad3iHF||1o$S;*Z%V)!}|LVwxTOv8$1L< zueu(P;>wSGcDnm-$(Pg>@nIevj(PVb+r$@r`QF12=#}(veW}3gQa=7CPuFP|8T)kT zI3L!0u=?Mp#gm`&+?AYZV`p|KLrSB|aPCy+&Grt;m6L1xc>5V#4a%lpd}DsSa>KvE z|ECmzW!B68+@D{_oLM{r6bzpr!H~K-;$UQy^`<>vU(Nn=dYZ^%$C9GyPx^9uglZKM zbm!f1t@_Nh+@3pdUkPip`JdM7>!bW_s;0VZYPCPQYNf*2xBCQ7NtaESZ{t^-K4sO@ zR)-D&#ioQ$-~N2H`e!vqk$ZyNL$`4Iliv6>4a`J2O{0|2&Xnnf$_sg@~M-Qe&Wi1ZR%(Xnl{xX!M)^nY3Qt4Z6 zk#j$eO=)-4x>j@b`3)^4ThF`?q36zp!ZL{!JERz2{=oYDZU)aPwNI;V{w!j=-uJid zaPHlnO*8%|9DcF(bxqx}sqZ9vldtUA(|GEI?K<^ITfQ$j`Df|%{xym3_szW`+CRaf zc-FIE-7WVUJtUe6Iu`bY_0?51Exx{K%5{@VA;NRF_I=o;yJt@k=VICTfdreH$hqdSWrpK9*^wsS%;po z3Pk!|20CzR>dt99Jc@QSl-}Nde%~(l8@#(s{4T%CI@K&_B{VlBYx~BJjhBo5uLDzKJK`uwMtJ5S&7 zxLg0-yQ3g-PHp}E=B?astM!iQe|%@Hz9Vky-@0#ZTP#hl`3FizYfF>%lH*mj_dN@k)Vu;f#*&E5Ac z45akSksr6BK`o|Bmp`mnzxVIC&sqT>aR{2HB5n`zHUypJ)~f@X59ZGj2j&yFN)!DZ zKQ!SgGvf+A2E()g3z`RyK#2;1mQ)@*>JQ_ncz$l`o)1?!=~-)}xV^fX+PqR(kl>Qa zq;)=-_rTWFM)yzB{QCsAL5IFPdcMfDd&b*zu<3HguYX=z33kO76&tU8f8rgV-F))! SqSbLwN5Rw8&t;ucLK6TtSdwJ` literal 0 HcmV?d00001 diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml new file mode 100644 index 000000000000000..f1ed5a8a5a78bab --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml @@ -0,0 +1,41 @@ +format_version: 1.0.0 +name: with_required_variables +title: Package with variables +description: This is a package. +version: 0.1.0 +categories: [] +# Options are experimental, beta, ga +release: beta +# The package type. The options for now are [integration, solution], more type might be added in the future. +# The default type is integration and will be set if empty. +type: integration +license: basic +# This package can be removed +removable: true + +requirement: + elasticsearch: + versions: ">7.7.0" + kibana: + versions: ">7.7.0" + +screenshots: +- src: "/img/screenshots/metricbeat_dashboard.png" + title: "metricbeat dashboard" + size: "1855x949" + type: "image/png" +icons: + - src: "/img/logo.svg" + size: "16x16" + type: "image/svg+xml" + + +policy_templates: + - name: with_required_variables + title: Package Policy Upgrade + description: Test Package for Upgrading Package Policies + inputs: + - type: test_input + title: Test Input + description: Test Input + enabled: true \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 1815ab91b53165f..75d5c58d8e37590 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -324,5 +324,80 @@ export default function (providerContext: FtrProviderContext) { }) .expect(400); }); + + it('should return a 400 with required variables not provided', async function () { + const { body } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'pacakge-policy-required-variables-test-456', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [ + { + enabled: true, + streams: [ + { + data_stream: { + dataset: 'with_required_variables.log', + type: 'logs', + }, + enabled: true, + vars: {}, + }, + ], + type: 'test_input', + }, + ], + package: { + name: 'with_required_variables', + version: '0.1.0', + }, + }) + .expect(400); + expect(body.message).contain('Package policy is invalid'); + }); + + it('should work with required variables provided', async function () { + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'pacakge-policy-required-variables-test-123', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [ + { + enabled: true, + streams: [ + { + data_stream: { + dataset: 'with_required_variables.log', + type: 'logs', + }, + enabled: true, + vars: { + test_var_required: { + value: 'I am required', + }, + }, + }, + ], + type: 'test_input', + }, + ], + package: { + name: 'with_required_variables', + version: '0.1.0', + }, + }) + .expect(200); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts index 727a779178bb35a..0ccbb913f0d10c4 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/upgrade.ts @@ -841,6 +841,7 @@ export default function (providerContext: FtrProviderContext) { policy_template: 'package_policy_upgrade', type: 'test_input_new_2', enabled: true, + vars: {}, streams: [ { id: 'test-package_policy_upgrade-xxxx', @@ -850,6 +851,12 @@ export default function (providerContext: FtrProviderContext) { dataset: 'package_policy_upgrade.test_stream_new_2', }, vars: { + test_input_new_2_var_1: { + value: 'Test input value 1', + }, + test_input_new_2_var_2: { + value: 'Test input value 2', + }, test_var_new_2_var_1: { value: 'Test value 1', }, @@ -867,7 +874,6 @@ export default function (providerContext: FtrProviderContext) { version: '0.5.0-restructure-inputs', }, }); - packagePolicyId = packagePolicyResponse.item.id; }); From bdb3ce465f5d56da97e2a8342fc1e8528e92109d Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 19 Jan 2022 11:50:25 -0700 Subject: [PATCH 05/12] [maps] fetch geometry from fields API (#122431) * [maps] fetch geometry from fields API * tslint, eslint * fix elasticsearch_geo_utils unit test * more clean up of unit test * i18n * clean up * eslint * update functional test expects * eslint * remove unused turfCircle import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 1 - .../elasticsearch_geo_utils.test.js | 148 +++++++++++------- .../elasticsearch_geo_utils.ts | 145 ++++------------- .../es_search_source/es_search_source.tsx | 18 ++- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../test/functional/apps/maps/mvt_scaling.js | 2 +- yarn.lock | 36 +---- 8 files changed, 146 insertions(+), 208 deletions(-) diff --git a/package.json b/package.json index 07b75529823065c..429d1ea67e73f2a 100644 --- a/package.json +++ b/package.json @@ -411,7 +411,6 @@ "venn.js": "0.2.20", "vinyl": "^2.2.0", "vt-pbf": "^3.1.1", - "wellknown": "^0.5.0", "whatwg-fetch": "^3.0.0", "xml2js": "^0.4.22", "yauzl": "^2.10.0" diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js index d4fcab060b01c00..096f5370ca3b97c 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js @@ -42,14 +42,24 @@ describe('hitsToGeoJson', () => { _id: 'doc1', _index: 'index1', fields: { - [geoFieldName]: '20,100', + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }, }, { _id: 'doc2', _index: 'index1', - _source: { - [geoFieldName]: '30,110', + fields: { + [geoFieldName]: [ + { + type: 'Point', + coordinates: [110, 30], + }, + ], }, }, ]; @@ -73,12 +83,17 @@ describe('hitsToGeoJson', () => { it('Should handle documents where geoField is not populated', () => { const hits = [ { - _source: { - [geoFieldName]: '20,100', + fields: { + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }, }, { - _source: {}, + fields: {}, }, ]; const geojson = hitsToGeoJson(hits, flattenHitMock, geoFieldName, 'geo_point', []); @@ -90,10 +105,15 @@ describe('hitsToGeoJson', () => { const hits = [ { _source: { - [geoFieldName]: '20,100', myField: 8, }, fields: { + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], myScriptedField: 10, }, }, @@ -109,8 +129,17 @@ describe('hitsToGeoJson', () => { { _id: 'doc1', _index: 'index1', - _source: { - [geoFieldName]: ['20,100', '30,110'], + fields: { + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + { + type: 'Point', + coordinates: [110, 30], + }, + ], myField: 8, }, }, @@ -151,15 +180,15 @@ describe('hitsToGeoJson', () => { { _id: 'doc1', _index: 'index1', - _source: { + fields: { [geoFieldName]: { type: 'GeometryCollection', geometries: [ { - type: 'geometrycollection', //explicitly test coercion to proper GeoJson type value + type: 'geometrycollection', geometries: [ { - type: 'point', //explicitly test coercion to proper GeoJson type value + type: 'Point', coordinates: [0, 0], }, ], @@ -216,8 +245,11 @@ describe('hitsToGeoJson', () => { { _id: 'doc1', _index: 'index1', - _source: { - [geoFieldName]: '20,100', + fields: { + [geoFieldName]: { + type: 'Point', + coordinates: [100, 20], + }, myDateField: '1587156257081', }, }, @@ -234,16 +266,21 @@ describe('hitsToGeoJson', () => { const geoFieldName = 'my.location'; const indexPatternFlattenHit = (hit) => { return { - [geoFieldName]: _.get(hit._source, geoFieldName), + [geoFieldName]: _.get(hit.fields, geoFieldName), }; }; it('Should handle geoField being an object', () => { const hits = [ { - _source: { + fields: { my: { - location: '20,100', + location: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }, }, }, @@ -258,8 +295,13 @@ describe('hitsToGeoJson', () => { it('Should handle geoField containing dot in the name', () => { const hits = [ { - _source: { - ['my.location']: '20,100', + fields: { + ['my.location']: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }, }, ]; @@ -273,15 +315,25 @@ describe('hitsToGeoJson', () => { it('Should not modify results of flattenHit', () => { const geoFieldName = 'location'; const cachedProperities = { - [geoFieldName]: '20,100', + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }; const cachedFlattenHit = () => { return cachedProperities; }; const hits = [ { - _source: { - [geoFieldName]: '20,100', + fields: { + [geoFieldName]: [ + { + type: 'Point', + coordinates: [100, 20], + }, + ], }, }, ]; @@ -296,8 +348,11 @@ describe('geoPointToGeometry', () => { const lat = 41.12; const lon = -71.34; - it('Should convert single docvalue_field', () => { - const value = `${lat},${lon}`; + it('Should convert value', () => { + const value = { + type: 'Point', + coordinates: [lon, lat], + }; const points = []; geoPointToGeometry(value, points); expect(points.length).toBe(1); @@ -305,10 +360,19 @@ describe('geoPointToGeometry', () => { expect(points[0].coordinates).toEqual([lon, lat]); }); - it('Should convert multiple docvalue_fields', () => { + it('Should convert array of values', () => { const lat2 = 30; const lon2 = -60; - const value = [`${lat},${lon}`, `${lat2},${lon2}`]; + const value = [ + { + type: 'Point', + coordinates: [lon, lat], + }, + { + type: 'Point', + coordinates: [lon2, lat2], + }, + ]; const points = []; geoPointToGeometry(value, points); expect(points.length).toBe(2); @@ -318,13 +382,13 @@ describe('geoPointToGeometry', () => { }); describe('geoShapeToGeometry', () => { - it('Should convert value stored as geojson', () => { + it('Should convert value', () => { const coordinates = [ [-77.03653, 38.897676], [-77.009051, 38.889939], ]; const value = { - type: 'linestring', + type: 'LineString', coordinates: coordinates, }; const shapes = []; @@ -340,7 +404,7 @@ describe('geoShapeToGeometry', () => { [101.0, 0.0], ]; const value = { - type: 'envelope', + type: 'Envelope', coordinates: coordinates, }; const shapes = []; @@ -366,11 +430,11 @@ describe('geoShapeToGeometry', () => { const pointCoordinates = [125.6, 10.1]; const value = [ { - type: 'linestring', + type: 'LineString', coordinates: linestringCoordinates, }, { - type: 'point', + type: 'Point', coordinates: pointCoordinates, }, ]; @@ -382,28 +446,6 @@ describe('geoShapeToGeometry', () => { expect(shapes[1].type).toBe('Point'); expect(shapes[1].coordinates).toEqual(pointCoordinates); }); - - it('Should convert wkt shapes to geojson', () => { - const pointWkt = 'POINT (32 40)'; - const linestringWkt = 'LINESTRING (50 60, 70 80)'; - - const shapes = []; - geoShapeToGeometry(pointWkt, shapes); - geoShapeToGeometry(linestringWkt, shapes); - - expect(shapes.length).toBe(2); - expect(shapes[0]).toEqual({ - coordinates: [32, 40], - type: 'Point', - }); - expect(shapes[1]).toEqual({ - coordinates: [ - [50, 60], - [70, 80], - ], - type: 'LineString', - }); - }); }); describe('roundCoordinates', () => { diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts index 98782e7447b348f..3e494976787447b 100644 --- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts +++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts @@ -7,10 +7,6 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -// @ts-expect-error -import { parse } from 'wellknown'; -// @ts-expect-error -import turfCircle from '@turf/circle'; import { Feature, FeatureCollection, Geometry, Polygon, Point, Position } from 'geojson'; import { BBox } from '@turf/helpers'; import { @@ -89,12 +85,12 @@ export function hitsToGeoJson( ensureGeoField(geoFieldType); if (geoFieldType === ES_GEO_FIELD_TYPE.GEO_POINT) { geoPointToGeometry( - properties[geoFieldName] as string | string[] | undefined, + properties[geoFieldName] as Point | Point[] | undefined, tmpGeometriesAccumulator ); } else { geoShapeToGeometry( - properties[geoFieldName] as string | string[] | ESGeometry | ESGeometry[] | undefined, + properties[geoFieldName] as ESGeometry | ESGeometry[] | undefined, tmpGeometriesAccumulator ); } @@ -131,12 +127,9 @@ export function hitsToGeoJson( }; } -// Parse geo_point docvalue_field -// Either -// 1) Array of latLon strings -// 2) latLon string +// Parse geo_point fields API response export function geoPointToGeometry( - value: string[] | string | undefined, + value: Point[] | Point | undefined, accumulator: Geometry[] ): void { if (!value) { @@ -150,99 +143,12 @@ export function geoPointToGeometry( return; } - const commaSplit = value.split(','); - const lat = parseFloat(commaSplit[0]); - const lon = parseFloat(commaSplit[1]); - accumulator.push({ - type: GEO_JSON_TYPE.POINT, - coordinates: [lon, lat], - } as Point); -} - -export function convertESShapeToGeojsonGeometry(value: ESGeometry): Geometry { - const geoJson = { - type: value.type, - coordinates: value.coordinates, - }; - - // https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html#input-structure - // For some unknown compatibility nightmarish reason, Elasticsearch types are not capitalized the same as geojson types - // For example: 'LineString' geojson type is 'linestring' in elasticsearch - // Convert feature types to geojson spec values - // Sometimes, the type in ES is capitalized correctly. Sometimes it is not. It depends on how the doc was ingested - // The below is the correction in-place. - switch (value.type) { - case 'point': - geoJson.type = GEO_JSON_TYPE.POINT; - break; - case 'linestring': - geoJson.type = GEO_JSON_TYPE.LINE_STRING; - break; - case 'polygon': - geoJson.type = GEO_JSON_TYPE.POLYGON; - break; - case 'multipoint': - geoJson.type = GEO_JSON_TYPE.MULTI_POINT; - break; - case 'multilinestring': - geoJson.type = GEO_JSON_TYPE.MULTI_LINE_STRING; - break; - case 'multipolygon': - geoJson.type = GEO_JSON_TYPE.MULTI_POLYGON; - break; - case 'geometrycollection': - case GEO_JSON_TYPE.GEOMETRY_COLLECTION: - // PEBKAC - geometry-collections need to be unrolled to their individual geometries first. - const invalidGeometrycollectionError = i18n.translate( - 'xpack.maps.es_geo_utils.convert.invalidGeometryCollectionErrorMessage', - { - defaultMessage: `Should not pass GeometryCollection to convertESShapeToGeojsonGeometry`, - } - ); - throw new Error(invalidGeometrycollectionError); - case 'envelope': - const envelopeCoords = geoJson.coordinates as Position[]; - // format defined here https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html#_envelope - const polygon = formatEnvelopeAsPolygon({ - minLon: envelopeCoords[0][0], - maxLon: envelopeCoords[1][0], - minLat: envelopeCoords[1][1], - maxLat: envelopeCoords[0][1], - }); - geoJson.type = polygon.type; - geoJson.coordinates = polygon.coordinates; - break; - case 'circle': - const errorMessage = i18n.translate( - 'xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage', - { - defaultMessage: `Unable to convert {geometryType} geometry to geojson, not supported`, - values: { - geometryType: geoJson.type, - }, - } - ); - throw new Error(errorMessage); - } - return geoJson as unknown as Geometry; -} - -function convertWKTStringToGeojson(value: string): Geometry { - try { - return parse(value); - } catch (e) { - const errorMessage = i18n.translate('xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage', { - defaultMessage: `Unable to convert {wkt} to geojson. Valid WKT expected.`, - values: { - wkt: value, - }, - }); - throw new Error(errorMessage); - } + accumulator.push(value as Point); } +// Parse geo_shape fields API response export function geoShapeToGeometry( - value: string | ESGeometry | string[] | ESGeometry[] | undefined, + value: ESGeometry | ESGeometry[] | undefined, accumulator: Geometry[] ): void { if (!value) { @@ -257,21 +163,38 @@ export function geoShapeToGeometry( return; } - if (typeof value === 'string') { - const geoJson = convertWKTStringToGeojson(value); - accumulator.push(geoJson); - } else if ( - // Needs to deal with possible inconsistencies in capitalization - value.type === GEO_JSON_TYPE.GEOMETRY_COLLECTION || - value.type === 'geometrycollection' - ) { + if (value.type.toLowerCase() === GEO_JSON_TYPE.GEOMETRY_COLLECTION.toLowerCase()) { const geometryCollection = value as unknown as { geometries: ESGeometry[] }; for (let i = 0; i < geometryCollection.geometries.length; i++) { geoShapeToGeometry(geometryCollection.geometries[i], accumulator); } + return; + } + + // fields API does not return true geojson yet, circle and envelope still exist which are not part of geojson spec + if (value.type.toLowerCase() === 'envelope') { + const envelopeCoords = value.coordinates as Position[]; + // format defined here https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html#_envelope + const polygon = formatEnvelopeAsPolygon({ + minLon: envelopeCoords[0][0], + maxLon: envelopeCoords[1][0], + minLat: envelopeCoords[1][1], + maxLat: envelopeCoords[0][1], + }); + accumulator.push(polygon); + } else if (value.type.toLowerCase() === 'circle') { + const errorMessage = i18n.translate( + 'xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage', + { + defaultMessage: `Unable to convert {geometryType} geometry to geojson, not supported`, + values: { + geometryType: value.type, + }, + } + ); + throw new Error(errorMessage); } else { - const geoJson = convertESShapeToGeojsonGeometry(value); - accumulator.push(geoJson); + accumulator.push(value as Geometry); } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 687418edd25b5ba..1b7c9e1cd6aa0cb 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -281,21 +281,26 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource const indexPattern: IndexPattern = await this.getIndexPattern(); + const fieldNames = searchFilters.fieldNames.filter( + (fieldName) => fieldName !== this._descriptor.geoField + ); const { docValueFields, sourceOnlyFields, scriptFields } = getDocValueAndSourceFields( indexPattern, - searchFilters.fieldNames, + fieldNames, 'epoch_millis' ); const topHits: { size: number; script_fields: Record; docvalue_fields: Array; + fields: string[]; _source?: boolean | { includes: string[] }; sort?: Array>; } = { size: topHitsSize, script_fields: scriptFields, docvalue_fields: docValueFields, + fields: [this._descriptor.geoField], }; if (this._hasSort()) { @@ -389,9 +394,12 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource ) { const indexPattern = await this.getIndexPattern(); + const fieldNames = searchFilters.fieldNames.filter( + (fieldName) => fieldName !== this._descriptor.geoField + ); const { docValueFields, sourceOnlyFields } = getDocValueAndSourceFields( indexPattern, - searchFilters.fieldNames, + fieldNames, 'epoch_millis' ); @@ -418,6 +426,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource } else { searchSource.setField('source', sourceOnlyFields); } + searchSource.setField('fields', [this._descriptor.geoField]); if (this._hasSort()) { searchSource.setField('sort', this._buildEsSort()); } @@ -800,9 +809,12 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); + const fieldNames = searchFilters.fieldNames.filter( + (fieldName) => fieldName !== this._descriptor.geoField + ); const { docValueFields, sourceOnlyFields } = getDocValueAndSourceFields( indexPattern, - searchFilters.fieldNames, + fieldNames, 'epoch_millis' ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 42fd880311a5892..bbc5309e476dda2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14910,12 +14910,10 @@ "xpack.maps.embeddableDisplayName": "マップ", "xpack.maps.emsFileSelect.selectPlaceholder": "EMSレイヤーを選択", "xpack.maps.emsSource.tooltipsTitle": "ツールチップフィールド", - "xpack.maps.es_geo_utils.convert.invalidGeometryCollectionErrorMessage": "GeometryCollectionを convertESShapeToGeojsonGeometryに渡さないでください", "xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage": "{geometryType} ジオメトリから Geojson に変換できません。サポートされていません", "xpack.maps.es_geo_utils.distanceFilterAlias": "{pointLabel}の{distanceKm} km以内", "xpack.maps.es_geo_utils.unsupportedFieldTypeErrorMessage": "サポートされていないフィールドタイプ、期待値:{expectedTypes}、提供された値:{fieldType}", "xpack.maps.es_geo_utils.unsupportedGeometryTypeErrorMessage": "サポートされていないジオメトリタイプ、期待値:{expectedTypes}、提供された値:{geometryType}", - "xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage": "{wkt} を Geojson に変換できません。有効な WKT が必要です。", "xpack.maps.esGeoLine.areEntitiesTrimmedMsg": "結果は ~{totalEntities} 中最初の {entityCount} トラックに制限されます。", "xpack.maps.esGeoLine.tracksCountMsg": "{entityCount} 個のトラックが見つかりました。", "xpack.maps.esGeoLine.tracksTrimmedMsg": "{entityCount} 中 {numTrimmedTracks} 個のトラックが不完全です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2fe7805f64259ae..ebfc41d0821b01c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15105,12 +15105,10 @@ "xpack.maps.embeddableDisplayName": "地图", "xpack.maps.emsFileSelect.selectPlaceholder": "选择 EMS 图层", "xpack.maps.emsSource.tooltipsTitle": "工具提示字段", - "xpack.maps.es_geo_utils.convert.invalidGeometryCollectionErrorMessage": "不应将 GeometryCollection 传递给 convertESShapeToGeojsonGeometry", "xpack.maps.es_geo_utils.convert.unsupportedGeometryTypeErrorMessage": "无法将 {geometryType} 几何图形转换成 geojson,不支持", "xpack.maps.es_geo_utils.distanceFilterAlias": "{pointLabel} {distanceKm}km 内", "xpack.maps.es_geo_utils.unsupportedFieldTypeErrorMessage": "字段类型不受支持,应为 {expectedTypes},而提供的是 {fieldType}", "xpack.maps.es_geo_utils.unsupportedGeometryTypeErrorMessage": "几何类型不受支持,应为 {expectedTypes},而提供的是 {geometryType}", - "xpack.maps.es_geo_utils.wkt.invalidWKTErrorMessage": "无法将 {wkt} 转换成 geojson。需要有效的 WKT。", "xpack.maps.esGeoLine.areEntitiesTrimmedMsg": "结果限制为 ~{totalEntities} 条轨迹中的前 {entityCount} 条。", "xpack.maps.esGeoLine.tracksCountMsg": "找到 {entityCount} 条轨迹。", "xpack.maps.esGeoLine.tracksTrimmedMsg": "{entityCount} 条轨迹中有 {numTrimmedTracks} 条不完整。", diff --git a/x-pack/test/functional/apps/maps/mvt_scaling.js b/x-pack/test/functional/apps/maps/mvt_scaling.js index 3e0e8924a6417ee..2be9606d11dea51 100644 --- a/x-pack/test/functional/apps/maps/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/mvt_scaling.js @@ -52,7 +52,7 @@ export default function ({ getPageObjects, getService }) { geometryFieldName: 'geometry', index: 'geo_shapes*', requestBody: - '(_source:!(geometry),docvalue_fields:!(prop1),query:(bool:(filter:!(),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10001,stored_fields:!(geometry,prop1))', + '(_source:!f,docvalue_fields:!(prop1),query:(bool:(filter:!(),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10001,stored_fields:!(geometry,prop1))', }); }); diff --git a/yarn.lock b/yarn.lock index b73679a1945c3df..369c5edf179d2f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10311,15 +10311,6 @@ concat-stream@1.6.2, concat-stream@^1.4.7, concat-stream@^1.5.0, concat-stream@^ readable-stream "^2.2.2" typedarray "^0.0.6" -concat-stream@~1.5.0: - version "1.5.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" - integrity sha1-cIl4Yk2FavQaWnQd790mHadSwmY= - dependencies: - inherits "~2.0.1" - readable-stream "~2.0.0" - typedarray "~0.0.5" - concat-stream@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" @@ -22651,11 +22642,6 @@ process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - integrity sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M= - process-on-spawn@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.0.0.tgz#95b05a23073d30a17acfdc92a440efd2baefdc93" @@ -24032,18 +24018,6 @@ readable-stream@~1.1.9: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@~2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" - integrity sha1-j5A0HmilPMySh4jaz80Rs265t44= - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - readdir-glob@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4" @@ -27877,7 +27851,7 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typedarray@^0.0.6, typedarray@~0.0.5: +typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= @@ -29518,14 +29492,6 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== -wellknown@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/wellknown/-/wellknown-0.5.0.tgz#09ae9871fa826cf0a6ec1537ef00c379d78d7101" - integrity sha1-Ca6YcfqCbPCm7BU37wDDedeNcQE= - dependencies: - concat-stream "~1.5.0" - minimist "~1.2.0" - wgs84@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/wgs84/-/wgs84-0.0.0.tgz#34fdc555917b6e57cf2a282ed043710c049cdc76" From 2050262b51001442f83a1bffdc57c6e1e856d133 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 19 Jan 2022 14:04:37 -0500 Subject: [PATCH 06/12] remove reference to deprecated kibana.index setting (#123379) --- docs/developer/advanced/running-elasticsearch.asciidoc | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index 36f9ee420d41dbf..c04c53f66cf191b 100644 --- a/docs/developer/advanced/running-elasticsearch.asciidoc +++ b/docs/developer/advanced/running-elasticsearch.asciidoc @@ -71,13 +71,6 @@ elasticsearch.password: {{ password }} elasticsearch.ssl.verificationMode: none ---- -If many other users will be interacting with your remote cluster, you'll want to add the following to avoid causing conflicts: - -[source,bash] ----- -kibana.index: '.{YourGitHubHandle}-kibana' ----- - ==== Running remote clusters Setup remote clusters for cross cluster search (CCS) and cross cluster replication (CCR). From 70c2b8b98e8fe453ebb8c8a24893b7480f3789a8 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Wed, 19 Jan 2022 15:40:07 -0500 Subject: [PATCH 07/12] Move bazel remote cache token to a space that more employees have access to (#123402) --- packages/kbn-pm/dist/index.js | 2 +- packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index bf51f1a97e8dd2a..3896c67454b9028 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -59012,7 +59012,7 @@ async function setupRemoteCache(repoRootPath) { try { const { stdout - } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_3__["spawn"])('vault', ['read', '-field=readonly-key', 'secret/kibana-issues/dev/bazel-remote-cache'], { + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_3__["spawn"])('vault', ['read', '-field=readonly-key', 'secret/ui-team/kibana-bazel-remote-cache'], { stdio: 'pipe' }); apiKey = stdout.trim(); diff --git a/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts b/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts index fb510cfa81ffd03..0e740e674b7d81e 100644 --- a/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts +++ b/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts @@ -60,7 +60,7 @@ export async function setupRemoteCache(repoRootPath: string) { try { const { stdout } = await spawn( 'vault', - ['read', '-field=readonly-key', 'secret/kibana-issues/dev/bazel-remote-cache'], + ['read', '-field=readonly-key', 'secret/ui-team/kibana-bazel-remote-cache'], { stdio: 'pipe', } From 158a9a53a33957c9bf31fe832a9ce4c2daee08ae Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Wed, 19 Jan 2022 14:09:31 -0800 Subject: [PATCH 08/12] [Actions] Fixed ad-hoc actions tasks remain as "running" when they timeout by adding cancellation support (#120853) * [Actions] Fixed ad-hoc actions tasks remain as "running" when they timeout by adding cancellation support * fixed test * fixed tests * fixed test * removed test data * fixed typechecks * fixed typechecks * fixed typechecks * fixed tests * fixed typechecks * fixed tests * fixed typechecks * fixed test * fixed tests * fixed tests * changed unit tests * fixed tests * fixed jest tests * fixed typechecks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../actions/server/constants/event_log.ts | 1 + .../server/lib/action_executor.mock.ts | 1 + .../server/lib/action_executor.test.ts | 30 ++++ .../actions/server/lib/action_executor.ts | 150 ++++++++++++++---- ...ate_action_event_log_record_object.test.ts | 128 +++++++++++++++ .../create_action_event_log_record_object.ts | 53 +++++++ .../server/lib/task_runner_factory.test.ts | 34 +++- .../actions/server/lib/task_runner_factory.ts | 84 +++++++--- .../task_running/ephemeral_task_runner.ts | 1 + .../server/task_running/task_runner.ts | 1 + 10 files changed, 423 insertions(+), 60 deletions(-) create mode 100644 x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts create mode 100644 x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts diff --git a/x-pack/plugins/actions/server/constants/event_log.ts b/x-pack/plugins/actions/server/constants/event_log.ts index 9163a0d105ce8a3..9dba72462f317f2 100644 --- a/x-pack/plugins/actions/server/constants/event_log.ts +++ b/x-pack/plugins/actions/server/constants/event_log.ts @@ -10,4 +10,5 @@ export const EVENT_LOG_ACTIONS = { execute: 'execute', executeStart: 'execute-start', executeViaHttp: 'execute-via-http', + executeTimeout: 'execute-timeout', }; diff --git a/x-pack/plugins/actions/server/lib/action_executor.mock.ts b/x-pack/plugins/actions/server/lib/action_executor.mock.ts index 6abfec1116c3ad7..54df74b2fbd3d07 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.mock.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.mock.ts @@ -11,6 +11,7 @@ const createActionExecutorMock = () => { const mocked: jest.Mocked = { initialize: jest.fn(), execute: jest.fn().mockResolvedValue({ status: 'ok', actionId: '' }), + logCancellation: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 30d4ed92e03f8e0..1d678c244c1b09d 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -115,6 +115,7 @@ test('successfully executes', async () => { Object { "event": Object { "action": "execute-start", + "kind": "action", }, "kibana": Object { "saved_objects": Array [ @@ -134,6 +135,7 @@ test('successfully executes', async () => { Object { "event": Object { "action": "execute", + "kind": "action", "outcome": "success", }, "kibana": Object { @@ -511,6 +513,34 @@ test('logs a warning when alert executor returns invalid status', async () => { ); }); +test('writes to event log for execute timeout', async () => { + setupActionExecutorMock(); + + await actionExecutor.logCancellation({ + actionId: 'action1', + relatedSavedObjects: [], + request: {} as KibanaRequest, + }); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls[0][0]).toMatchObject({ + event: { + action: 'execute-timeout', + }, + kibana: { + saved_objects: [ + { + rel: 'primary', + type: 'action', + id: 'action1', + type_id: 'test', + namespace: 'some-namespace', + }, + ], + }, + message: `action: test:action1: 'action-1' execution cancelled due to timeout - exceeded default timeout of "5m"`, + }); +}); + test('writes to event log for execute and execute start', async () => { const executorMock = setupActionExecutorMock(); executorMock.mockResolvedValue({ diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 9458180fdd2200c..9737630628823fa 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -19,16 +19,17 @@ import { ActionTypeExecutorResult, ActionTypeRegistryContract, GetServicesFunction, - RawAction, PreConfiguredAction, + RawAction, } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { SpacesServiceStart } from '../../../spaces/server'; import { EVENT_LOG_ACTIONS } from '../constants/event_log'; -import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; +import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { ActionsClient } from '../actions_client'; import { ActionExecutionSource } from './action_execution_source'; import { RelatedSavedObjects } from './related_saved_objects'; +import { createActionEventLogRecordObject } from './create_action_event_log_record_object'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; @@ -68,6 +69,7 @@ export class ActionExecutor { private isInitialized = false; private actionExecutorContext?: ActionExecutorContext; private readonly isESOCanEncrypt: boolean; + private actionInfo: ActionInfo | undefined; constructor({ isESOCanEncrypt }: { isESOCanEncrypt: boolean }) { this.isESOCanEncrypt = isESOCanEncrypt; @@ -124,7 +126,7 @@ export class ActionExecutor { const spaceId = spaces && spaces.getSpaceId(request); const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; - const { actionTypeId, name, config, secrets } = await getActionInfo( + const actionInfo = await getActionInfoInternal( await getActionsClientWithRequest(request, source), encryptedSavedObjectsClient, preconfiguredActions, @@ -132,6 +134,12 @@ export class ActionExecutor { namespace.namespace ); + const { actionTypeId, name, config, secrets } = actionInfo; + + if (!this.actionInfo || this.actionInfo.actionId !== actionId) { + this.actionInfo = actionInfo; + } + if (span) { span.name = `execute_action ${actionTypeId}`; span.addLabels({ @@ -169,26 +177,25 @@ export class ActionExecutor { ? { task: { scheduled: taskInfo.scheduled.toISOString(), - schedule_delay: Millis2Nanos * (Date.now() - taskInfo.scheduled.getTime()), + scheduleDelay: Millis2Nanos * (Date.now() - taskInfo.scheduled.getTime()), }, } : {}; - const event: IEvent = { - event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { - ...task, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'action', - id: actionId, - type_id: actionTypeId, - ...namespace, - }, - ], - }, - }; + const event = createActionEventLogRecordObject({ + actionId, + action: EVENT_LOG_ACTIONS.execute, + ...namespace, + ...task, + savedObjects: [ + { + type: 'action', + id: actionId, + typeId: actionTypeId, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); for (const relatedSavedObject of relatedSavedObjects || []) { event.kibana?.saved_objects?.push({ @@ -210,6 +217,7 @@ export class ActionExecutor { }, message: `action started: ${actionLabel}`, }); + eventLogger.logEvent(startEvent); let rawResult: ActionTypeExecutorResult; @@ -269,22 +277,77 @@ export class ActionExecutor { } ); } -} -function actionErrorToMessage(result: ActionTypeExecutorResult): string { - let message = result.message || 'unknown error running action'; - - if (result.serviceMessage) { - message = `${message}: ${result.serviceMessage}`; - } - - if (result.retry instanceof Date) { - message = `${message}; retry at ${result.retry.toISOString()}`; - } else if (result.retry) { - message = `${message}; retry: ${JSON.stringify(result.retry)}`; + public async logCancellation({ + actionId, + request, + relatedSavedObjects, + source, + taskInfo, + }: { + actionId: string; + request: KibanaRequest; + taskInfo?: TaskInfo; + relatedSavedObjects: RelatedSavedObjects; + source?: ActionExecutionSource; + }) { + const { + spaces, + encryptedSavedObjectsClient, + preconfiguredActions, + eventLogger, + getActionsClientWithRequest, + } = this.actionExecutorContext!; + + const spaceId = spaces && spaces.getSpaceId(request); + const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; + if (!this.actionInfo || this.actionInfo.actionId !== actionId) { + this.actionInfo = await getActionInfoInternal( + await getActionsClientWithRequest(request, source), + encryptedSavedObjectsClient, + preconfiguredActions, + actionId, + namespace.namespace + ); + } + const task = taskInfo + ? { + task: { + scheduled: taskInfo.scheduled.toISOString(), + scheduleDelay: Millis2Nanos * (Date.now() - taskInfo.scheduled.getTime()), + }, + } + : {}; + // Write event log entry + const event = createActionEventLogRecordObject({ + actionId, + action: EVENT_LOG_ACTIONS.executeTimeout, + message: `action: ${this.actionInfo.actionTypeId}:${actionId}: '${ + this.actionInfo.name ?? '' + }' execution cancelled due to timeout - exceeded default timeout of "5m"`, + ...namespace, + ...task, + savedObjects: [ + { + type: 'action', + id: actionId, + typeId: this.actionInfo.actionTypeId, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); + + for (const relatedSavedObject of (relatedSavedObjects || []) as RelatedSavedObjects) { + event.kibana?.saved_objects?.push({ + rel: SAVED_OBJECT_REL_PRIMARY, + type: relatedSavedObject.type, + id: relatedSavedObject.id, + type_id: relatedSavedObject.typeId, + namespace: relatedSavedObject.namespace, + }); + } + eventLogger.logEvent(event); } - - return message; } interface ActionInfo { @@ -292,9 +355,10 @@ interface ActionInfo { name: string; config: unknown; secrets: unknown; + actionId: string; } -async function getActionInfo( +async function getActionInfoInternal( actionsClient: PublicMethodsOf, encryptedSavedObjectsClient: EncryptedSavedObjectsClient, preconfiguredActions: PreConfiguredAction[], @@ -311,6 +375,7 @@ async function getActionInfo( name: pcAction.name, config: pcAction.config, secrets: pcAction.secrets, + actionId, }; } @@ -329,5 +394,22 @@ async function getActionInfo( name, config, secrets, + actionId, }; } + +function actionErrorToMessage(result: ActionTypeExecutorResult): string { + let message = result.message || 'unknown error running action'; + + if (result.serviceMessage) { + message = `${message}: ${result.serviceMessage}`; + } + + if (result.retry instanceof Date) { + message = `${message}; retry at ${result.retry.toISOString()}`; + } else if (result.retry) { + message = `${message}; retry: ${JSON.stringify(result.retry)}`; + } + + return message; +} diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts new file mode 100644 index 000000000000000..ee58f8a01488cee --- /dev/null +++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.test.ts @@ -0,0 +1,128 @@ +/* + * 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 { createActionEventLogRecordObject } from './create_action_event_log_record_object'; + +describe('createActionEventLogRecordObject', () => { + test('created action event "execute-start"', async () => { + expect( + createActionEventLogRecordObject({ + actionId: '1', + action: 'execute-start', + timestamp: '1970-01-01T00:00:00.000Z', + task: { + scheduled: '1970-01-01T00:00:00.000Z', + scheduleDelay: 0, + }, + savedObjects: [ + { + id: '1', + type: 'action', + typeId: 'test', + relation: 'primary', + }, + ], + }) + ).toStrictEqual({ + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { + action: 'execute-start', + kind: 'action', + }, + kibana: { + saved_objects: [ + { + id: '1', + rel: 'primary', + type: 'action', + type_id: 'test', + }, + ], + task: { + schedule_delay: 0, + scheduled: '1970-01-01T00:00:00.000Z', + }, + }, + }); + }); + + test('created action event "execute"', async () => { + expect( + createActionEventLogRecordObject({ + actionId: '1', + name: 'test name', + action: 'execute', + message: 'action execution start', + namespace: 'default', + savedObjects: [ + { + id: '2', + type: 'action', + typeId: '.email', + relation: 'primary', + }, + ], + }) + ).toStrictEqual({ + event: { + action: 'execute', + kind: 'action', + }, + kibana: { + saved_objects: [ + { + id: '2', + namespace: 'default', + rel: 'primary', + type: 'action', + type_id: '.email', + }, + ], + }, + message: 'action execution start', + }); + }); + + test('created action event "execute-timeout"', async () => { + expect( + createActionEventLogRecordObject({ + actionId: '1', + action: 'execute-timeout', + task: { + scheduled: '1970-01-01T00:00:00.000Z', + }, + savedObjects: [ + { + id: '1', + type: 'action', + typeId: 'test', + relation: 'primary', + }, + ], + }) + ).toStrictEqual({ + event: { + action: 'execute-timeout', + kind: 'action', + }, + kibana: { + saved_objects: [ + { + id: '1', + rel: 'primary', + type: 'action', + type_id: 'test', + }, + ], + task: { + schedule_delay: undefined, + scheduled: '1970-01-01T00:00:00.000Z', + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts new file mode 100644 index 000000000000000..1a1c5e9e6b3aaa5 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/create_action_event_log_record_object.ts @@ -0,0 +1,53 @@ +/* + * 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 { IEvent } from '../../../event_log/server'; + +export type Event = Exclude; + +interface CreateActionEventLogRecordParams { + actionId: string; + action: string; + name?: string; + message?: string; + namespace?: string; + timestamp?: string; + task?: { + scheduled?: string; + scheduleDelay?: number; + }; + savedObjects: Array<{ + type: string; + id: string; + typeId: string; + relation?: string; + }>; +} + +export function createActionEventLogRecordObject(params: CreateActionEventLogRecordParams): Event { + const { action, message, task, namespace } = params; + + const event: Event = { + ...(params.timestamp ? { '@timestamp': params.timestamp } : {}), + event: { + action, + kind: 'action', + }, + kibana: { + saved_objects: params.savedObjects.map((so) => ({ + ...(so.relation ? { rel: so.relation } : {}), + type: so.type, + id: so.id, + type_id: so.typeId, + ...(namespace ? { namespace } : {}), + })), + ...(task ? { task: { scheduled: task.scheduled, schedule_delay: task.scheduleDelay } } : {}), + }, + ...(message ? { message } : {}), + }; + return event; +} diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index ec0aa48ef291e4b..0ea6b5316fb82a4 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -22,6 +22,7 @@ const spaceIdToNamespace = jest.fn(); const actionTypeRegistry = actionTypeRegistryMock.create(); const mockedEncryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const mockedActionExecutor = actionExecutorMock.create(); +const eventLogger = eventLoggerMock.create(); let fakeTimer: sinon.SinonFakeTimers; let taskRunnerFactory: TaskRunnerFactory; @@ -62,7 +63,7 @@ const actionExecutorInitializerParams = { actionTypeRegistry, getActionsClientWithRequest: jest.fn(async () => actionsClientMock.create()), encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient, - eventLogger: eventLoggerMock.create(), + eventLogger, preconfiguredActions: [], }; const taskRunnerFactoryInitializerParams = { @@ -236,6 +237,37 @@ test('cleans up action_task_params object', async () => { expect(services.savedObjectsClient.delete).toHaveBeenCalledWith('action_task_params', '3'); }); +test('task runner should implement CancellableTask cancel method with logging warning message', async () => { + mockedEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '3', + type: 'action_task_params', + attributes: { + actionId: '2', + params: { baz: true }, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [ + { + id: '2', + name: 'actionRef', + type: 'action', + }, + ], + }); + const taskRunner = taskRunnerFactory.create({ + taskInstance: mockedTaskInstance, + }); + + await taskRunner.cancel(); + expect(mockedActionExecutor.logCancellation.mock.calls[0][0].actionId).toBe('2'); + + expect(mockedActionExecutor.logCancellation.mock.calls.length).toBe(1); + + expect(taskRunnerFactoryInitializerParams.logger.debug).toHaveBeenCalledWith( + `Cancelling action task for action with id 2 - execution error due to timeout.` + ); +}); + test('runs successfully when cleanup fails and logs the error', async () => { const taskRunner = taskRunnerFactory.create({ taskInstance: mockedTaskInstance, diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index 4f6b9ac2e8b7d7b..f3fdf627e08fff0 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -93,31 +93,10 @@ export class TaskRunnerFactory { encryptedSavedObjectsClient, spaceIdToNamespace ); - - const requestHeaders: Record = {}; - if (apiKey) { - requestHeaders.authorization = `ApiKey ${apiKey}`; - } - const path = addSpaceIdToPath('/', spaceId); - // Since we're using API keys and accessing elasticsearch can only be done - // via a request, we're faking one with the proper authorization headers. - const fakeRequest = KibanaRequest.from({ - headers: requestHeaders, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - } as unknown as Request); - - basePathService.set(fakeRequest, path); + const request = getFakeRequest(apiKey); + basePathService.set(request, path); // Throwing an executor error means we will attempt to retry the task // TM will treat a task as a failure if `attempts >= maxAttempts` @@ -132,7 +111,7 @@ export class TaskRunnerFactory { params, actionId: actionId as string, isEphemeral: !isPersistedActionTask(actionTaskExecutorParams), - request: fakeRequest, + request, ...getSourceFromReferences(references), taskInfo, relatedSavedObjects: validatedRelatedSavedObjects(logger, relatedSavedObjects), @@ -181,7 +160,7 @@ export class TaskRunnerFactory { // We would idealy secure every operation but in order to support clean up of legacy alerts // we allow this operation in an unsecured manner // Once support for legacy alert RBAC is dropped, this can be secured - await getUnsecuredSavedObjectsClient(fakeRequest).delete( + await getUnsecuredSavedObjectsClient(request).delete( ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, actionTaskExecutorParams.actionTaskParamsId ); @@ -193,10 +172,65 @@ export class TaskRunnerFactory { } } }, + cancel: async () => { + // Write event log entry + const actionTaskExecutorParams = taskInstance.params as ActionTaskExecutorParams; + const { spaceId } = actionTaskExecutorParams; + + const { + attributes: { actionId, apiKey, relatedSavedObjects }, + references, + } = await getActionTaskParams( + actionTaskExecutorParams, + encryptedSavedObjectsClient, + spaceIdToNamespace + ); + + const request = getFakeRequest(apiKey); + const path = addSpaceIdToPath('/', spaceId); + basePathService.set(request, path); + + await actionExecutor.logCancellation({ + actionId, + request, + relatedSavedObjects: (relatedSavedObjects || []) as RelatedSavedObjects, + ...getSourceFromReferences(references), + }); + + logger.debug( + `Cancelling action task for action with id ${actionId} - execution error due to timeout.` + ); + return { state: {} }; + }, }; } } +function getFakeRequest(apiKey?: string) { + const requestHeaders: Record = {}; + if (apiKey) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + // Since we're using API keys and accessing elasticsearch can only be done + // via a request, we're faking one with the proper authorization headers. + const fakeRequest = KibanaRequest.from({ + headers: requestHeaders, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + } as unknown as Request); + + return fakeRequest; +} + async function getActionTaskParams( executorParams: ActionTaskExecutorParams, encryptedSavedObjectsClient: EncryptedSavedObjectsClient, diff --git a/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts b/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts index 0085329cd66e613..a9d1d8b6f2f1689 100644 --- a/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts @@ -304,6 +304,7 @@ export class EphemeralTaskManagerRunner implements TaskRunner { public async cancel() { const { task } = this; if (task?.cancel) { + // it will cause the task state of "running" to be cleared this.task = undefined; return task.cancel(); } diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index e21e418c9046153..48927435c4bdf91 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -431,6 +431,7 @@ export class TaskManagerRunner implements TaskRunner { public async cancel() { const { task } = this; if (task?.cancel) { + // it will cause the task state of "running" to be cleared this.task = undefined; return task.cancel(); } From 12e63dd46912c63ca027c3d0d6c778bf4c4750c5 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 19 Jan 2022 15:46:32 -0700 Subject: [PATCH 09/12] [ftr] support filtering tests by es version (#123289) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/serializers/recursive_serializer.ts | 19 +++++- packages/kbn-es-archiver/src/cli.ts | 4 +- packages/kbn-test/BUILD.bazel | 2 + .../src/functional_test_runner/cli.ts | 10 ++- .../functional_test_runner.ts | 47 +++++++++++-- .../src/functional_test_runner/index.ts | 2 +- .../lib/config/read_config_file.test.js | 23 ++++--- .../lib/config/read_config_file.ts | 20 ++++-- .../functional_test_runner/lib/es_version.ts | 55 +++++++++++++++ .../src/functional_test_runner/lib/index.ts | 1 + .../lib/mocha/decorate_mocha_ui.js | 3 + ..._by_tags.test.js => filter_suites.test.js} | 33 ++++++++- ...ter_suites_by_tags.js => filter_suites.ts} | 68 ++++++++++++++++--- .../lib/mocha/setup_mocha.js | 17 +++-- .../functional_test_runner/public_types.ts | 10 +-- .../run_tests/__snapshots__/args.test.js.snap | 13 ++++ .../functional_tests/cli/run_tests/args.js | 2 + .../cli/run_tests/args.test.js | 13 +++- .../src/functional_tests/lib/run_ftr.ts | 64 ++++++++++------- .../kbn-test/src/functional_tests/tasks.ts | 8 ++- packages/kbn-test/src/kbn_archiver_cli.ts | 4 +- .../kbn-test/types/ftr_globals/mocha.d.ts | 6 ++ 22 files changed, 349 insertions(+), 75 deletions(-) create mode 100644 packages/kbn-test/src/functional_test_runner/lib/es_version.ts rename packages/kbn-test/src/functional_test_runner/lib/mocha/{filter_suites_by_tags.test.js => filter_suites.test.js} (86%) rename packages/kbn-test/src/functional_test_runner/lib/mocha/{filter_suites_by_tags.js => filter_suites.ts} (55%) diff --git a/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts b/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts index 6e6572addbc833f..15d3f033a85a120 100644 --- a/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts +++ b/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts @@ -6,11 +6,26 @@ * Side Public License, v 1. */ -export function createRecursiveSerializer(test: (v: any) => boolean, print: (v: any) => string) { +class RawPrint { + static fromString(s: string) { + return new RawPrint(s); + } + constructor(public readonly v: string) {} +} + +export function createRecursiveSerializer( + test: (v: any) => boolean, + print: (v: any, printRaw: (v: string) => RawPrint) => string | RawPrint +) { return { test: (v: any) => test(v), serialize: (v: any, ...rest: any[]) => { - const replacement = print(v); + const replacement = print(v, RawPrint.fromString); + + if (replacement instanceof RawPrint) { + return replacement.v; + } + const printer = rest.pop()!; return printer(replacement, ...rest); }, diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index e54b4d5fbdb52c1..fbb5784afe5ac91 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -18,7 +18,7 @@ import readline from 'readline'; import Fs from 'fs'; import { RunWithCommands, createFlagError, CA_CERT_PATH } from '@kbn/dev-utils'; -import { readConfigFile, KbnClient } from '@kbn/test'; +import { readConfigFile, KbnClient, EsVersion } from '@kbn/test'; import { Client, HttpConnection } from '@elastic/elasticsearch'; import { EsArchiver } from './es_archiver'; @@ -45,7 +45,7 @@ export function runCli() { if (typeof configPath !== 'string') { throw createFlagError('--config must be a string'); } - const config = await readConfigFile(log, Path.resolve(configPath)); + const config = await readConfigFile(log, EsVersion.getDefault(), Path.resolve(configPath)); statsMeta.set('ftrConfigPath', configPath); let esUrl = flags['es-url']; diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index 1ff9677615f5ab8..69addd9e3c4c7c8 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -69,6 +69,7 @@ RUNTIME_DEPS = [ "@npm//react-router-dom", "@npm//redux", "@npm//rxjs", + "@npm//semver", "@npm//strip-ansi", "@npm//xmlbuilder", "@npm//xml2js", @@ -108,6 +109,7 @@ TYPES_DEPS = [ "@npm//@types/react-dom", "@npm//@types/react-redux", "@npm//@types/react-router-dom", + "@npm//@types/semver", "@npm//@types/xml2js", ] diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index d9938bebea5bb0c..e013085e1b39a3c 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -35,6 +35,11 @@ export function runFtrCli() { const reportTime = getTimeReporter(toolingLog, 'scripts/functional_test_runner'); run( async ({ flags, log }) => { + const esVersion = flags['es-version'] || undefined; // convert "" to undefined + if (esVersion !== undefined && typeof esVersion !== 'string') { + throw createFlagError('expected --es-version to be a string'); + } + const functionalTestRunner = new FunctionalTestRunner( log, makeAbsolutePath(flags.config as string), @@ -57,7 +62,8 @@ export function runFtrCli() { }, updateBaselines: flags.updateBaselines || flags.u, updateSnapshots: flags.updateSnapshots || flags.u, - } + }, + esVersion ); if (flags.throttle) { @@ -131,6 +137,7 @@ export function runFtrCli() { 'include-tag', 'exclude-tag', 'kibana-install-dir', + 'es-version', ], boolean: [ 'bail', @@ -150,6 +157,7 @@ export function runFtrCli() { --bail stop tests after the first failure --grep pattern used to select which tests to run --invert invert grep to exclude tests + --es-version the elasticsearch version, formatted as "x.y.z" --include=file a test file to be included, pass multiple times for multiple files --exclude=file a test file to be excluded, pass multiple times for multiple files --include-tag=tag a tag to be included, pass multiple times for multiple tags. Only diff --git a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts index 4130cd8d138b878..ea55a2672d670f6 100644 --- a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts +++ b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { Client as EsClient } from '@elastic/elasticsearch'; import { ToolingLog } from '@kbn/dev-utils'; import { Suite, Test } from './fake_mocha_types'; @@ -21,6 +22,7 @@ import { DockerServersService, Config, SuiteTracker, + EsVersion, } from './lib'; export class FunctionalTestRunner { @@ -28,10 +30,12 @@ export class FunctionalTestRunner { public readonly failureMetadata = new FailureMetadata(this.lifecycle); private closed = false; + private readonly esVersion: EsVersion; constructor( private readonly log: ToolingLog, private readonly configFile: string, - private readonly configOverrides: any + private readonly configOverrides: any, + esVersion?: string | EsVersion ) { for (const [key, value] of Object.entries(this.lifecycle)) { if (value instanceof LifecyclePhase) { @@ -39,6 +43,12 @@ export class FunctionalTestRunner { value.after$.subscribe(() => log.verbose('starting %j lifecycle phase', key)); } } + this.esVersion = + esVersion === undefined + ? EsVersion.getDefault() + : esVersion instanceof EsVersion + ? esVersion + : new EsVersion(esVersion); } async run() { @@ -51,6 +61,27 @@ export class FunctionalTestRunner { ...readProviderSpec('PageObject', config.get('pageObjects')), ]); + // validate es version + if (providers.hasService('es')) { + const es = (await providers.getService('es')) as unknown as EsClient; + let esInfo; + try { + esInfo = await es.info(); + } catch (error) { + throw new Error( + `attempted to use the "es" service to fetch Elasticsearch version info but the request failed: ${error.stack}` + ); + } + + if (!this.esVersion.eql(esInfo.version.number)) { + throw new Error( + `ES reports a version number "${ + esInfo.version.number + }" which doesn't match supplied es version "${this.esVersion.toString()}"` + ); + } + } + await providers.loadAll(); const customTestRunner = config.get('testRunner'); @@ -61,7 +92,7 @@ export class FunctionalTestRunner { return (await providers.invokeProviderFn(customTestRunner)) || 0; } - const mocha = await setupMocha(this.lifecycle, this.log, config, providers); + const mocha = await setupMocha(this.lifecycle, this.log, config, providers, this.esVersion); await this.lifecycle.beforeTests.trigger(mocha.suite); this.log.info('Starting tests'); @@ -107,14 +138,14 @@ export class FunctionalTestRunner { ...readStubbedProviderSpec('PageObject', config.get('pageObjects'), []), ]); - const mocha = await setupMocha(this.lifecycle, this.log, config, providers); + const mocha = await setupMocha(this.lifecycle, this.log, config, providers, this.esVersion); const countTests = (suite: Suite): number => suite.suites.reduce((sum, s) => sum + countTests(s), suite.tests.length); return { testCount: countTests(mocha.suite), - excludedTests: mocha.excludedTests.map((t: Test) => t.fullTitle()), + testsExcludedByTag: mocha.testsExcludedByTag.map((t: Test) => t.fullTitle()), }; }); } @@ -125,7 +156,12 @@ export class FunctionalTestRunner { let runErrorOccurred = false; try { - const config = await readConfigFile(this.log, this.configFile, this.configOverrides); + const config = await readConfigFile( + this.log, + this.esVersion, + this.configFile, + this.configOverrides + ); this.log.info('Config loaded'); if ( @@ -148,6 +184,7 @@ export class FunctionalTestRunner { failureMetadata: () => this.failureMetadata, config: () => config, dockerServers: () => dockerServers, + esVersion: () => this.esVersion, }); return await handler(config, coreProviders); diff --git a/packages/kbn-test/src/functional_test_runner/index.ts b/packages/kbn-test/src/functional_test_runner/index.ts index 268c6b2bd9a670f..1718b5f7a4bc57b 100644 --- a/packages/kbn-test/src/functional_test_runner/index.ts +++ b/packages/kbn-test/src/functional_test_runner/index.ts @@ -7,7 +7,7 @@ */ export { FunctionalTestRunner } from './functional_test_runner'; -export { readConfigFile, Config } from './lib'; +export { readConfigFile, Config, EsVersion } from './lib'; export { runFtrCli } from './cli'; export * from './lib/docker_servers'; export * from './public_types'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js index 60c307b58aee692..27434ce5a09ca0b 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js @@ -9,34 +9,41 @@ import { ToolingLog } from '@kbn/dev-utils'; import { readConfigFile } from './read_config_file'; import { Config } from './config'; +import { EsVersion } from '../es_version'; const log = new ToolingLog(); +const esVersion = new EsVersion('8.0.0'); describe('readConfigFile()', () => { it('reads config from a file, returns an instance of Config class', async () => { - const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1')); + const config = await readConfigFile(log, esVersion, require.resolve('./__fixtures__/config.1')); expect(config instanceof Config).toBeTruthy(); expect(config.get('testFiles')).toEqual(['config.1']); }); it('merges setting overrides into log', async () => { - const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1'), { - screenshots: { - directory: 'foo.bar', - }, - }); + const config = await readConfigFile( + log, + esVersion, + require.resolve('./__fixtures__/config.1'), + { + screenshots: { + directory: 'foo.bar', + }, + } + ); expect(config.get('screenshots.directory')).toBe('foo.bar'); }); it('supports loading config files from within config files', async () => { - const config = await readConfigFile(log, require.resolve('./__fixtures__/config.2')); + const config = await readConfigFile(log, esVersion, require.resolve('./__fixtures__/config.2')); expect(config.get('testFiles')).toEqual(['config.1', 'config.2']); }); it('throws if settings are invalid', async () => { try { - await readConfigFile(log, require.resolve('./__fixtures__/config.invalid')); + await readConfigFile(log, esVersion, require.resolve('./__fixtures__/config.invalid')); throw new Error('expected readConfigFile() to fail'); } catch (err) { expect(err.message).toMatch(/"foo"/); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts index 374edea7a8db776..fd836f338edf02f 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts @@ -10,10 +10,16 @@ import { ToolingLog } from '@kbn/dev-utils'; import { defaultsDeep } from 'lodash'; import { Config } from './config'; +import { EsVersion } from '../es_version'; const cache = new WeakMap(); -async function getSettingsFromFile(log: ToolingLog, path: string, settingOverrides: any) { +async function getSettingsFromFile( + log: ToolingLog, + esVersion: EsVersion, + path: string, + settingOverrides: any +) { const configModule = require(path); // eslint-disable-line @typescript-eslint/no-var-requires const configProvider = configModule.__esModule ? configModule.default : configModule; @@ -23,9 +29,10 @@ async function getSettingsFromFile(log: ToolingLog, path: string, settingOverrid configProvider, configProvider({ log, + esVersion, async readConfigFile(p: string, o: any) { return new Config({ - settings: await getSettingsFromFile(log, p, o), + settings: await getSettingsFromFile(log, esVersion, p, o), primary: false, path: p, }); @@ -43,9 +50,14 @@ async function getSettingsFromFile(log: ToolingLog, path: string, settingOverrid return settingsWithDefaults; } -export async function readConfigFile(log: ToolingLog, path: string, settingOverrides: any = {}) { +export async function readConfigFile( + log: ToolingLog, + esVersion: EsVersion, + path: string, + settingOverrides: any = {} +) { return new Config({ - settings: await getSettingsFromFile(log, path, settingOverrides), + settings: await getSettingsFromFile(log, esVersion, path, settingOverrides), primary: true, path, }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/es_version.ts b/packages/kbn-test/src/functional_test_runner/lib/es_version.ts new file mode 100644 index 000000000000000..8b3acde47a4dc8d --- /dev/null +++ b/packages/kbn-test/src/functional_test_runner/lib/es_version.ts @@ -0,0 +1,55 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import semver from 'semver'; +import { kibanaPackageJson } from '@kbn/utils'; + +export class EsVersion { + static getDefault() { + // example: https://storage.googleapis.com/kibana-ci-es-snapshots-daily/8.0.0/manifest-latest-verified.json + const manifestUrl = process.env.ES_SNAPSHOT_MANIFEST; + if (manifestUrl) { + const match = manifestUrl.match(/\d+\.\d+\.\d+/); + if (!match) { + throw new Error('unable to extract es version from ES_SNAPSHOT_MANIFEST_URL'); + } + return new EsVersion(match[0]); + } + + return new EsVersion(process.env.TEST_ES_BRANCH || kibanaPackageJson.version); + } + + public readonly parsed: semver.SemVer; + + constructor(version: string) { + const parsed = semver.coerce(version); + if (!parsed) { + throw new Error(`unable to parse es version [${version}]`); + } + this.parsed = parsed; + } + + toString() { + return this.parsed.version; + } + + /** + * Determine if the ES version matches a semver range, like >=7 or ^8.1.0 + */ + matchRange(range: string) { + return semver.satisfies(this.parsed, range); + } + + /** + * Determine if the ES version matches a specific version, ignores things like -SNAPSHOT + */ + eql(version: string) { + const other = semver.coerce(version); + return other && semver.compareLoose(this.parsed, other) === 0; + } +} diff --git a/packages/kbn-test/src/functional_test_runner/lib/index.ts b/packages/kbn-test/src/functional_test_runner/lib/index.ts index 1cb1e58a265d579..98b5fec0597e4e7 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/index.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/index.ts @@ -17,3 +17,4 @@ export * from './docker_servers'; export { SuiteTracker } from './suite_tracker'; export type { Provider } from './providers'; +export * from './es_version'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js index 7610ca91286949f..e12ffdc8cd616a1 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js @@ -84,6 +84,9 @@ export function decorateMochaUi(log, lifecycle, context, { isDockerGroup, rootTa this._tags = [...this._tags, ...tagsToAdd]; }; + this.onlyEsVersion = (semver) => { + this._esVersionRequirement = semver; + }; provider.call(this); diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.test.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.test.js similarity index 86% rename from packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.test.js rename to packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.test.js index 10030a1c05632b2..191503af123d017 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.test.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.test.js @@ -12,9 +12,10 @@ import Mocha from 'mocha'; import { create as createSuite } from 'mocha/lib/suite'; import Test from 'mocha/lib/test'; -import { filterSuitesByTags } from './filter_suites_by_tags'; +import { filterSuites } from './filter_suites'; +import { EsVersion } from '../es_version'; -function setup({ include, exclude }) { +function setup({ include, exclude, esVersion }) { return new Promise((resolve) => { const history = []; @@ -55,6 +56,7 @@ function setup({ include, exclude }) { const level1b = createSuite(level1, 'level 1b'); level1b._tags = ['level1b']; + level1b._esVersionRequirement = '<=8'; level1b.addTest(new Test('test 1b', () => {})); const level2 = createSuite(mocha.suite, 'level 2'); @@ -62,7 +64,7 @@ function setup({ include, exclude }) { level2a._tags = ['level2a']; level2a.addTest(new Test('test 2a', () => {})); - filterSuitesByTags({ + filterSuites({ log: { info(...args) { history.push(`info: ${format(...args)}`); @@ -71,6 +73,7 @@ function setup({ include, exclude }) { mocha, include, exclude, + esVersion, }); mocha.run(); @@ -208,3 +211,27 @@ it('does nothing if everything excluded', async () => { ] `); }); + +it(`excludes tests which don't meet the esVersionRequirement`, async () => { + const { history } = await setup({ + include: [], + exclude: [], + esVersion: new EsVersion('9.0.0'), + }); + + expect(history).toMatchInlineSnapshot(` + Array [ + "info: Only running suites which are compatible with ES version 9.0.0", + "suite: ", + "suite: level 1", + "suite: level 1 level 1a", + "hook: \\"before each\\" hook: rootBeforeEach for \\"test 1a\\"", + "hook: level 1 \\"before each\\" hook: level1BeforeEach for \\"test 1a\\"", + "test: level 1 level 1a test 1a", + "suite: level 2", + "suite: level 2 level 2a", + "hook: \\"before each\\" hook: rootBeforeEach for \\"test 2a\\"", + "test: level 2 level 2a test 2a", + ] + `); +}); diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.ts similarity index 55% rename from packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.js rename to packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.ts index 9724956e121f34c..90bb3a894bc6c36 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites_by_tags.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/filter_suites.ts @@ -6,6 +6,24 @@ * Side Public License, v 1. */ +import { ToolingLog } from '@kbn/dev-utils'; +import { Suite, Test } from '../../fake_mocha_types'; +import { EsVersion } from '../es_version'; + +interface SuiteInternal extends Suite { + _tags?: string[]; + _esVersionRequirement?: string; + suites: SuiteInternal[]; +} + +interface Options { + log: ToolingLog; + mocha: any; + include: string[]; + exclude: string[]; + esVersion?: EsVersion; +} + /** * Given a mocha instance that has already loaded all of its suites, filter out * the suites based on the include/exclude tags. If there are include tags then @@ -16,23 +34,50 @@ * @param options.include an array of tags that suites must be tagged with to be run * @param options.exclude an array of tags that will be used to exclude suites from the run */ -export function filterSuitesByTags({ log, mocha, include, exclude }) { - mocha.excludedTests = []; +export function filterSuites({ log, mocha, include, exclude, esVersion }: Options) { + mocha.testsExcludedByTag = []; + mocha.testsExcludedByEsVersion = []; + // collect all the tests from some suite, including it's children - const collectTests = (suite) => + const collectTests = (suite: SuiteInternal): Test[] => suite.suites.reduce((acc, s) => acc.concat(collectTests(s)), suite.tests); + if (esVersion) { + // traverse the test graph and exclude any tests which don't meet their esVersionRequirement + log.info('Only running suites which are compatible with ES version', esVersion.toString()); + (function recurse(parentSuite: SuiteInternal) { + const children = parentSuite.suites; + parentSuite.suites = []; + + const meetsEsVersionRequirement = (suite: SuiteInternal) => + !suite._esVersionRequirement || esVersion.matchRange(suite._esVersionRequirement); + + for (const child of children) { + if (meetsEsVersionRequirement(child)) { + parentSuite.suites.push(child); + recurse(child); + } else { + mocha.testsExcludedByEsVersion = mocha.testsExcludedByEsVersion.concat( + collectTests(child) + ); + } + } + })(mocha.suite); + } + // if include tags were provided, filter the tree once to // only include branches that are included at some point if (include.length) { log.info('Only running suites (and their sub-suites) if they include the tag(s):', include); - const isIncluded = (suite) => + const isIncludedByTags = (suite: SuiteInternal) => !suite._tags ? false : suite._tags.some((t) => include.includes(t)); - const isChildIncluded = (suite) => + + const isIncluded = (suite: SuiteInternal) => isIncludedByTags(suite); + const isChildIncluded = (suite: SuiteInternal): boolean => suite.suites.some((s) => isIncluded(s) || isChildIncluded(s)); - (function recurse(parentSuite) { + (function recurse(parentSuite: SuiteInternal) { const children = parentSuite.suites; parentSuite.suites = []; @@ -47,13 +92,13 @@ export function filterSuitesByTags({ log, mocha, include, exclude }) { // itself, so strip out its tests and recurse to filter // out child suites which are not included if (isChildIncluded(child)) { - mocha.excludedTests = mocha.excludedTests.concat(child.tests); + mocha.testsExcludedByTag = mocha.testsExcludedByTag.concat(child.tests); child.tests = []; parentSuite.suites.push(child); recurse(child); continue; } else { - mocha.excludedTests = mocha.excludedTests.concat(collectTests(child)); + mocha.testsExcludedByTag = mocha.testsExcludedByTag.concat(collectTests(child)); } } })(mocha.suite); @@ -64,9 +109,10 @@ export function filterSuitesByTags({ log, mocha, include, exclude }) { if (exclude.length) { log.info('Filtering out any suites that include the tag(s):', exclude); - const isNotExcluded = (suite) => !suite._tags || !suite._tags.some((t) => exclude.includes(t)); + const isNotExcluded = (suite: SuiteInternal) => + !suite._tags || !suite._tags.some((t) => exclude.includes(t)); - (function recurse(parentSuite) { + (function recurse(parentSuite: SuiteInternal) { const children = parentSuite.suites; parentSuite.suites = []; @@ -77,7 +123,7 @@ export function filterSuitesByTags({ log, mocha, include, exclude }) { parentSuite.suites.push(child); recurse(child); } else { - mocha.excludedTests = mocha.excludedTests.concat(collectTests(child)); + mocha.testsExcludedByTag = mocha.testsExcludedByTag.concat(collectTests(child)); } } })(mocha.suite); diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/setup_mocha.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/setup_mocha.js index 65b7c09242fdd4c..8d88410cb2c1daf 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/setup_mocha.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/setup_mocha.js @@ -11,7 +11,7 @@ import { relative } from 'path'; import { REPO_ROOT } from '@kbn/utils'; import { loadTestFiles } from './load_test_files'; -import { filterSuitesByTags } from './filter_suites_by_tags'; +import { filterSuites } from './filter_suites'; import { MochaReporterProvider } from './reporter'; import { validateCiGroupTags } from './validate_ci_group_tags'; @@ -22,9 +22,10 @@ import { validateCiGroupTags } from './validate_ci_group_tags'; * @param {ToolingLog} log * @param {Config} config * @param {ProviderCollection} providers + * @param {EsVersion} esVersion * @return {Promise} */ -export async function setupMocha(lifecycle, log, config, providers) { +export async function setupMocha(lifecycle, log, config, providers, esVersion) { // configure mocha const mocha = new Mocha({ ...config.get('mochaOpts'), @@ -50,18 +51,26 @@ export async function setupMocha(lifecycle, log, config, providers) { // valiate that there aren't any tests in multiple ciGroups validateCiGroupTags(log, mocha); + filterSuites({ + log, + mocha, + include: [], + exclude: [], + esVersion, + }); + // Each suite has a tag that is the path relative to the root of the repo // So we just need to take input paths, make them relative to the root, and use them as tags // Also, this is a separate filterSuitesByTags() call so that the test suites will be filtered first by // files, then by tags. This way, you can target tags (like smoke) in a specific file. - filterSuitesByTags({ + filterSuites({ log, mocha, include: config.get('suiteFiles.include').map((file) => relative(REPO_ROOT, file)), exclude: config.get('suiteFiles.exclude').map((file) => relative(REPO_ROOT, file)), }); - filterSuitesByTags({ + filterSuites({ log, mocha, include: config.get('suiteTags.include').map((tag) => tag.replace(/-\d+$/, '')), diff --git a/packages/kbn-test/src/functional_test_runner/public_types.ts b/packages/kbn-test/src/functional_test_runner/public_types.ts index d1a0f7998b0a988..6cb6d5adf4b1915 100644 --- a/packages/kbn-test/src/functional_test_runner/public_types.ts +++ b/packages/kbn-test/src/functional_test_runner/public_types.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { ToolingLog } from '@kbn/dev-utils'; +import type { ToolingLog } from '@kbn/dev-utils'; -import { Config, Lifecycle, FailureMetadata, DockerServersService } from './lib'; -import { Test, Suite } from './fake_mocha_types'; +import type { Config, Lifecycle, FailureMetadata, DockerServersService, EsVersion } from './lib'; +import type { Test, Suite } from './fake_mocha_types'; export { Lifecycle, Config, FailureMetadata }; @@ -57,7 +57,7 @@ export interface GenericFtrProviderContext< * @param serviceName */ hasService( - serviceName: 'config' | 'log' | 'lifecycle' | 'failureMetadata' | 'dockerServers' + serviceName: 'config' | 'log' | 'lifecycle' | 'failureMetadata' | 'dockerServers' | 'esVersion' ): true; hasService(serviceName: K): serviceName is K; hasService(serviceName: string): serviceName is Extract; @@ -72,6 +72,7 @@ export interface GenericFtrProviderContext< getService(serviceName: 'lifecycle'): Lifecycle; getService(serviceName: 'dockerServers'): DockerServersService; getService(serviceName: 'failureMetadata'): FailureMetadata; + getService(serviceName: 'esVersion'): EsVersion; getService(serviceName: T): ServiceMap[T]; /** @@ -100,6 +101,7 @@ export class GenericFtrService; } diff --git a/packages/kbn-test/src/functional_tests/cli/run_tests/__snapshots__/args.test.js.snap b/packages/kbn-test/src/functional_tests/cli/run_tests/__snapshots__/args.test.js.snap index ad2f82de87b82d8..fb00908e0c7548d 100644 --- a/packages/kbn-test/src/functional_tests/cli/run_tests/__snapshots__/args.test.js.snap +++ b/packages/kbn-test/src/functional_tests/cli/run_tests/__snapshots__/args.test.js.snap @@ -37,6 +37,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -58,6 +59,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -80,6 +82,7 @@ Object { "createLogger": [Function], "debug": true, "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -101,6 +104,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -124,6 +128,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": Object { "server.foo": "bar", }, @@ -146,6 +151,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "quiet": true, "suiteFiles": Object { @@ -167,6 +173,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "silent": true, "suiteFiles": Object { @@ -188,6 +195,7 @@ Object { ], "createLogger": [Function], "esFrom": "source", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -208,6 +216,7 @@ Object { ], "createLogger": [Function], "esFrom": "source", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -228,6 +237,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "installDir": "foo", "suiteFiles": Object { @@ -249,6 +259,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "grep": "management", "suiteFiles": Object { @@ -270,6 +281,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], @@ -291,6 +303,7 @@ Object { ], "createLogger": [Function], "esFrom": "snapshot", + "esVersion": "999.999.999", "extraKbnOpts": undefined, "suiteFiles": Object { "exclude": Array [], diff --git a/packages/kbn-test/src/functional_tests/cli/run_tests/args.js b/packages/kbn-test/src/functional_tests/cli/run_tests/args.js index 901ff6394649def..497a9b9c6c53394 100644 --- a/packages/kbn-test/src/functional_tests/cli/run_tests/args.js +++ b/packages/kbn-test/src/functional_tests/cli/run_tests/args.js @@ -10,6 +10,7 @@ import { resolve } from 'path'; import dedent from 'dedent'; import { ToolingLog, pickLevelFromFlags } from '@kbn/dev-utils'; +import { EsVersion } from '../../../functional_test_runner'; const options = { help: { desc: 'Display this menu and exit.' }, @@ -147,6 +148,7 @@ export function processOptions(userOptions, defaultConfigPaths) { configs: configs.map((c) => resolve(c)), createLogger, extraKbnOpts: userOptions._, + esVersion: EsVersion.getDefault(), }; } diff --git a/packages/kbn-test/src/functional_tests/cli/run_tests/args.test.js b/packages/kbn-test/src/functional_tests/cli/run_tests/args.test.js index 7786aee5af55264..72ba541466960d2 100644 --- a/packages/kbn-test/src/functional_tests/cli/run_tests/args.test.js +++ b/packages/kbn-test/src/functional_tests/cli/run_tests/args.test.js @@ -6,9 +6,20 @@ * Side Public License, v 1. */ -import { displayHelp, processOptions } from './args'; import { createAbsolutePathSerializer } from '@kbn/dev-utils'; +import { displayHelp, processOptions } from './args'; + +jest.mock('../../../functional_test_runner/lib/es_version', () => { + return { + EsVersion: class { + static getDefault() { + return '999.999.999'; + } + }, + }; +}); + expect.addSnapshotSerializer(createAbsolutePathSerializer(process.cwd())); const INITIAL_TEST_ES_FROM = process.env.TEST_ES_FROM; diff --git a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts index f9e109928ddc0ba..77d6cd8e357a5a4 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import type { ToolingLog } from '@kbn/dev-utils'; -import { FunctionalTestRunner, readConfigFile } from '../../functional_test_runner'; +import { FunctionalTestRunner, readConfigFile, EsVersion } from '../../functional_test_runner'; import { CliError } from './run_cli'; export interface CreateFtrOptions { @@ -26,6 +26,7 @@ export interface CreateFtrOptions { exclude?: string[]; }; updateSnapshots?: boolean; + esVersion: EsVersion; } export interface CreateFtrParams { @@ -34,31 +35,46 @@ export interface CreateFtrParams { } async function createFtr({ configPath, - options: { installDir, log, bail, grep, updateBaselines, suiteFiles, suiteTags, updateSnapshots }, + options: { + installDir, + log, + bail, + grep, + updateBaselines, + suiteFiles, + suiteTags, + updateSnapshots, + esVersion, + }, }: CreateFtrParams) { - const config = await readConfigFile(log, configPath); + const config = await readConfigFile(log, esVersion, configPath); return { config, - ftr: new FunctionalTestRunner(log, configPath, { - mochaOpts: { - bail: !!bail, - grep, + ftr: new FunctionalTestRunner( + log, + configPath, + { + mochaOpts: { + bail: !!bail, + grep, + }, + kbnTestServer: { + installDir, + }, + updateBaselines, + updateSnapshots, + suiteFiles: { + include: [...(suiteFiles?.include || []), ...config.get('suiteFiles.include')], + exclude: [...(suiteFiles?.exclude || []), ...config.get('suiteFiles.exclude')], + }, + suiteTags: { + include: [...(suiteTags?.include || []), ...config.get('suiteTags.include')], + exclude: [...(suiteTags?.exclude || []), ...config.get('suiteTags.exclude')], + }, }, - kbnTestServer: { - installDir, - }, - updateBaselines, - updateSnapshots, - suiteFiles: { - include: [...(suiteFiles?.include || []), ...config.get('suiteFiles.include')], - exclude: [...(suiteFiles?.exclude || []), ...config.get('suiteFiles.exclude')], - }, - suiteTags: { - include: [...(suiteTags?.include || []), ...config.get('suiteTags.include')], - exclude: [...(suiteTags?.exclude || []), ...config.get('suiteTags.exclude')], - }, - }), + esVersion + ), }; } @@ -71,15 +87,15 @@ export async function assertNoneExcluded({ configPath, options }: CreateFtrParam } const stats = await ftr.getTestStats(); - if (stats.excludedTests.length > 0) { + if (stats.testsExcludedByTag.length > 0) { throw new CliError(` - ${stats.excludedTests.length} tests in the ${configPath} config + ${stats.testsExcludedByTag.length} tests in the ${configPath} config are excluded when filtering by the tags run on CI. Make sure that all suites are tagged with one of the following tags: ${JSON.stringify(options.suiteTags)} - - ${stats.excludedTests.join('\n - ')} + - ${stats.testsExcludedByTag.join('\n - ')} `); } } diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index cace061be64a9cf..5906193ca145c77 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -23,7 +23,7 @@ import { CreateFtrOptions, } from './lib'; -import { readConfigFile } from '../functional_test_runner/lib'; +import { readConfigFile, EsVersion } from '../functional_test_runner/lib'; const makeSuccessMessage = (options: StartServerOptions) => { const installDirFlag = options.installDir ? ` --kibana-install-dir=${options.installDir}` : ''; @@ -55,6 +55,7 @@ interface RunTestsParams extends CreateFtrOptions { configs: string[]; /** run from source instead of snapshot */ esFrom?: string; + esVersion: EsVersion; createLogger: () => ToolingLog; extraKbnOpts: string[]; assertNoneExcluded: boolean; @@ -105,7 +106,7 @@ export async function runTests(options: RunTestsParams) { log.write(`--- [${progress}] Running ${relative(REPO_ROOT, configPath)}`); await withProcRunner(log, async (procs) => { - const config = await readConfigFile(log, configPath); + const config = await readConfigFile(log, options.esVersion, configPath); let es; try { @@ -145,6 +146,7 @@ interface StartServerOptions { createLogger: () => ToolingLog; extraKbnOpts: string[]; useDefaultConfig?: boolean; + esVersion: EsVersion; } export async function startServers({ ...options }: StartServerOptions) { @@ -162,7 +164,7 @@ export async function startServers({ ...options }: StartServerOptions) { }; await withProcRunner(log, async (procs) => { - const config = await readConfigFile(log, options.config); + const config = await readConfigFile(log, options.esVersion, options.config); const es = await runElasticsearch({ config, options: opts }); await runKibanaServer({ diff --git a/packages/kbn-test/src/kbn_archiver_cli.ts b/packages/kbn-test/src/kbn_archiver_cli.ts index 80e35efaec976fc..f7f17900efcfffc 100644 --- a/packages/kbn-test/src/kbn_archiver_cli.ts +++ b/packages/kbn-test/src/kbn_archiver_cli.ts @@ -12,7 +12,7 @@ import Url from 'url'; import { RunWithCommands, createFlagError, Flags } from '@kbn/dev-utils'; import { KbnClient } from './kbn_client'; -import { readConfigFile } from './functional_test_runner'; +import { readConfigFile, EsVersion } from './functional_test_runner'; function getSinglePositionalArg(flags: Flags) { const positional = flags._; @@ -57,7 +57,7 @@ export function runKbnArchiverCli() { throw createFlagError('expected --config to be a string'); } - config = await readConfigFile(log, Path.resolve(flags.config)); + config = await readConfigFile(log, EsVersion.getDefault(), Path.resolve(flags.config)); statsMeta.set('ftrConfigPath', flags.config); } diff --git a/packages/kbn-test/types/ftr_globals/mocha.d.ts b/packages/kbn-test/types/ftr_globals/mocha.d.ts index ac9e33d4b9dcc7e..d5895b40f124532 100644 --- a/packages/kbn-test/types/ftr_globals/mocha.d.ts +++ b/packages/kbn-test/types/ftr_globals/mocha.d.ts @@ -14,5 +14,11 @@ declare module 'mocha' { * Assign tags to the test suite to determine in which CI job it should be run. */ tags(tags: string[] | string): void; + /** + * Define the ES versions for which this test requires, any version which doesn't meet this range will + * cause these tests to be skipped + * @param semver any valid semver range, like ">=8" + */ + onlyEsVersion(semver: string): void; } } From aaefbd22aac753b8f48026ed6c1e4418bcbf7c8f Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Wed, 19 Jan 2022 18:43:16 -0500 Subject: [PATCH 10/12] [Uptime] Monitor management - fix breadcrumbs (#123240) * uptime - monitor management - fix breadcrumbs * add synthetics breadcrumbs tests * fix types * update i18n --- x-pack/plugins/uptime/common/constants/ui.ts | 2 +- .../journeys/monitor_management.journey.ts | 60 ++++++++++++++++++ .../e2e/page_objects/monitor_management.tsx | 4 ++ .../common/header/action_menu_content.tsx | 8 ++- .../action_bar/action_bar.tsx | 6 +- .../monitor_list/actions.tsx | 1 + .../pages/monitor_management/add_monitor.tsx | 3 + .../pages/monitor_management/edit_monitor.tsx | 2 + .../monitor_management/monitor_management.tsx | 2 + .../use_monitor_management_breadcrumbs.tsx | 63 +++++++++++++++++++ x-pack/plugins/uptime/public/routes.tsx | 4 +- 11 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx diff --git a/x-pack/plugins/uptime/common/constants/ui.ts b/x-pack/plugins/uptime/common/constants/ui.ts index 7a9cd4943bc5796..155c6ea80242fcf 100644 --- a/x-pack/plugins/uptime/common/constants/ui.ts +++ b/x-pack/plugins/uptime/common/constants/ui.ts @@ -11,7 +11,7 @@ export const MONITOR_ADD_ROUTE = '/add-monitor'; export const MONITOR_EDIT_ROUTE = '/edit-monitor/:monitorId'; -export const MONITOR_MANAGEMENT = '/manage-monitors'; +export const MONITOR_MANAGEMENT_ROUTE = '/manage-monitors'; export const OVERVIEW_ROUTE = '/'; diff --git a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts index efc47c44765c8c5..e5b0cd352ac5bf4 100644 --- a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts @@ -134,3 +134,63 @@ journey('Monitor Management', async ({ page, params }: { page: Page; params: any await deleteMonitor(); }); }); + +journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page; params: any }) => { + const uptime = monitorManagementPageProvider({ page, kibanaUrl: params.kibanaUrl }); + const basicMonitorDetails = { + name: 'Sample monitor', + location: 'US Central', + schedule: '@every 3m', + apmServiceName: 'service', + }; + + before(async () => { + await uptime.waitForLoadingToFinish(); + }); + + step('Go to monitor-management', async () => { + await uptime.navigateToMonitorManagement(); + }); + + step('login to Kibana', async () => { + await uptime.loginToKibana(); + }); + + step('Check breadcrumb', async () => { + const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); + expect(lastBreadcrumb).toEqual('Monitor management'); + }); + + step('check breadcrumbs', async () => { + await uptime.clickAddMonitor(); + const breadcrumbs = await page.$$('[data-test-subj="breadcrumb"]'); + expect(await breadcrumbs[1].textContent()).toEqual('Monitor management'); + const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); + expect(lastBreadcrumb).toEqual('Add monitor'); + }); + + step('create monitor http monitor', async () => { + const monitorDetails = { + ...basicMonitorDetails, + url: 'https://elastic.co', + locations: [basicMonitorDetails.location], + }; + await uptime.createBasicHTTPMonitorDetails(monitorDetails); + const isSuccessful = await uptime.confirmAndSave(); + expect(isSuccessful).toBeTruthy(); + }); + + step('edit http monitor and check breadcrumb', async () => { + await uptime.editMonitor(); + const breadcrumbs = await page.$$('[data-test-subj=breadcrumb]'); + expect(await breadcrumbs[1].textContent()).toEqual('Monitor management'); + const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); + expect(lastBreadcrumb).toEqual('Edit monitor'); + }); + + step('delete monitor', async () => { + await uptime.navigateToMonitorManagement(); + const isSuccessful = await uptime.deleteMonitor(); + expect(isSuccessful).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx index 51f476a7a174294..e12b7fdf40bc379 100644 --- a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx +++ b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx @@ -44,6 +44,10 @@ export function monitorManagementPageProvider({ return await this.findByTestSubj('uptimeDeleteMonitorSuccess'); }, + async editMonitor() { + await this.clickByTestSubj('monitorManagementEditMonitor'); + }, + async findMonitorConfiguration(monitorConfig: Record) { const values = Object.values(monitorConfig); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 0600a629ad3e2bd..985b1ae9146f249 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -16,7 +16,11 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; import { useGetUrlParams } from '../../../hooks'; import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; -import { MONITOR_MANAGEMENT, MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../common/constants'; +import { + MONITOR_MANAGEMENT_ROUTE, + MONITOR_ROUTE, + SETTINGS_ROUTE, +} from '../../../../common/constants'; import { stringifyUrlParams } from '../../../lib/helper/stringify_url_params'; import { InspectorHeaderLink } from './inspector_header_link'; import { monitorStatusSelector } from '../../../state/selectors'; @@ -81,7 +85,7 @@ export function ActionMenuContent({ config }: { config: UptimeConfig }): React.R color="text" data-test-subj="management-page-link" href={history.createHref({ - pathname: MONITOR_MANAGEMENT, + pathname: MONITOR_MANAGEMENT_ROUTE, })} > { }, [data, status, notifications.toasts, isSaving, isValid, monitorId]); return status === FETCH_STATUS.SUCCESS ? ( - + ) : ( {!isValid && hasBeenSubmitted && VALIDATION_ERROR_LABEL} @@ -92,7 +92,7 @@ export const ActionBar = ({ monitor, isValid, onSave }: Props) => { color="ghost" size="s" iconType="cross" - href={`${basePath}/app/uptime/${MONITOR_MANAGEMENT}`} + href={`${basePath}/app/uptime/${MONITOR_MANAGEMENT_ROUTE}`} > {DISCARD_LABEL} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx index c3ef3610f8c7e30..d86117df555ad32 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx @@ -61,6 +61,7 @@ export const Actions = ({ id, setRefresh }: Props) => { iconType="pencil" href={`${basePath}/app/uptime/edit-monitor/${Buffer.from(id, 'utf8').toString('base64')}`} aria-label={EDIT_MONITOR_LABEL} + data-test-subj="monitorManagementEditMonitor" /> diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx index 749a109dffda2b4..cc474b065464394 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx @@ -13,6 +13,7 @@ import { SyntheticsProviders } from '../../components/fleet_package/contexts'; import { Loader } from '../../components/monitor_management/loader/loader'; import { MonitorConfig } from '../../components/monitor_management/monitor_config/monitor_config'; import { useLocations } from '../../components/monitor_management/hooks/use_locations'; +import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs'; export const AddMonitorPage: React.FC = () => { useTrackPageview({ app: 'uptime', path: 'add-monitor' }); @@ -20,6 +21,8 @@ export const AddMonitorPage: React.FC = () => { const { error, loading } = useLocations(); + useMonitorManagementBreadcrumbs({ isAddMonitor: true }); + return ( { useTrackPageview({ app: 'uptime', path: 'edit-monitor' }); useTrackPageview({ app: 'uptime', path: 'edit-monitor', delay: 15000 }); + useMonitorManagementBreadcrumbs({ isEditMonitor: true }); const { monitorId } = useParams<{ monitorId: string }>(); const { data, status } = useFetcher>(() => { diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index 0619f4d4bed1cf2..cb43dc9c90d7d05 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -11,6 +11,7 @@ import { useTrackPageview } from '../../../../observability/public'; import { getMonitors } from '../../state/actions'; import { monitorManagementListSelector } from '../../state/selectors'; import { MonitorManagementList } from '../../components/monitor_management/monitor_list/monitor_list'; +import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs'; export const MonitorManagementPage: React.FC = () => { const [refresh, setRefresh] = useState(true); @@ -18,6 +19,7 @@ export const MonitorManagementPage: React.FC = () => { const [pageSize, setPageSize] = useState(10); // saved objects page index is base 1 useTrackPageview({ app: 'uptime', path: 'manage-monitors' }); useTrackPageview({ app: 'uptime', path: 'manage-monitors', delay: 15000 }); + useMonitorManagementBreadcrumbs(); const dispatch = useDispatch(); const monitorList = useSelector(monitorManagementListSelector); diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx new file mode 100644 index 000000000000000..e5784591a00fc42 --- /dev/null +++ b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx @@ -0,0 +1,63 @@ +/* + * 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'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { MONITOR_MANAGEMENT_ROUTE } from '../../../common/constants'; +import { PLUGIN } from '../../../common/constants/plugin'; + +export const useMonitorManagementBreadcrumbs = ({ + isAddMonitor, + isEditMonitor, + monitorId, +}: { + isAddMonitor?: boolean; + isEditMonitor?: boolean; + monitorId?: string; +} = {}) => { + const kibana = useKibana(); + const appPath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + + useBreadcrumbs([ + { + text: MONITOR_MANAGEMENT_CRUMB, + href: isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}` : undefined, + }, + ...(isAddMonitor + ? [ + { + text: ADD_MONITOR_CRUMB, + }, + ] + : []), + ...(isEditMonitor + ? [ + { + text: EDIT_MONITOR_CRUMB, + }, + ] + : []), + ]); +}; + +export const MONITOR_MANAGEMENT_CRUMB = i18n.translate( + 'xpack.uptime.monitorManagement.monitorManagementCrumb', + { + defaultMessage: 'Monitor management', + } +); + +export const ADD_MONITOR_CRUMB = i18n.translate('xpack.uptime.monitorManagement.addMonitorCrumb', { + defaultMessage: 'Add monitor', +}); + +export const EDIT_MONITOR_CRUMB = i18n.translate( + 'xpack.uptime.monitorManagement.editMonitorCrumb', + { + defaultMessage: 'Edit monitor', + } +); diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index d462d35a15f5666..bcb942250c6f138 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -16,7 +16,7 @@ import { MONITOR_ROUTE, MONITOR_ADD_ROUTE, MONITOR_EDIT_ROUTE, - MONITOR_MANAGEMENT, + MONITOR_MANAGEMENT_ROUTE, OVERVIEW_ROUTE, SETTINGS_ROUTE, STEP_DETAIL_ROUTE, @@ -231,7 +231,7 @@ const getRoutes = (config: UptimeConfig): RouteProps[] => { defaultMessage: 'Manage Monitors | {baseTitle}', values: { baseTitle }, }), - path: MONITOR_MANAGEMENT, + path: MONITOR_MANAGEMENT_ROUTE, component: MonitorManagementPage, dataTestSubj: 'uptimeMonitorManagementListPage', telemetryId: UptimePage.MonitorManagement, From b5c8464162dc56407b14e97153ea9ad626915d44 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Wed, 19 Jan 2022 21:57:52 -0300 Subject: [PATCH 11/12] Host Risk score tab on Host details page (#122586) * Create host risk tab * Create host score over time chart * Create Top risk score contributors table * Fix Host risk score over time chart * Add Dashboard and information buttons to host details page * Update Top risk score contributors table to follow timerange filter * Improve unit tests * Hide risk tab from Hots detaisl when feature flag is disabled * Add cypress test to host details risk tab * Delete filterQuery option from Hostrisk SearchStrategy --- .../security_solution/common/constants.ts | 3 +- .../hosts/risk_score/index.ts | 12 +- .../hosts/host_details_risk_tab.spec.ts | 40 ++++ ...sk_column.ts => hosts_risk_column.spec.ts} | 0 .../cypress/screens/hosts/host_risk.ts | 16 ++ .../cypress/tasks/host_risk.ts | 17 ++ .../security_solution/cypress/tasks/login.ts | 4 +- .../cti_details/host_risk_summary.test.tsx | 2 + .../navigation/tab_navigation/index.test.tsx | 3 +- .../common/containers/hosts_risk/types.ts | 13 ++ .../hosts_risk/use_hosts_risk_score.ts | 41 +++- .../use_hosts_risk_score_complete.ts | 4 + .../public/common/mock/global_state.ts | 2 + .../host_risk_information/index.test.tsx | 22 +- .../host_risk_information/index.tsx | 53 +++-- .../host_risk_information/translations.ts | 7 + .../host_score_over_time/index.test.tsx | 51 +++++ .../components/host_score_over_time/index.tsx | 202 ++++++++++++++++++ .../host_score_over_time/translations.ts | 33 +++ .../kpi_hosts/risky_hosts/index.tsx | 7 +- .../index.test.tsx | 72 +++++++ .../top_host_score_contributors/index.tsx | 125 +++++++++++ .../translations.ts | 29 +++ .../hosts/pages/details/details_tabs.tsx | 4 + .../public/hosts/pages/details/index.tsx | 9 +- .../hosts/pages/details/nav_tabs.test.tsx | 15 +- .../public/hosts/pages/details/nav_tabs.tsx | 23 +- .../public/hosts/pages/details/utils.ts | 1 + .../public/hosts/pages/index.tsx | 1 + .../pages/navigation/host_risk_tab_body.tsx | 60 ++++++ .../public/hosts/pages/navigation/index.ts | 1 + .../public/hosts/pages/translations.ts | 14 ++ .../public/hosts/store/helpers.test.ts | 4 + .../public/hosts/store/model.ts | 2 + .../public/hosts/store/reducer.ts | 2 + .../overview_risky_host_links/index.tsx | 6 +- .../risky_hosts_enabled_module.test.tsx | 1 + .../risky_hosts_panel_view.tsx | 4 +- .../query.hosts_kpi_risky_hosts.dsl.ts | 3 - .../hosts/risk_score/query.hosts_risk.dsl.ts | 14 +- .../es_archives/risky_hosts/data.json | 25 +++ .../es_archives/risky_hosts/mappings.json | 58 +++++ 42 files changed, 953 insertions(+), 52 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/hosts/host_details_risk_tab.spec.ts rename x-pack/plugins/security_solution/cypress/integration/hosts/{hosts_risk_column.ts => hosts_risk_column.spec.ts} (100%) create mode 100644 x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts create mode 100644 x-pack/plugins/security_solution/cypress/tasks/host_risk.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts create mode 100644 x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts create mode 100644 x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 9a9236d573fc4ed..7514edea6247ff4 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -99,6 +99,7 @@ export enum SecurityPageName { hosts = 'hosts', hostsAnomalies = 'hosts-anomalies', hostsExternalAlerts = 'hosts-external_alerts', + hostsRisk = 'hosts-risk', investigate = 'investigate', network = 'network', networkAnomalies = 'network-anomalies', @@ -359,7 +360,7 @@ export const showAllOthersBucket: string[] = [ */ export const ELASTIC_NAME = 'estc' as const; -export const RISKY_HOSTS_INDEX_PREFIX = 'ml_host_risk_score_latest_' as const; +export const RISKY_HOSTS_INDEX_PREFIX = 'ml_host_risk_score_' as const; export const TRANSFORM_STATES = { ABORTING: 'aborting', diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts index 4273c08c638f3f7..1c0d20161823bae 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts @@ -11,13 +11,16 @@ import type { IEsSearchResponse, } from '../../../../../../../../src/plugins/data/common'; import { RISKY_HOSTS_INDEX_PREFIX } from '../../../../constants'; -import { Inspect, Maybe, TimerangeInput } from '../../../common'; +import { Direction, Inspect, Maybe, TimerangeInput } from '../../../common'; export interface HostsRiskScoreRequestOptions extends IEsSearchRequest { defaultIndex: string[]; factoryQueryType?: FactoryQueryTypes; hostNames?: string[]; timerange?: TimerangeInput; + onlyLatest?: boolean; + limit?: number; + sortOrder?: Direction.asc | Direction.desc; } export interface HostsRiskScoreStrategyResponse extends IEsSearchResponse { @@ -25,6 +28,7 @@ export interface HostsRiskScoreStrategyResponse extends IEsSearchResponse { } export interface HostsRiskScore { + '@timestamp': string; host: { name: string; }; @@ -37,9 +41,9 @@ export interface HostsRiskScore { export interface RuleRisk { rule_name: string; - rule_risk: string; + rule_risk: number; } -export const getHostRiskIndex = (spaceId: string): string => { - return `${RISKY_HOSTS_INDEX_PREFIX}${spaceId}`; +export const getHostRiskIndex = (spaceId: string, onlyLatest: boolean = true): string => { + return `${RISKY_HOSTS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; }; diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/host_details_risk_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/host_details_risk_tab.spec.ts new file mode 100644 index 000000000000000..8f17d69a0d35be0 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/host_details_risk_tab.spec.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 { loginAndWaitForHostDetailsPage } from '../../tasks/login'; + +import { cleanKibana } from '../../tasks/common'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { + navigateToHostRiskDetailTab, + openRiskFlyout, + waitForTableToLoad, +} from '../../tasks/host_risk'; +import { RULE_NAME, RISK_FLYOUT } from '../../screens/hosts/host_risk'; + +describe('risk tab', () => { + before(() => { + cleanKibana(); + esArchiverLoad('risky_hosts'); + loginAndWaitForHostDetailsPage('siem-kibana'); + navigateToHostRiskDetailTab(); + waitForTableToLoad(); + }); + + after(() => { + esArchiverUnload('risky_hosts'); + }); + + it('renders risk tab', () => { + cy.get(RULE_NAME).eq(3).should('have.text', 'Unusual Linux Username'); + }); + + it('shows risk information overlay when button is clicked', () => { + openRiskFlyout(); + cy.get(RISK_FLYOUT).should('have.text', 'How is host risk calculated?'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.spec.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts rename to x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.spec.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts new file mode 100644 index 000000000000000..00bd39b911fb811 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/hosts/host_risk.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const RULE_NAME = '[data-test-subj="topHostScoreContributors"] .euiTableCellContent__text'; + +export const RISK_FLYOUT = '[data-test-subj="open-risk-information-flyout"] .euiFlyoutHeader'; + +export const RISK_DETAILS_NAV = '[data-test-subj="navigation-hostRisk"]'; + +export const RISK_FLYOUT_TRIGGER = '[data-test-subj="open-risk-information-flyout-trigger"]'; + +export const LOADING_TABLE = '.euiBasicTable-loading'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts b/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts new file mode 100644 index 000000000000000..7a357e8a5c7fb2c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/host_risk.ts @@ -0,0 +1,17 @@ +/* + * 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 { LOADING_TABLE, RISK_DETAILS_NAV, RISK_FLYOUT_TRIGGER } from '../screens/hosts/host_risk'; + +export const navigateToHostRiskDetailTab = () => cy.get(RISK_DETAILS_NAV).click(); + +export const openRiskFlyout = () => cy.get(RISK_FLYOUT_TRIGGER).click(); + +export const waitForTableToLoad = () => { + cy.get(LOADING_TABLE).should('exist'); + cy.get(LOADING_TABLE).should('not.exist'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 0610333352ce85e..ad6ad0486e5185f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -334,8 +334,8 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { cy.get(TIMELINE_FLYOUT_BODY).should('be.visible'); }; -export const loginAndWaitForHostDetailsPage = () => { - loginAndWaitForPage(hostDetailsUrl('suricata-iowa')); +export const loginAndWaitForHostDetailsPage = (hostName = 'suricata-iowa') => { + loginAndWaitForPage(hostDetailsUrl(hostName)); cy.get('[data-test-subj="loading-spinner"]', { timeout: 12000 }).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx index 9d60fbc496d8db0..945317036e7bcb7 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx @@ -20,6 +20,7 @@ describe('HostRiskSummary', () => { isModuleEnabled: true, result: [ { + '@timestamp': '1641902481', host: { name: 'test-host-name', }, @@ -63,6 +64,7 @@ describe('HostRiskSummary', () => { isModuleEnabled: false, result: [ { + '@timestamp': '1641902530', host: { name: 'test-host-name', }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx index b123a26257683b6..d2a17e87cffcfa3 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx @@ -51,13 +51,14 @@ const hostName = 'siem-window'; describe('Table Navigation', () => { const mockHasMlUserPermissions = true; + const mockRiskyHostEnabled = true; const mockProps: TabNavigationProps & RouteSpyState = { pageName: 'hosts', pathName: '/hosts', detailName: undefined, search: '', tabName: HostsTableType.authentications, - navTabs: navTabsHostDetails(hostName, mockHasMlUserPermissions), + navTabs: navTabsHostDetails(hostName, mockHasMlUserPermissions, mockRiskyHostEnabled), [CONSTANTS.timerange]: { global: { [CONSTANTS.timerange]: { diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts new file mode 100644 index 000000000000000..4a231b38ce61268 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/types.ts @@ -0,0 +1,13 @@ +/* + * 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 enum HostRiskScoreQueryId { + DEFAULT = 'HostRiskScore', + HOST_RISK_SCORE_OVER_TIME = 'HostRiskScoreOverTimeQuery', + TOP_HOST_SCORE_CONTRIBUTORS = 'TopHostScoreContributorsQuery', + OVERVIEW_RISKY_HOSTS = 'OverviewRiskyHosts', +} diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts index debdacb570ad05e..3c47c4d02b358c1 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts @@ -8,17 +8,16 @@ import { i18n } from '@kbn/i18n'; import { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; - import { useAppToasts } from '../../hooks/use_app_toasts'; import { useKibana } from '../../lib/kibana'; import { inputsActions } from '../../store/actions'; import { isIndexNotFoundError } from '../../utils/exceptions'; -import { getHostRiskIndex, HostsRiskScore } from '../../../../common/search_strategy'; +import { Direction, getHostRiskIndex, HostsRiskScore } from '../../../../common/search_strategy'; import { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { HostRiskScoreQueryId } from './types'; -export const QUERY_ID = 'host_risk_score'; const noop = () => {}; const isRecord = (item: unknown): item is Record => @@ -37,12 +36,23 @@ export interface HostRisk { result?: HostsRiskScore[]; } +/** + * @param queryId Provide this parameter when using query inspector to identify the query. + */ export const useHostsRiskScore = ({ timerange, hostName, + onlyLatest = true, + queryId = HostRiskScoreQueryId.DEFAULT, + sortOrder, + limit, }: { timerange?: { to: string; from: string }; hostName?: string; + onlyLatest?: boolean; + queryId?: HostRiskScoreQueryId; + limit?: number; + sortOrder?: Direction; }): HostRisk | null => { const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); const [isModuleEnabled, setIsModuleEnabled] = useState(undefined); @@ -56,8 +66,8 @@ export const useHostsRiskScore = ({ const { error, result, start, loading: isHostsRiskScoreLoading } = useHostsRiskScoreComplete(); const deleteQuery = useCallback(() => { - dispatch(inputsActions.deleteOneQuery({ inputId: 'global', id: QUERY_ID })); - }, [dispatch]); + dispatch(inputsActions.deleteOneQuery({ inputId: 'global', id: queryId })); + }, [dispatch, queryId]); useEffect(() => { if (!isHostsRiskScoreLoading && result) { @@ -66,7 +76,7 @@ export const useHostsRiskScore = ({ dispatch( inputsActions.setQuery({ inputId: 'global', - id: QUERY_ID, + id: queryId, inspect: { dsl: result.inspect?.dsl ?? [], response: [JSON.stringify(result.rawResponse, null, 2)], @@ -77,7 +87,7 @@ export const useHostsRiskScore = ({ ); } return deleteQuery; - }, [deleteQuery, dispatch, isHostsRiskScoreLoading, result, setIsModuleEnabled]); + }, [deleteQuery, dispatch, isHostsRiskScoreLoading, result, setIsModuleEnabled, queryId]); useEffect(() => { if (error) { @@ -105,11 +115,24 @@ export const useHostsRiskScore = ({ ? { to: timerange.to, from: timerange.from, interval: '' } : undefined, hostNames: hostName ? [hostName] : undefined, - defaultIndex: [getHostRiskIndex(space.id)], + defaultIndex: [getHostRiskIndex(space.id, onlyLatest)], + onlyLatest, + sortOrder, + limit, }); }); } - }, [start, data, timerange, hostName, riskyHostsFeatureEnabled, spaces]); + }, [ + start, + data, + timerange, + hostName, + onlyLatest, + riskyHostsFeatureEnabled, + spaces, + sortOrder, + limit, + ]); if ((!hostName && !timerange) || !riskyHostsFeatureEnabled) { return null; diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts index 6faaa3c8f08dbf8..2531d533d830bf0 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts @@ -30,6 +30,8 @@ export const getHostsRiskScore = ({ timerange, hostNames, signal, + limit, + sortOrder, }: GetHostsRiskScoreProps): Observable => data.search.search( { @@ -37,6 +39,8 @@ export const getHostsRiskScore = ({ factoryQueryType: HostsQueries.hostsRiskScore, timerange, hostNames, + limit, + sortOrder, }, { strategy: 'securitySolutionSearchStrategy', diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 2d804392580d0ae..cf7d9f1dae23e6f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -83,6 +83,7 @@ export const mockGlobalState: State = { uncommonProcesses: { activePage: 0, limit: 10 }, anomalies: null, externalAlerts: { activePage: 0, limit: 10 }, + hostRisk: null, }, }, details: { @@ -98,6 +99,7 @@ export const mockGlobalState: State = { uncommonProcesses: { activePage: 0, limit: 10 }, anomalies: null, externalAlerts: { activePage: 0, limit: 10 }, + hostRisk: null, }, }, }, diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.test.tsx index 09d0375cf7dffb3..7f5e512978ee9c9 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.test.tsx @@ -7,25 +7,35 @@ import { render, fireEvent } from '@testing-library/react'; import React from 'react'; -import { HostRiskInformation } from '.'; +import { HostRiskInformationButtonIcon, HostRiskInformationButtonEmpty } from '.'; import { TestProviders } from '../../../common/mock'; describe('Host Risk Flyout', () => { - it('renders', () => { - const { queryByTestId } = render(); + describe('HostRiskInformationButtonIcon', () => { + it('renders', () => { + const { queryByTestId } = render(); - expect(queryByTestId('open-risk-information-flyout')).toBeInTheDocument(); + expect(queryByTestId('open-risk-information-flyout-trigger')).toBeInTheDocument(); + }); + }); + + describe('HostRiskInformationButtonEmpty', () => { + it('renders', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('open-risk-information-flyout-trigger')).toBeInTheDocument(); + }); }); it('opens and displays table with 5 rows', () => { const NUMBER_OF_ROWS = 1 + 5; // 1 header row + 5 severity rows const { getByTestId, queryByTestId, queryAllByRole } = render( - + ); - fireEvent.click(getByTestId('open-risk-information-flyout')); + fireEvent.click(getByTestId('open-risk-information-flyout-trigger')); expect(queryByTestId('risk-information-table')).toBeInTheDocument(); expect(queryAllByRole('row')).toHaveLength(NUMBER_OF_ROWS); diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx index b4632466672e28b..00230b0a4d27862 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/index.tsx @@ -21,10 +21,11 @@ import { EuiButton, EuiSpacer, EuiBasicTableColumn, + EuiButtonEmpty, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, memo } from 'react'; import { HostRiskSeverity } from '../../../../common/search_strategy'; import { RISKY_HOSTS_DOC_LINK } from '../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; import { HostRiskScore } from '../common/host_risk_score'; @@ -61,16 +62,8 @@ const tableItems: TableItem[] = [ export const HOST_RISK_INFO_BUTTON_CLASS = 'HostRiskInformation__button'; -export const HostRiskInformation = () => { - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - - const handleOnClose = useCallback(() => { - setIsFlyoutVisible(false); - }, []); - - const handleOnOpen = useCallback(() => { - setIsFlyoutVisible(true); - }, []); +export const HostRiskInformationButtonIcon = memo(() => { + const [isFlyoutVisible, handleOnOpen, handleOnClose] = useOnOpenCloseHandler(); return ( <> @@ -81,11 +74,39 @@ export const HostRiskInformation = () => { aria-label={i18n.INFORMATION_ARIA_LABEL} onClick={handleOnOpen} className={HOST_RISK_INFO_BUTTON_CLASS} - data-test-subj="open-risk-information-flyout" + data-test-subj="open-risk-information-flyout-trigger" /> {isFlyoutVisible && } ); +}); +HostRiskInformationButtonIcon.displayName = 'HostRiskInformationButtonIcon'; + +export const HostRiskInformationButtonEmpty = memo(() => { + const [isFlyoutVisible, handleOnOpen, handleOnClose] = useOnOpenCloseHandler(); + + return ( + <> + + {i18n.INFO_BUTTON_TEXT} + + {isFlyoutVisible && } + + ); +}); +HostRiskInformationButtonEmpty.displayName = 'HostRiskInformationButtonEmpty'; + +const useOnOpenCloseHandler = (): [boolean, () => void, () => void] => { + const [isOpen, setIsOpen] = useState(false); + + const handleOnClose = useCallback(() => { + setIsOpen(false); + }, []); + + const handleOnOpen = useCallback(() => { + setIsOpen(true); + }, []); + return [isOpen, handleOnOpen, handleOnClose]; }; const HostRiskInformationFlyout = ({ handleOnClose }: { handleOnClose: () => void }) => { @@ -94,7 +115,13 @@ const HostRiskInformationFlyout = ({ handleOnClose }: { handleOnClose: () => voi }); return ( - +

{i18n.TITLE}

diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/translations.ts index 244c7b458b20605..1e031a84ae8a50c 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_information/translations.ts @@ -68,3 +68,10 @@ export const CLOSE_BUTTON_LTEXT = i18n.translate( defaultMessage: 'Close', } ); + +export const INFO_BUTTON_TEXT = i18n.translate( + 'xpack.securitySolution.hosts.hostRiskInformation.buttonLabel', + { + defaultMessage: 'How is risk score calculated?', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx new file mode 100644 index 000000000000000..9a7dfcc967fbb42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { HostRiskScoreOverTime } from '.'; +import { TestProviders } from '../../../common/mock'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; + +jest.mock('../../../common/containers/hosts_risk/use_hosts_risk_score'); +const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock; + +describe('Host Risk Flyout', () => { + it('renders', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('hostRiskScoreOverTime')).toBeInTheDocument(); + }); + + it('renders loader when HostsRiskScore is laoding', () => { + useHostsRiskScoreMock.mockReturnValueOnce({ + loading: true, + isModuleEnabled: true, + result: [], + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('HostRiskScoreOverTime-loading')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx new file mode 100644 index 000000000000000..eb34f9100101b31 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/index.tsx @@ -0,0 +1,202 @@ +/* + * 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 React, { useMemo, useCallback } from 'react'; +import { + Chart, + LineSeries, + ScaleType, + Settings, + Axis, + Position, + AnnotationDomainType, + LineAnnotation, + TooltipValue, +} from '@elastic/charts'; +import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiText, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { chartDefaultSettings, useTheme } from '../../../common/components/charts/common'; +import { useTimeZone } from '../../../common/lib/kibana'; +import { histogramDateTimeFormatter } from '../../../common/components/utils'; +import { HeaderSection } from '../../../common/components/header_section'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import * as i18n from './translations'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; +import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; +import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; + +export interface HostRiskScoreOverTimeProps { + hostName: string; + from: string; + to: string; +} + +const RISKY_TRESHOULD = 70; +const DEFAULT_CHART_HEIGH = 250; + +const StyledEuiText = styled(EuiText)` + font-size: 9px; + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + margin-right: ${({ theme }) => theme.eui.paddingSizes.xs}; +`; + +const LoadingChart = styled(EuiLoadingChart)` + display: block; + text-align: center; +`; + +const HostRiskScoreOverTimeComponent: React.FC = ({ + hostName, + from, + to, +}) => { + const timeZone = useTimeZone(); + + const memoizedDataTimeFormatter = useMemo( + () => histogramDateTimeFormatter([from, to]), + [from, to] + ); + const scoreFormatter = useCallback((d: number) => Math.round(d).toString(), []); + const headerFormatter = useCallback( + (tooltip: TooltipValue) => , + [] + ); + + const timerange = useMemo( + () => ({ + from, + to, + }), + [from, to] + ); + const theme = useTheme(); + + const hostRisk = useHostsRiskScore({ + hostName, + onlyLatest: false, + timerange, + queryId: HostRiskScoreQueryId.HOST_RISK_SCORE_OVER_TIME, + }); + + const data = useMemo( + () => + hostRisk?.result + ?.map((result) => ({ + x: result['@timestamp'], + y: result.risk_stats.risk_score, + })) + .reverse() ?? [], + [hostRisk] + ); + + return ( + + + + + + + + + + + + + + +
+ {hostRisk?.loading ? ( + + ) : ( + + + + + + + {i18n.RISKY} + + } + /> + + )} +
+
+
+
+
+ ); +}; + +HostRiskScoreOverTimeComponent.displayName = 'HostRiskScoreOverTimeComponent'; +export const HostRiskScoreOverTime = React.memo(HostRiskScoreOverTimeComponent); +HostRiskScoreOverTime.displayName = 'HostRiskScoreOverTime'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts new file mode 100644 index 000000000000000..5e1b4ca7410a835 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/host_score_over_time/translations.ts @@ -0,0 +1,33 @@ +/* + * 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 HOST_RISK_SCORE_OVER_TIME = i18n.translate( + 'xpack.securitySolution.hosts.hostScoreOverTime.title', + { + defaultMessage: 'Host risk score over time', + } +); + +export const HOST_RISK_THRESHOLD = i18n.translate( + 'xpack.securitySolution.hosts.hostScoreOverTime.riskyThresholdHeader', + { + defaultMessage: 'Risky threshold', + } +); + +export const RISKY = i18n.translate('xpack.securitySolution.hosts.hostScoreOverTime.riskyLabel', { + defaultMessage: 'Risky', +}); + +export const RISK_SCORE = i18n.translate( + 'xpack.securitySolution.hosts.hostScoreOverTime.riskScore', + { + defaultMessage: 'Risk score', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index f882e12d211d39b..9e7e01c64a43253 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -33,7 +33,10 @@ import { import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; import { useErrorToast } from '../../../../common/hooks/use_error_toast'; import { HostRiskScore } from '../../common/host_risk_score'; -import { HostRiskInformation, HOST_RISK_INFO_BUTTON_CLASS } from '../../host_risk_information'; +import { + HostRiskInformationButtonIcon, + HOST_RISK_INFO_BUTTON_CLASS, +} from '../../host_risk_information'; import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; const QUERY_ID = 'hostsKpiRiskyHostsQuery'; @@ -80,7 +83,7 @@ const RiskyHostsComponent: React.FC<{ - + {data?.inspect && ( diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx new file mode 100644 index 000000000000000..6315897fac1d2f4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { TopHostScoreContributors } from '.'; +import { TestProviders } from '../../../common/mock'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; + +jest.mock('../../../common/containers/hosts_risk/use_hosts_risk_score'); +const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock; + +describe('Host Risk Flyout', () => { + it('renders', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('topHostScoreContributors')).toBeInTheDocument(); + }); + + it('renders sorted items', () => { + useHostsRiskScoreMock.mockReturnValueOnce({ + loading: true, + isModuleEnabled: true, + result: [ + { + risk_stats: { + rule_risks: [ + { + rule_name: 'third', + rule_risk: '10', + }, + { + rule_name: 'first', + rule_risk: '99', + }, + { + rule_name: 'second', + rule_risk: '55', + }, + ], + }, + }, + ], + }); + + const { queryAllByRole } = render( + + + + ); + + expect(queryAllByRole('row')[1]).toHaveTextContent('first'); + expect(queryAllByRole('row')[2]).toHaveTextContent('second'); + expect(queryAllByRole('row')[3]).toHaveTextContent('third'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx new file mode 100644 index 000000000000000..84892da3b6e3cb3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx @@ -0,0 +1,125 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiInMemoryTable, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; + +import { HeaderSection } from '../../../common/components/header_section'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import * as i18n from './translations'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; +import { Direction } from '../../../../../timelines/common'; +import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; + +export interface TopHostScoreContributorsProps { + hostName: string; + from: string; + to: string; +} + +interface TableItem { + rank: number; + name: string; +} + +const columns: Array> = [ + { + name: i18n.RANK_TITLE, + field: 'rank', + width: '45px', + align: 'right', + }, + { + name: i18n.RULE_NAME_TITLE, + field: 'name', + sortable: true, + truncateText: true, + }, +]; + +const PAGE_SIZE = 5; + +const TopHostScoreContributorsComponent: React.FC = ({ + hostName, + from, + to, +}) => { + const timerange = useMemo( + () => ({ + from, + to, + }), + [from, to] + ); + + const hostRisk = useHostsRiskScore({ + hostName, + timerange, + onlyLatest: false, + queryId: HostRiskScoreQueryId.TOP_HOST_SCORE_CONTRIBUTORS, + limit: 1, + sortOrder: Direction.desc, + }); + + const result = hostRisk?.result; + + const items = useMemo(() => { + const rules = result && result.length > 0 ? result[0].risk_stats.rule_risks : []; + return rules + .sort((a, b) => b.rule_risk - a.rule_risk) + .map(({ rule_name: name }, i) => ({ rank: i + 1, name })); + }, [result]); + + const pagination = useMemo( + () => ({ + hidePerPageOptions: true, + pageSize: PAGE_SIZE, + totalItemCount: items.length, + }), + [items.length] + ); + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export const TopHostScoreContributors = React.memo(TopHostScoreContributorsComponent); +TopHostScoreContributors.displayName = 'TopHostScoreContributors'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts new file mode 100644 index 000000000000000..02017bf33d2da0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const TOP_RISK_SCORE_CONTRIBUTORS = i18n.translate( + 'xpack.securitySolution.hosts.topRiskScoreContributors.title', + { + defaultMessage: 'Top risk score contributors', + } +); + +export const RANK_TITLE = i18n.translate( + 'xpack.securitySolution.hosts.topRiskScoreContributors.rankColumnTitle', + { + defaultMessage: 'Rank', + } +); + +export const RULE_NAME_TITLE = i18n.translate( + 'xpack.securitySolution.hosts.topRiskScoreContributors.ruleNameColumnTitle', + { + defaultMessage: 'Rule name', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx index dc537f2f6ffe35c..891db470161d426 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx @@ -25,6 +25,7 @@ import { UncommonProcessQueryTabBody, EventsQueryTabBody, HostAlertsQueryTabBody, + HostRiskTabBody, } from '../navigation'; export const HostDetailsTabs = React.memo( @@ -102,6 +103,9 @@ export const HostDetailsTabs = React.memo( + + + ); } diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 2f89efa56b4d069..40ba3990ee9a7ec 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -53,6 +53,7 @@ import { ID, useHostDetails } from '../../containers/hosts/details'; import { manageQuery } from '../../../common/components/page/manage_query'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; const HostOverviewManage = manageQuery(HostOverview); @@ -119,6 +120,8 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta dispatch(setHostDetailsTablesActivePageToZero()); }, [dispatch, detailName]); + const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + return ( <> {indicesExist ? ( @@ -195,7 +198,11 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx index 872afc4e82440d8..90f3c223c5501f9 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx @@ -11,18 +11,29 @@ import { navTabsHostDetails } from './nav_tabs'; describe('navTabsHostDetails', () => { const mockHostName = 'mockHostName'; test('it should skip anomalies tab if without mlUserPermission', () => { - const tabs = navTabsHostDetails(mockHostName, false); + const tabs = navTabsHostDetails(mockHostName, false, false); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).not.toHaveProperty(HostsTableType.anomalies); expect(tabs).toHaveProperty(HostsTableType.events); + expect(tabs).not.toHaveProperty(HostsTableType.risk); }); test('it should display anomalies tab if with mlUserPermission', () => { - const tabs = navTabsHostDetails(mockHostName, true); + const tabs = navTabsHostDetails(mockHostName, true, false); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).toHaveProperty(HostsTableType.anomalies); expect(tabs).toHaveProperty(HostsTableType.events); + expect(tabs).not.toHaveProperty(HostsTableType.risk); + }); + + test('it should display risky hosts tab if when risky hosts is enabled', () => { + const tabs = navTabsHostDetails(mockHostName, false, true); + expect(tabs).toHaveProperty(HostsTableType.authentications); + expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); + expect(tabs).not.toHaveProperty(HostsTableType.anomalies); + expect(tabs).toHaveProperty(HostsTableType.events); + expect(tabs).toHaveProperty(HostsTableType.risk); }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx index 02f8fa740c024bf..c58fbde09aef1d4 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx @@ -16,8 +16,11 @@ const getTabsOnHostDetailsUrl = (hostName: string, tabName: HostsTableType) => export const navTabsHostDetails = ( hostName: string, - hasMlUserPermissions: boolean + hasMlUserPermissions: boolean, + isRiskyHostsEnabled: boolean ): HostDetailsNavTab => { + const hiddenTabs = []; + const hostDetailsNavTabs = { [HostsTableType.authentications]: { id: HostsTableType.authentications, @@ -49,9 +52,21 @@ export const navTabsHostDetails = ( href: getTabsOnHostDetailsUrl(hostName, HostsTableType.alerts), disabled: false, }, + [HostsTableType.risk]: { + id: HostsTableType.risk, + name: i18n.NAVIGATION_HOST_RISK_TITLE, + href: getTabsOnHostDetailsUrl(hostName, HostsTableType.risk), + disabled: false, + }, }; - return hasMlUserPermissions - ? hostDetailsNavTabs - : omit(HostsTableType.anomalies, hostDetailsNavTabs); + if (!hasMlUserPermissions) { + hiddenTabs.push(HostsTableType.anomalies); + } + + if (!isRiskyHostsEnabled) { + hiddenTabs.push(HostsTableType.risk); + } + + return omit(hiddenTabs, hostDetailsNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts b/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts index 3a584f7fefb5000..19975b6ad7abb71 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/utils.ts @@ -27,6 +27,7 @@ const TabNameMappedToI18nKey: Record = { [HostsTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE, [HostsTableType.events]: i18n.NAVIGATION_EVENTS_TITLE, [HostsTableType.alerts]: i18n.NAVIGATION_ALERTS_TITLE, + [HostsTableType.risk]: i18n.NAVIGATION_HOST_RISK_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx index 23be8f09ce1405d..64acbbc666312e3 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx @@ -30,6 +30,7 @@ const getHostDetailsTabPath = () => `${HostsTableType.uncommonProcesses}|` + `${HostsTableType.anomalies}|` + `${HostsTableType.events}|` + + `${HostsTableType.risk}|` + `${HostsTableType.alerts})`; export const HostsContainer = React.memo(() => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx new file mode 100644 index 000000000000000..6982047aa26ada0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx @@ -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. + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import { HostRiskScoreOverTime } from '../../components/host_score_over_time'; +import { TopHostScoreContributors } from '../../components/top_host_score_contributors'; +import { HostsComponentsQueryProps } from './types'; +import * as i18n from '../translations'; +import { useRiskyHostsDashboardButtonHref } from '../../../overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; +import { HostRiskInformationButtonEmpty } from '../../components/host_risk_information'; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; +`; + +const HostRiskTabBodyComponent: React.FC< + Pick & { hostName: string } +> = ({ hostName, startDate, endDate }) => { + const { buttonHref } = useRiskyHostsDashboardButtonHref(startDate, endDate); + + return ( + <> + + + + + + + + + + + + {i18n.VIEW_DASHBOARD_BUTTON} + + + + + + + + ); +}; + +HostRiskTabBodyComponent.displayName = 'HostRiskTabBodyComponent'; + +export const HostRiskTabBody = React.memo(HostRiskTabBodyComponent); + +HostRiskTabBody.displayName = 'HostRiskTabBody'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts index 09adfece7b01ed0..d5961cdc788e4db 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts @@ -10,3 +10,4 @@ export * from './events_query_tab_body'; export * from './hosts_query_tab_body'; export * from './uncommon_process_query_tab_body'; export * from './alerts_query_tab_body'; +export * from './host_risk_tab_body'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts index 5563dc285ad5a85..337f18ef335034a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/translations.ts @@ -57,6 +57,13 @@ export const NAVIGATION_ALERTS_TITLE = i18n.translate( } ); +export const NAVIGATION_HOST_RISK_TITLE = i18n.translate( + 'xpack.securitySolution.hosts.navigation.hostRisk', + { + defaultMessage: 'Host risk', + } +); + export const ERROR_FETCHING_AUTHENTICATIONS_DATA = i18n.translate( 'xpack.securitySolution.hosts.navigaton.matrixHistogram.errorFetchingAuthenticationsData', { @@ -76,3 +83,10 @@ export const EVENTS_UNIT = (totalCount: number) => values: { totalCount }, defaultMessage: `{totalCount, plural, =1 {event} other {events}}`, }); + +export const VIEW_DASHBOARD_BUTTON = i18n.translate( + 'xpack.securitySolution.hosts.navigaton.hostRisk.viewDashboardButtonLabel', + { + defaultMessage: 'View source dashboard', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts index 8c3a3e27ffb38bf..0d7df790f8f317c 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/helpers.test.ts @@ -36,6 +36,7 @@ export const mockHostsState: HostsModel = { activePage: 4, limit: DEFAULT_TABLE_LIMIT, }, + [HostsTableType.risk]: null, }, }, details: { @@ -63,6 +64,7 @@ export const mockHostsState: HostsModel = { activePage: 4, limit: DEFAULT_TABLE_LIMIT, }, + [HostsTableType.risk]: null, }, }, }; @@ -94,6 +96,7 @@ describe('Hosts redux store', () => { activePage: 0, limit: 10, }, + [HostsTableType.risk]: null, }); }); @@ -122,6 +125,7 @@ describe('Hosts redux store', () => { activePage: 0, limit: 10, }, + [HostsTableType.risk]: null, }); }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/store/model.ts b/x-pack/plugins/security_solution/public/hosts/store/model.ts index ea168e965fa2335..0a82d3961aa0207 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/model.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/model.ts @@ -20,6 +20,7 @@ export enum HostsTableType { uncommonProcesses = 'uncommonProcesses', anomalies = 'anomalies', alerts = 'externalAlerts', + risk = 'hostRisk', } export interface BasicQueryPaginated { @@ -39,6 +40,7 @@ export interface Queries { [HostsTableType.uncommonProcesses]: BasicQueryPaginated; [HostsTableType.anomalies]: null | undefined; [HostsTableType.alerts]: BasicQueryPaginated; + [HostsTableType.risk]: null | undefined; } export interface GenericHostsModel { diff --git a/x-pack/plugins/security_solution/public/hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/hosts/store/reducer.ts index eebf3ca1684a1de..171431144a746b0 100644 --- a/x-pack/plugins/security_solution/public/hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/hosts/store/reducer.ts @@ -51,6 +51,7 @@ export const initialHostsState: HostsState = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, }, + [HostsTableType.risk]: null, }, }, details: { @@ -78,6 +79,7 @@ export const initialHostsState: HostsState = { activePage: DEFAULT_TABLE_ACTIVE_PAGE, limit: DEFAULT_TABLE_LIMIT, }, + [HostsTableType.risk]: null, }, }, }; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx index 64829aab7776d0a..add96986d1d079b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx @@ -10,12 +10,16 @@ import React from 'react'; import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module'; import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; +import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; export interface RiskyHostLinksProps { timerange: { to: string; from: string }; } const RiskyHostLinksComponent: React.FC = ({ timerange }) => { - const hostRiskScore = useHostsRiskScore({ timerange }); + const hostRiskScore = useHostsRiskScore({ + timerange, + queryId: HostRiskScoreQueryId.OVERVIEW_RISKY_HOSTS, + }); switch (hostRiskScore?.isModuleEnabled) { case true: diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx index 364b608c6086deb..bc45faf1f05805d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx @@ -57,6 +57,7 @@ describe('RiskyHostsEnabledModule', () => { isModuleEnabled: true, result: [ { + '@timestamp': '1641902481', host: { name: 'a', }, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx index 8a42cedc3be461d..f508da6c1c99119 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx @@ -14,8 +14,8 @@ import { LinkPanelViewProps } from '../link_panel/types'; import { Link } from '../link_panel/link'; import * as i18n from './translations'; import { VIEW_DASHBOARD } from '../overview_cti_links/translations'; -import { QUERY_ID as RiskyHostsQueryId } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; import { NavigateToHost } from './navigate_to_host'; +import { HostRiskScoreQueryId } from '../../../common/containers/hosts_risk/types'; const columns: Array> = [ { @@ -94,7 +94,7 @@ export const RiskyHostsPanelView: React.FC = ({ dataTestSubj: 'risky-hosts-dashboard-links', defaultSortField: 'count', defaultSortOrder: 'desc', - inspectQueryId: isInspectEnabled ? RiskyHostsQueryId : undefined, + inspectQueryId: isInspectEnabled ? HostRiskScoreQueryId.OVERVIEW_RISKY_HOSTS : undefined, listItems, panelTitle: i18n.PANEL_TITLE, splitPanel: splitPanelElement, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts index 201d73c4ebb1852..7c00b7c0f8193b2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts @@ -6,15 +6,12 @@ */ import type { HostsKpiRiskyHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; -import { createQueryFilterClauses } from '../../../../../../utils/build_query'; export const buildHostsKpiRiskyHostsQuery = ({ - filterQuery, timerange: { from, to }, defaultIndex, }: HostsKpiRiskyHostsRequestOptions) => { const filter = [ - ...createQueryFilterClauses(filterQuery), { range: { '@timestamp': { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts index 182ad7892204f86..dc3d1e4b5d587c2 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts @@ -5,12 +5,16 @@ * 2.0. */ -import { HostsRiskScoreRequestOptions } from '../../../../../../common/search_strategy'; +import { Direction, HostsRiskScoreRequestOptions } from '../../../../../../common/search_strategy'; + +const QUERY_SIZE = 10; export const buildHostsRiskScoreQuery = ({ timerange, hostNames, defaultIndex, + limit = QUERY_SIZE, + sortOrder = Direction.desc, }: HostsRiskScoreRequestOptions) => { const filter = []; @@ -35,12 +39,20 @@ export const buildHostsRiskScoreQuery = ({ allow_no_indices: false, ignore_unavailable: true, track_total_hits: false, + size: limit, body: { query: { bool: { filter, }, }, + sort: [ + { + '@timestamp': { + order: sortOrder, + }, + }, + ], }, }; diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json index f9972f21eeb6429..cde819a836b0a31 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json @@ -22,3 +22,28 @@ } } } + +{ + "type":"doc", + "value":{ + "id":"a4cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f", + "index":"ml_host_risk_score_default", + "source":{ + "@timestamp":"2021-03-10T14:51:05.766Z", + "risk_stats": { + "risk_score": 21, + "rule_risks": [ + { + "rule_name": "Unusual Linux Username", + "rule_risk": 42 + } + ] + }, + "host":{ + "name":"siem-kibana" + }, + "ingest_timestamp":"2021-03-09T18:02:08.319296053Z", + "risk":"Low" + } + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json index 97d3288bf07b69c..02ceb5b5ebcccca 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json @@ -54,3 +54,61 @@ } } } + + +{ + "type": "index", + "value": { + "index": "ml_host_risk_score_default", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "keyword" + } + } + }, + "ingest_timestamp": { + "type": "date" + }, + "risk": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "risk_stats": { + "properties": { + "risk_score": { + "type": "long" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": "ml_host_risk_score_latest_default", + "rollover_alias": "ml_host_risk_score_latest_default" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "max_docvalue_fields_search": "200", + "number_of_replicas": "1", + "number_of_shards": "1", + "refresh_interval": "5s" + } + } + } +} From d6917fcb8be5bbb13196d7d8cae0923f636ae7df Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Wed, 19 Jan 2022 19:30:21 -0700 Subject: [PATCH 12/12] Fixes broken cypress test after ECS update to Rule Registry (#123429) ## Summary New ECS FieldMap was generated in https://github.com/elastic/kibana/pull/123012, however since it only contained changes to `Rule Registry` code the `Security Solution` Cypress tests were not run, and thus did not catch this field change. See https://github.com/elastic/kibana/pull/122661#discussion_r784412959 for details. Confirmed w/ @madirey that expected value is indeed `5` now that `host.geo.continent_code` has been [added](https://github.com/elastic/kibana/pull/123012/files#diff-a1647ccb73ef26c8c8b6aefd87084504b146af72fcb088ccacad93fcaad15b69R1524-R1528). Some failing PR's from `main`: https://github.com/elastic/kibana/pull/123357 https://github.com/elastic/kibana/pull/121644 https://github.com/elastic/kibana/pull/123352 ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../cypress/integration/timelines/fields_browser.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index df194136c6bb2c3..be726f0323d48cd 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -109,7 +109,7 @@ describe('Fields Browser', () => { filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '4'); + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should('have.text', '5'); }); });