diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts index 7f5e0b0c6f455..acd5ad468dd70 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts @@ -9,6 +9,11 @@ const createAlertsClientMock = () => { return { initializeExecution: jest.fn(), processAndLogAlerts: jest.fn(), + processAlerts: jest.fn(), + logAlerts: jest.fn(), + updateAlertMaintenanceWindowIds: jest.fn(), + getMaintenanceWindowScopedQueryAlerts: jest.fn(), + updateAlertsMaintenanceWindowIdByScopedQuery: jest.fn(), getTrackedAlerts: jest.fn(), getProcessedAlerts: jest.fn(), getAlertsToSerialize: jest.fn(), diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts index 86fe855152a9a..d00e1dfafb083 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts @@ -5,6 +5,7 @@ * 2.0. */ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import type { UpdateByQueryRequest } from '@elastic/elasticsearch/lib/api/types'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { AlertsFilter, @@ -46,7 +47,11 @@ import * as LegacyAlertsClientModule from './legacy_alerts_client'; import { LegacyAlertsClient } from './legacy_alerts_client'; import { Alert } from '../alert/alert'; import { AlertsClient, AlertsClientParams } from './alerts_client'; -import { GetSummarizedAlertsParams, ProcessAndLogAlertsOpts } from './types'; +import { + GetSummarizedAlertsParams, + ProcessAndLogAlertsOpts, + GetMaintenanceWindowScopedQueryAlertsParams, +} from './types'; import { legacyAlertsClientMock } from './legacy_alerts_client.mock'; import { keys, range } from 'lodash'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; @@ -58,9 +63,12 @@ import { getExpectedQueryByTimeRange, getParamsByExecutionUuid, getParamsByTimeQuery, + getParamsByMaintenanceWindowScopedQuery, + getParamsByUpdateMaintenanceWindowIds, mockAAD, } from './alerts_client_fixtures'; import { getDataStreamAdapter } from '../alerts_service/lib/data_stream_adapter'; +import { MaintenanceWindow } from '../application/maintenance_window/types'; const date = '2023-03-28T22:27:28.159Z'; const startedAtDate = '2023-03-28T13:00:00.000Z'; @@ -1666,6 +1674,258 @@ describe('Alerts Client', () => { }); }); + describe('getMaintenanceWindowScopedQueryAlerts', () => { + const alertWithMwId1 = { + ...mockAAD, + _id: 'alert_id_1', + _source: { + ...mockAAD._source, + [ALERT_UUID]: 'alert_id_1', + [ALERT_MAINTENANCE_WINDOW_IDS]: ['mw1', 'mw2'], + }, + }; + + const alertWithMwId2 = { + ...mockAAD, + _id: 'alert_id_2', + _source: { + ...mockAAD._source, + [ALERT_UUID]: 'alert_id_2', + [ALERT_MAINTENANCE_WINDOW_IDS]: ['mw1'], + }, + }; + + beforeEach(() => { + clusterClient.search.mockReturnValueOnce({ + // @ts-ignore + hits: { total: { value: 2 }, hits: [alertWithMwId1, alertWithMwId2] }, + aggregations: { + mw1: { + doc_count: 2, + alertId: { + hits: { + hits: [ + { + _id: 'alert_id_1', + _source: { [ALERT_UUID]: 'alert_id_1' }, + }, + { + _id: 'alert_id_2', + _source: { [ALERT_UUID]: 'alert_id_2' }, + }, + ], + }, + }, + }, + mw2: { + doc_count: 1, + alertId: { + hits: { + hits: [ + { + _id: 'alert_id_1', + _source: { [ALERT_UUID]: 'alert_id_1' }, + }, + ], + }, + }, + }, + }, + }); + }); + + test('should get the persistent lifecycle alerts affected by scoped query successfully', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + // @ts-ignore + const result = await alertsClient.getMaintenanceWindowScopedQueryAlerts( + getParamsByMaintenanceWindowScopedQuery + ); + + expect(result).toEqual({ + mw1: ['alert_id_1', 'alert_id_2'], + mw2: ['alert_id_1'], + }); + }); + + test('should get the persistent continual alerts affected by scoped query successfully', async () => { + const alertsClient = new AlertsClient({ + ...alertsClientParams, + ruleType: { + ...alertsClientParams.ruleType, + autoRecoverAlerts: false, + }, + }); + // @ts-ignore + const result = await alertsClient.getMaintenanceWindowScopedQueryAlerts( + getParamsByMaintenanceWindowScopedQuery + ); + + expect(result).toEqual({ + mw1: ['alert_id_1', 'alert_id_2'], + mw2: ['alert_id_1'], + }); + }); + + test('should throw if ruleId is not specified', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const { ruleId, ...paramsWithoutRuleId } = getParamsByMaintenanceWindowScopedQuery; + + await expect( + // @ts-ignore + alertsClient.getMaintenanceWindowScopedQueryAlerts( + paramsWithoutRuleId as GetMaintenanceWindowScopedQueryAlertsParams + ) + ).rejects.toThrowError( + 'Must specify rule ID, space ID, and executionUuid for scoped query AAD alert query.' + ); + }); + + test('should throw if spaceId is not specified', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const { spaceId, ...paramsWithoutRuleId } = getParamsByMaintenanceWindowScopedQuery; + + await expect( + // @ts-ignore + alertsClient.getMaintenanceWindowScopedQueryAlerts( + paramsWithoutRuleId as GetMaintenanceWindowScopedQueryAlertsParams + ) + ).rejects.toThrowError( + 'Must specify rule ID, space ID, and executionUuid for scoped query AAD alert query.' + ); + }); + + test('should throw if executionUuid is not specified', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const { executionUuid, ...paramsWithoutRuleId } = getParamsByMaintenanceWindowScopedQuery; + + await expect( + // @ts-ignore + alertsClient.getMaintenanceWindowScopedQueryAlerts( + paramsWithoutRuleId as GetMaintenanceWindowScopedQueryAlertsParams + ) + ).rejects.toThrowError( + 'Must specify rule ID, space ID, and executionUuid for scoped query AAD alert query.' + ); + }); + }); + + describe('updateAlertMaintenanceWindowIds', () => { + test('should update alerts with new maintenance window Ids', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + + const alert1 = new Alert('1', { meta: { maintenanceWindowIds: ['mw1'] } }); + const alert2 = new Alert('2', { meta: { maintenanceWindowIds: ['mw1', 'mw2'] } }); + const alert3 = new Alert('3', { meta: { maintenanceWindowIds: ['mw2', 'mw3'] } }); + + jest.spyOn(LegacyAlertsClient.prototype, 'getProcessedAlerts').mockReturnValueOnce({ + '1': alert1, + '2': alert2, + '3': alert3, + }); + + // @ts-ignore + await alertsClient.updateAlertMaintenanceWindowIds([ + alert1.getUuid(), + alert2.getUuid(), + alert3.getUuid(), + ]); + + const params = clusterClient.updateByQuery.mock.calls[0][0] as UpdateByQueryRequest; + + expect(params.query).toEqual({ + terms: { + _id: [alert1.getUuid(), alert2.getUuid(), alert3.getUuid()], + }, + }); + + expect(params.script).toEqual({ + source: expect.anything(), + lang: 'painless', + params: { + [alert1.getUuid()]: ['mw1'], + [alert2.getUuid()]: ['mw1', 'mw2'], + [alert3.getUuid()]: ['mw2', 'mw3'], + }, + }); + }); + + test('should call warn if ES errors', async () => { + clusterClient.updateByQuery.mockRejectedValueOnce('something went wrong!'); + const alertsClient = new AlertsClient(alertsClientParams); + + const alert1 = new Alert('1', { meta: { maintenanceWindowIds: ['mw1'] } }); + + jest.spyOn(LegacyAlertsClient.prototype, 'getProcessedAlerts').mockReturnValueOnce({ + '1': alert1, + }); + + await expect( + // @ts-ignore + alertsClient.updateAlertMaintenanceWindowIds([alert1.getUuid()]) + ).rejects.toBe('something went wrong!'); + + expect(logger.warn).toHaveBeenCalledWith( + 'Error updating alert maintenance window IDs: something went wrong!' + ); + }); + }); + + describe('updateAlertsMaintenanceWindowIdByScopedQuery', () => { + test('should update alerts with MW ids when provided with maintenance windows', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + + const alert1 = new Alert('1'); + const alert2 = new Alert('2'); + const alert3 = new Alert('3'); + const alert4 = new Alert('4'); + + jest.spyOn(LegacyAlertsClient.prototype, 'getProcessedAlerts').mockReturnValueOnce({ + '1': alert1, + '2': alert2, + '3': alert3, + '4': alert4, + }); + + jest + // @ts-ignore + .spyOn(AlertsClient.prototype, 'getMaintenanceWindowScopedQueryAlerts') + // @ts-ignore + .mockResolvedValueOnce({ + mw1: [alert1.getUuid(), alert2.getUuid()], + mw2: [alert3.getUuid()], + }); + + const updateSpy = jest + // @ts-ignore + .spyOn(AlertsClient.prototype, 'updateAlertMaintenanceWindowIds') + // @ts-ignore + .mockResolvedValueOnce({}); + + const result = await alertsClient.updateAlertsMaintenanceWindowIdByScopedQuery({ + ...getParamsByUpdateMaintenanceWindowIds, + maintenanceWindows: [ + ...getParamsByUpdateMaintenanceWindowIds.maintenanceWindows, + { id: 'mw3' } as unknown as MaintenanceWindow, + ], + }); + + expect(alert1.getMaintenanceWindowIds()).toEqual(['mw3', 'mw1']); + expect(alert2.getMaintenanceWindowIds()).toEqual(['mw3', 'mw1']); + expect(alert3.getMaintenanceWindowIds()).toEqual(['mw3', 'mw2']); + + expect(result).toEqual({ + alertIds: [alert1.getUuid(), alert2.getUuid(), alert3.getUuid()], + maintenanceWindowIds: ['mw3', 'mw1', 'mw2'], + }); + + expect(updateSpy).toHaveBeenLastCalledWith([ + alert1.getUuid(), + alert2.getUuid(), + alert3.getUuid(), + ]); + }); + }); + describe('report()', () => { test('should create legacy alert with id, action group', async () => { const mockGetUuidCurrent = jest diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index a01ea78612e8e..144a0203e4909 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -6,7 +6,13 @@ */ import { ElasticsearchClient } from '@kbn/core/server'; -import { ALERT_INSTANCE_ID, ALERT_RULE_UUID, ALERT_STATUS, ALERT_UUID } from '@kbn/rule-data-utils'; +import { + ALERT_INSTANCE_ID, + ALERT_RULE_UUID, + ALERT_STATUS, + ALERT_UUID, + ALERT_MAINTENANCE_WINDOW_IDS, +} from '@kbn/rule-data-utils'; import { chunk, flatMap, get, isEmpty, keys } from 'lodash'; import { SearchRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Alert } from '@kbn/alerts-as-data-utils'; @@ -15,6 +21,7 @@ import { DeepPartial } from '@kbn/utility-types'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { SummarizedAlerts, + ScopedQueryAlerts, AlertInstanceContext, AlertInstanceState, RuleAlertData, @@ -27,7 +34,7 @@ import { IIndexPatternString, } from '../alerts_service/resource_installer_utils'; import { CreateAlertsClientParams } from '../alerts_service/alerts_service'; -import type { AlertRule, SearchResult } from './types'; +import type { AlertRule, LogAlertsOpts, ProcessAlertsOpts, SearchResult } from './types'; import { IAlertsClient, InitializeExecutionOpts, @@ -37,6 +44,9 @@ import { ReportedAlertData, UpdateableAlert, GetSummarizedAlertsParams, + GetMaintenanceWindowScopedQueryAlertsParams, + UpdateAlertsMaintenanceWindowIdByScopedQueryParams, + ScopedQueryAggregationResult, } from './types'; import { buildNewAlert, @@ -45,7 +55,9 @@ import { buildRecoveredAlert, formatRule, getHitsWithCount, + getScopedQueryHitsWithIds, getLifecycleAlertsQueries, + getMaintenanceWindowAlertsQuery, getContinualAlertsQuery, } from './lib'; import { isValidAlertIndexName } from '../alerts_service'; @@ -185,20 +197,23 @@ export class AlertsClient< } } - public async search(queryBody: SearchRequest['body']): Promise> { + public async search( + queryBody: SearchRequest['body'] + ): Promise> { const esClient = await this.options.elasticsearchClientPromise; const index = this.isUsingDataStreams() ? this.indexTemplateAndPattern.alias : this.indexTemplateAndPattern.pattern; const { hits: { hits, total }, - } = await esClient.search({ + aggregations, + } = await esClient.search({ index, body: queryBody, ignore_unavailable: true, }); - return { hits, total }; + return { hits, total, aggregations }; } public report( @@ -265,6 +280,14 @@ export class AlertsClient< return this.legacyAlertsClient.checkLimitUsage(); } + public processAlerts(opts: ProcessAlertsOpts) { + this.legacyAlertsClient.processAlerts(opts); + } + + public logAlerts(opts: LogAlertsOpts) { + this.legacyAlertsClient.logAlerts(opts); + } + public processAndLogAlerts(opts: ProcessAndLogAlertsOpts) { this.legacyAlertsClient.processAndLogAlerts(opts); } @@ -540,6 +563,150 @@ export class AlertsClient< }; } + private async getMaintenanceWindowScopedQueryAlerts({ + ruleId, + spaceId, + executionUuid, + maintenanceWindows, + }: GetMaintenanceWindowScopedQueryAlertsParams): Promise { + if (!ruleId || !spaceId || !executionUuid) { + throw new Error( + `Must specify rule ID, space ID, and executionUuid for scoped query AAD alert query.` + ); + } + const isLifecycleAlert = this.ruleType.autoRecoverAlerts ?? false; + + const query = getMaintenanceWindowAlertsQuery({ + executionUuid, + ruleId, + maintenanceWindows, + action: isLifecycleAlert ? 'open' : undefined, + }); + + const response = await this.search(query); + + return getScopedQueryHitsWithIds(response.aggregations); + } + + private async updateAlertMaintenanceWindowIds(idsToUpdate: string[]) { + const esClient = await this.options.elasticsearchClientPromise; + const newAlerts = Object.values(this.legacyAlertsClient.getProcessedAlerts('new')); + + const params: Record = {}; + + idsToUpdate.forEach((id) => { + const newAlert = newAlerts.find((alert) => alert.getUuid() === id); + if (newAlert) { + params[id] = newAlert.getMaintenanceWindowIds(); + } + }); + + try { + const response = await esClient.updateByQuery({ + query: { + terms: { + _id: idsToUpdate, + }, + }, + conflicts: 'proceed', + index: this.indexTemplateAndPattern.alias, + script: { + source: ` + if (params.containsKey(ctx._source['${ALERT_UUID}'])) { + ctx._source['${ALERT_MAINTENANCE_WINDOW_IDS}'] = params[ctx._source['${ALERT_UUID}']]; + } + `, + lang: 'painless', + params, + }, + }); + return response; + } catch (err) { + this.options.logger.warn(`Error updating alert maintenance window IDs: ${err}`); + throw err; + } + } + + public async updateAlertsMaintenanceWindowIdByScopedQuery({ + ruleId, + spaceId, + executionUuid, + maintenanceWindows, + }: UpdateAlertsMaintenanceWindowIdByScopedQueryParams) { + const maintenanceWindowsWithScopedQuery = maintenanceWindows.filter( + ({ scopedQuery }) => scopedQuery + ); + const maintenanceWindowsWithoutScopedQuery = maintenanceWindows.filter( + ({ scopedQuery }) => !scopedQuery + ); + const maintenanceWindowsWithoutScopedQueryIds = maintenanceWindowsWithoutScopedQuery.map( + ({ id }) => id + ); + + if (maintenanceWindowsWithScopedQuery.length === 0) { + return { + alertIds: [], + maintenanceWindowIds: maintenanceWindowsWithoutScopedQueryIds, + }; + } + + // Run aggs to get all scoped query alert IDs, returns a record, + // indicating the maintenance window has matches a number of alerts with the scoped query. + const aggsResult = await this.getMaintenanceWindowScopedQueryAlerts({ + ruleId, + spaceId, + executionUuid, + maintenanceWindows: maintenanceWindowsWithScopedQuery, + }); + + const alertsAffectedByScopedQuery: string[] = []; + const appliedMaintenanceWindowIds: string[] = []; + + const newAlerts = Object.values(this.getProcessedAlerts('new')); + + for (const [scopedQueryMaintenanceWindowId, alertIds] of Object.entries(aggsResult)) { + // Go through matched alerts, find the in memory object + alertIds.forEach((alertId) => { + const newAlert = newAlerts.find((alert) => alert.getUuid() === alertId); + if (!newAlert) { + return; + } + + const newMaintenanceWindowIds = [ + // Keep existing Ids + ...newAlert.getMaintenanceWindowIds(), + // Add the ids that don't have scoped queries + ...maintenanceWindowsWithoutScopedQueryIds, + // Add the scoped query id + scopedQueryMaintenanceWindowId, + ]; + + // Update in memory alert with new maintenance window IDs + newAlert.setMaintenanceWindowIds([...new Set(newMaintenanceWindowIds)]); + + alertsAffectedByScopedQuery.push(newAlert.getUuid()); + appliedMaintenanceWindowIds.push(...newMaintenanceWindowIds); + }); + } + + const uniqueAlertsId = [...new Set(alertsAffectedByScopedQuery)]; + const uniqueMaintenanceWindowIds = [...new Set(appliedMaintenanceWindowIds)]; + + if (uniqueAlertsId.length) { + // Update alerts with new maintenance window IDs, await not needed + this.updateAlertMaintenanceWindowIds(uniqueAlertsId).catch(() => { + this.options.logger.debug( + 'Failed to update new alerts with scoped query maintenance window Ids by updateByQuery.' + ); + }); + } + + return { + alertIds: uniqueAlertsId, + maintenanceWindowIds: uniqueMaintenanceWindowIds, + }; + } + public client() { return { report: ( diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client_fixtures.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client_fixtures.ts index 4395df7217419..90c2f4c40b65b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client_fixtures.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client_fixtures.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { GetSummarizedAlertsParams } from './types'; +import { + GetSummarizedAlertsParams, + GetMaintenanceWindowScopedQueryAlertsParams, + UpdateAlertsMaintenanceWindowIdByScopedQueryParams, +} from './types'; +import type { MaintenanceWindow } from '../application/maintenance_window/types'; import { AlertRuleData } from '.'; import { AlertsFilter } from '../types'; @@ -76,6 +81,36 @@ export const getParamsByTimeQuery: GetSummarizedAlertsParams = { start: new Date('2023-09-06T00:00:00.000'), }; +export const getParamsByMaintenanceWindowScopedQuery: GetMaintenanceWindowScopedQueryAlertsParams = + { + ruleId: 'ruleId', + spaceId: 'default', + executionUuid: '111', + maintenanceWindows: [ + { + id: 'mw1', + categoryIds: ['management'], + scopedQuery: { + kql: "kibana.alert.rule.name: 'test123'", + filters: [], + dsl: '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match_phrase":{"kibana.alert.rule.name":"test123"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}', + }, + } as unknown as MaintenanceWindow, + { + id: 'mw2', + categoryIds: ['management'], + scopedQuery: { + kql: "kibana.alert.rule.name: 'test456'", + filters: [], + dsl: '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match_phrase":{"kibana.alert.rule.name":"test456"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}', + }, + } as unknown as MaintenanceWindow, + ], + }; + +export const getParamsByUpdateMaintenanceWindowIds: UpdateAlertsMaintenanceWindowIdByScopedQueryParams = + getParamsByMaintenanceWindowScopedQuery; + export const getExpectedQueryByExecutionUuid = ({ indexName, uuid = getParamsByExecutionUuid.executionUuid, diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts index fad2788d9f4f1..70ed0d415e8d1 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts @@ -11,6 +11,8 @@ const createLegacyAlertsClientMock = () => { processAndLogAlerts: jest.fn(), getTrackedAlerts: jest.fn(), getProcessedAlerts: jest.fn(), + processAlerts: jest.fn(), + logAlerts: jest.fn(), getAlertsToSerialize: jest.fn(), hasReachedAlertLimit: jest.fn(), checkLimitUsage: jest.fn(), diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts index b05d7aecf90ff..0de97a8a29be6 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts @@ -29,6 +29,8 @@ import { IAlertsClient, InitializeExecutionOpts, ProcessAndLogAlertsOpts, + ProcessAlertsOpts, + LogAlertsOpts, TrackedAlerts, } from './types'; import { DEFAULT_MAX_ALERTS } from '../config'; @@ -134,14 +136,11 @@ export class LegacyAlertsClient< return this.alertFactory?.get(id); } - public processAndLogAlerts({ - eventLogger, - ruleRunMetricsStore, - shouldLogAlerts, - flappingSettings, + public processAlerts({ notifyOnActionGroupChange, + flappingSettings, maintenanceWindowIds, - }: ProcessAndLogAlertsOpts) { + }: ProcessAlertsOpts) { const { newAlerts: processedAlertsNew, activeAlerts: processedAlertsActive, @@ -181,13 +180,15 @@ export class LegacyAlertsClient< this.processedAlerts.activeCurrent = alerts.currentActiveAlerts; this.processedAlerts.recovered = alerts.recoveredAlerts; this.processedAlerts.recoveredCurrent = alerts.currentRecoveredAlerts; + } + public logAlerts({ eventLogger, ruleRunMetricsStore, shouldLogAlerts }: LogAlertsOpts) { logAlerts({ logger: this.options.logger, alertingEventLogger: eventLogger, - newAlerts: alerts.newAlerts, - activeAlerts: alerts.currentActiveAlerts, - recoveredAlerts: alerts.currentRecoveredAlerts, + newAlerts: this.processedAlerts.new, + activeAlerts: this.processedAlerts.activeCurrent, + recoveredAlerts: this.processedAlerts.recoveredCurrent, ruleLogPrefix: this.ruleLogPrefix, ruleRunMetricsStore, canSetRecoveryContext: this.options.ruleType.doesSetRecoveryContext ?? false, @@ -195,6 +196,27 @@ export class LegacyAlertsClient< }); } + public processAndLogAlerts({ + eventLogger, + ruleRunMetricsStore, + shouldLogAlerts, + flappingSettings, + notifyOnActionGroupChange, + maintenanceWindowIds, + }: ProcessAndLogAlertsOpts) { + this.processAlerts({ + notifyOnActionGroupChange, + flappingSettings, + maintenanceWindowIds, + }); + + this.logAlerts({ + eventLogger, + ruleRunMetricsStore, + shouldLogAlerts, + }); + } + public getProcessedAlerts( type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' ) { diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/get_summarized_alerts_query.ts b/x-pack/plugins/alerting/server/alerts_client/lib/get_summarized_alerts_query.ts index 917a6dad803d7..2ae2962718748 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/get_summarized_alerts_query.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/get_summarized_alerts_query.ts @@ -9,7 +9,9 @@ import { QueryDslQueryContainer, SearchRequest, SearchTotalHits, + AggregationsAggregationContainer, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { BoolQuery } from '@kbn/es-query'; import { ALERT_END, ALERT_INSTANCE_ID, @@ -17,6 +19,7 @@ import { ALERT_RULE_EXECUTION_UUID, ALERT_RULE_UUID, ALERT_START, + ALERT_UUID, EVENT_ACTION, TIMESTAMP, } from '@kbn/rule-data-utils'; @@ -28,9 +31,12 @@ import { GetAlertsQueryParams, GetQueryByExecutionUuidParams, GetQueryByTimeRangeParams, + GetQueryByScopedQueriesParams, + GetMaintenanceWindowAlertsQueryParams, + ScopedQueryAggregationResult, SearchResult, } from '../types'; -import { SummarizedAlertsChunk } from '../..'; +import { SummarizedAlertsChunk, ScopedQueryAlerts } from '../..'; import { FormatAlert } from '../../types'; import { expandFlattenedAlert } from './format_alert'; @@ -270,6 +276,71 @@ const getQueryByTimeRange = ({ }; }; +export const getQueryByScopedQueries = ({ + executionUuid, + ruleId, + action, + maintenanceWindows, +}: GetQueryByScopedQueriesParams): SearchRequest['body'] => { + const filters: QueryDslQueryContainer[] = [ + { + term: { + [ALERT_RULE_EXECUTION_UUID]: executionUuid, + }, + }, + { + term: { + [ALERT_RULE_UUID]: ruleId, + }, + }, + ]; + + if (action) { + filters.push({ + term: { + [EVENT_ACTION]: action, + }, + }); + } + + const aggs: Record = {}; + + maintenanceWindows.forEach(({ id, scopedQuery }) => { + if (!scopedQuery) { + return; + } + + const scopedQueryFilter = generateAlertsFilterDSL({ + query: scopedQuery as AlertsFilter['query'], + })[0] as { bool: BoolQuery }; + + aggs[id] = { + filter: { + bool: { + ...scopedQueryFilter.bool, + filter: [...(scopedQueryFilter.bool?.filter || []), ...filters], + }, + }, + aggs: { + alertId: { + top_hits: { + size: 1, + _source: { + includes: [ALERT_UUID], + }, + }, + }, + }, + }; + }); + + return { + size: 0, + track_total_hits: true, + aggs: { ...aggs }, + }; +}; + const generateAlertsFilterDSL = (alertsFilter: AlertsFilter): QueryDslQueryContainer[] => { const filter: QueryDslQueryContainer[] = []; @@ -354,6 +425,20 @@ const getHitsWithCount = ( }; }; +const getScopedQueryHitsWithIds = ( + aggregationsResult: SearchResult['aggregations'] +): ScopedQueryAlerts => { + return Object.entries(aggregationsResult || {}).reduce( + (result, [maintenanceWindowId, aggregation]) => { + result[maintenanceWindowId] = (aggregation.alertId?.hits?.hits || []).map( + (hit) => hit._source[ALERT_UUID] + ); + return result; + }, + {} + ); +}; + const getLifecycleAlertsQueries = ({ executionUuid, start, @@ -412,4 +497,24 @@ const getContinualAlertsQuery = ({ return queryBody; }; -export { getHitsWithCount, getLifecycleAlertsQueries, getContinualAlertsQuery }; +const getMaintenanceWindowAlertsQuery = ({ + executionUuid, + ruleId, + action, + maintenanceWindows, +}: GetMaintenanceWindowAlertsQueryParams): SearchRequest['body'] => { + return getQueryByScopedQueries({ + executionUuid, + ruleId, + action, + maintenanceWindows, + }); +}; + +export { + getHitsWithCount, + getLifecycleAlertsQueries, + getContinualAlertsQuery, + getMaintenanceWindowAlertsQuery, + getScopedQueryHitsWithIds, +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts index 6e40e918a8b2c..8f209345ff508 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts @@ -14,6 +14,8 @@ export { getHitsWithCount, getLifecycleAlertsQueries, getContinualAlertsQuery, + getMaintenanceWindowAlertsQuery, + getScopedQueryHitsWithIds, } from './get_summarized_alerts_query'; export { expandFlattenedAlert } from './format_alert'; export { sanitizeBulkErrorResponse } from './sanitize_bulk_response'; diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts index 4b84b55d106cf..c5d02f1cd6f14 100644 --- a/x-pack/plugins/alerting/server/alerts_client/types.ts +++ b/x-pack/plugins/alerting/server/alerts_client/types.ts @@ -19,6 +19,7 @@ import { ALERT_RULE_TAGS, ALERT_RULE_TYPE_ID, ALERT_RULE_UUID, + ALERT_UUID, SPACE_IDS, } from '@kbn/rule-data-utils'; import { Alert as LegacyAlert } from '../alert/alert'; @@ -35,6 +36,7 @@ import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { RulesSettingsFlappingProperties } from '../../common/rules_settings'; import type { PublicAlertFactory } from '../alert/create_alert_factory'; +import { MaintenanceWindow } from '../application/maintenance_window/types'; export interface AlertRuleData { consumer: string; @@ -72,11 +74,19 @@ export interface IAlertsClient< hasReachedAlertLimit(): boolean; checkLimitUsage(): void; processAndLogAlerts(opts: ProcessAndLogAlertsOpts): void; + processAlerts(opts: ProcessAlertsOpts): void; + logAlerts(opts: LogAlertsOpts): void; getProcessedAlerts( type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' ): Record>; persistAlerts(): Promise; getSummarizedAlerts?(params: GetSummarizedAlertsParams): Promise; + updateAlertsMaintenanceWindowIdByScopedQuery?( + params: UpdateAlertsMaintenanceWindowIdByScopedQueryParams + ): Promise<{ + alertIds: string[]; + maintenanceWindowIds: string[]; + }>; getAlertsToSerialize(): { alertsToReturn: Record; recoveredAlertsToReturn: Record; @@ -103,6 +113,18 @@ export interface ProcessAndLogAlertsOpts { maintenanceWindowIds: string[]; } +export interface ProcessAlertsOpts { + flappingSettings: RulesSettingsFlappingProperties; + notifyOnActionGroupChange: boolean; + maintenanceWindowIds: string[]; +} + +export interface LogAlertsOpts { + eventLogger: AlertingEventLogger; + shouldLogAlerts: boolean; + ruleRunMetricsStore: RuleRunMetricsStore; +} + export interface InitializeExecutionOpts { maxAlerts: number; ruleLabel: string; @@ -168,10 +190,11 @@ export type UpdateableAlert< ActionGroupIds extends string > = Pick, 'id' | 'context' | 'payload'>; -export type SearchResult = Pick< - SearchResponseBody['hits'], - 'hits' | 'total' ->; +export interface SearchResult { + hits: SearchResponseBody['hits']['hits']; + total: SearchResponseBody['hits']['total']; + aggregations: SearchResponseBody['aggregations']; +} export type GetSummarizedAlertsParams = { ruleId: string; @@ -183,6 +206,16 @@ export type GetSummarizedAlertsParams = { | { executionUuid: string; start?: never; end?: never } ); +export interface GetMaintenanceWindowScopedQueryAlertsParams { + ruleId: string; + spaceId: string; + maintenanceWindows: MaintenanceWindow[]; + executionUuid: string; +} + +export type UpdateAlertsMaintenanceWindowIdByScopedQueryParams = + GetMaintenanceWindowScopedQueryAlertsParams; + export type GetAlertsQueryParams = Omit< GetSummarizedAlertsParams, 'formatAlert' | 'isLifecycleAlert' | 'spaceId' @@ -200,6 +233,20 @@ export interface GetQueryByExecutionUuidParams action?: string; } +export interface GetQueryByScopedQueriesParams { + ruleId: string; + executionUuid: string; + maintenanceWindows: MaintenanceWindow[]; + action?: string; +} + +export interface GetMaintenanceWindowAlertsQueryParams { + ruleId: string; + maintenanceWindows: MaintenanceWindow[]; + executionUuid: string; + action?: string; +} + export interface GetLifecycleAlertsQueryByTimeRangeParams { start: Date; end: Date; @@ -212,3 +259,20 @@ export interface GetQueryByTimeRangeParams extends GetLifecycleAlertsQueryByTimeRangeParams { type?: AlertTypes; } + +export type ScopedQueryAggregationResult = Record< + string, + { + doc_count: number; + alertId: { + hits: { + hits: Array<{ + _id: string; + _source: { + [ALERT_UUID]: string; + }; + }>; + }; + }; + } +>; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 13237f92b1c1d..04c48ab77c8aa 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -28,6 +28,7 @@ export type { AlertingApiRequestHandlerContext, RuleParamsAndRefs, SummarizedAlertsChunk, + ScopedQueryAlerts, ExecutorType, IRuleTypeAlerts, GetViewInAppRelativeUrlFnOpts, diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index 0c032dd6a3e92..2fbb7132e0a6f 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -14,6 +14,7 @@ import { } from '@kbn/actions-plugin/server/mocks'; import { KibanaRequest } from '@kbn/core/server'; import { ActionsCompletion } from '@kbn/alerting-state-types'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; import { InjectActionParamsOpts, injectActionParams } from './inject_action_params'; import { NormalizedRuleType } from '../rule_type_registry'; import { @@ -1699,19 +1700,44 @@ describe('Execution Handler', () => { ); }); - test('does not schedule summary actions when there is an active maintenance window', async () => { + test('does not schedule summary actions when there are alerts with MW ids in memory', async () => { + const newAlert1 = generateAlert({ + id: 1, + maintenanceWindowIds: ['mw1'], + }); + + const newAlert2 = generateAlert({ + id: 2, + maintenanceWindowIds: ['mw2'], + }); + + // The alerts that come back from getSummarizedAlerts might not have + // the MW Ids attached yet, due to lack of refresh: true in the + // update call to update the alert MW Ids + const newAlert1AAD = { + ...mockAAD, + [ALERT_UUID]: newAlert1[1].getUuid(), + }; + + const newAlert2AAD = { + ...mockAAD, + [ALERT_UUID]: newAlert2[2].getUuid(), + }; + alertsClient.getSummarizedAlerts.mockResolvedValue({ new: { count: 2, - data: [ - { ...mockAAD, kibana: { alert: { uuid: '1' } } }, - { ...mockAAD, kibana: { alert: { uuid: '2' } } }, - ], + data: [newAlert1AAD, newAlert2AAD], }, ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); + alertsClient.getProcessedAlerts.mockReturnValue({ + '1': newAlert1[1], + '2': newAlert2[2], + }); + const executionHandler = new ExecutionHandler( generateExecutionParams({ rule: { @@ -1746,15 +1772,60 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(2); + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( 1, - '(1) alert has been filtered out for: testActionTypeId:1' + '(3) alerts have been filtered out for: testActionTypeId:1' + ); + }); + + test('does not schedule summary actions when there is an active maintenance window', async () => { + alertsClient.getSummarizedAlerts.mockResolvedValue({ + new: { count: 0, data: [] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }); + + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + mutedInstanceIds: ['foo'], + actions: [ + { + uuid: '1', + id: '1', + group: null, + actionTypeId: 'testActionTypeId', + frequency: { + summary: true, + notifyWhen: 'onActiveAlert', + throttle: null, + }, + params: { + message: + 'New: {{alerts.new.count}} Ongoing: {{alerts.ongoing.count}} Recovered: {{alerts.recovered.count}}', + }, + }, + ], + }, + maintenanceWindowIds: ['test-id-active'], + }) ); + + await executionHandler.run({ + ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), + ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), + ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), + }); + + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( - 2, - 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-active.' + 1, + '(3) alerts have been filtered out for: testActionTypeId:1' ); }); @@ -1770,17 +1841,14 @@ describe('Execution Handler', () => { expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(3); - expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( - 1, - 'no scheduling of actions "1" for rule "1": has active maintenance windows test-id-1.' + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-1.' ); - expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( - 2, - 'no scheduling of actions "1" for rule "1": has active maintenance windows test-id-2.' + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-2.' ); - expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( - 3, - 'no scheduling of actions "1" for rule "1": has active maintenance windows test-id-3.' + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-3.' ); }); diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index dd1caa21a240c..e5fbe92238a0c 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -7,7 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Logger } from '@kbn/core/server'; -import { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; +import { ALERT_UUID, getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; import { @@ -119,7 +119,6 @@ export class ExecutionHandler< private ruleTypeActionGroups?: Map; private mutedAlertIdsSet: Set = new Set(); private previousStartedAt: Date | null; - private maintenanceWindowIds: string[] = []; private alertsClient: IAlertsClient< AlertData, State, @@ -142,7 +141,6 @@ export class ExecutionHandler< ruleLabel, previousStartedAt, actionsClient, - maintenanceWindowIds, alertsClient, }: ExecutionHandlerOptions< Params, @@ -172,7 +170,6 @@ export class ExecutionHandler< ); this.previousStartedAt = previousStartedAt; this.mutedAlertIdsSet = new Set(rule.mutedInstanceIds); - this.maintenanceWindowIds = maintenanceWindowIds ?? []; this.alertsClient = alertsClient; } @@ -577,9 +574,9 @@ export class ExecutionHandler< throttledSummaryActions: ThrottledActions ): Promise>> { const executables = []; - for (const action of this.rule.actions) { const alertsArray = Object.entries(alerts); + let summarizedAlerts = null; if (this.shouldGetSummarizedAlerts({ action, throttledSummaryActions })) { summarizedAlerts = await this.getSummarizedAlerts({ @@ -596,16 +593,7 @@ export class ExecutionHandler< } } - // By doing that we are not cancelling the summary action but just waiting - // for the window maintenance to be over before sending the summary action - if (isSummaryAction(action) && this.maintenanceWindowIds.length > 0) { - this.logger.debug( - `no scheduling of summary actions "${action.id}" for rule "${ - this.taskInstance.params.alertId - }": has active maintenance windows ${this.maintenanceWindowIds.join()}.` - ); - continue; - } else if (isSummaryAction(action)) { + if (isSummaryAction(action)) { if (summarizedAlerts && summarizedAlerts.all.count !== 0) { executables.push({ action, summarizedAlerts }); } @@ -613,19 +601,20 @@ export class ExecutionHandler< } for (const [alertId, alert] of alertsArray) { - if (alert.isFilteredOut(summarizedAlerts)) { - continue; - } - - if (alert.getMaintenanceWindowIds().length > 0) { + const alertMaintenanceWindowIds = alert.getMaintenanceWindowIds(); + if (alertMaintenanceWindowIds.length !== 0) { this.logger.debug( - `no scheduling of actions "${action.id}" for rule "${ + `no scheduling of summary actions "${action.id}" for rule "${ this.taskInstance.params.alertId - }": has active maintenance windows ${alert.getMaintenanceWindowIds().join()}.` + }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` ); continue; } + if (alert.isFilteredOut(summarizedAlerts)) { + continue; + } + const actionGroup = this.getActionGroup(alert); if (!this.ruleTypeActionGroups!.has(actionGroup)) { @@ -742,12 +731,40 @@ export class ExecutionHandler< } const alerts = await this.alertsClient.getSummarizedAlerts!(options); - const total = alerts.new.count + alerts.ongoing.count + alerts.recovered.count; + /** + * We need to remove all new alerts with maintenance windows retrieved from + * getSummarizedAlerts because they might not have maintenance window IDs + * associated with them from maintenance windows with scoped query updated + * yet (the update call uses refresh: false). So we need to rely on the in + * memory alerts to do this. + */ + const newAlertsInMemory = + Object.values(this.alertsClient.getProcessedAlerts('new') || {}) || []; + + const newAlertsWithMaintenanceWindowIds = newAlertsInMemory.reduce( + (result, alert) => { + if (alert.getMaintenanceWindowIds().length > 0) { + result.push(alert.getUuid()); + } + return result; + }, + [] + ); + + const newAlerts = alerts.new.data.filter((alert) => { + return !newAlertsWithMaintenanceWindowIds.includes(alert[ALERT_UUID]); + }); + + const total = newAlerts.length + alerts.ongoing.count + alerts.recovered.count; return { ...alerts, + new: { + count: newAlerts.length, + data: newAlerts, + }, all: { count: total, - data: [...alerts.new.data, ...alerts.ongoing.data, ...alerts.recovered.data], + data: [...newAlerts, ...alerts.ongoing.data, ...alerts.recovered.data], }, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 490584e03c87f..c1c4b442de13e 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -436,7 +436,7 @@ export const generateEnqueueFunctionInput = ({ }; export const generateAlertInstance = ( - { id, duration, start, flappingHistory, actions }: GeneratorParams = { + { id, duration, start, flappingHistory, actions, maintenanceWindowIds }: GeneratorParams = { id: 1, flappingHistory: [false], } @@ -451,7 +451,7 @@ export const generateAlertInstance = ( }, flappingHistory, flapping: false, - maintenanceWindowIds: [], + maintenanceWindowIds: maintenanceWindowIds || [], pendingRecoveredCount: 0, }, state: { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index c066ac73f0ac0..ee55378d08795 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -798,6 +798,71 @@ describe('Task Runner', () => { expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); + test('should update alerts with maintenance window if scoped query matches said alerts', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + ruleType.executor.mockImplementation(async () => { + return { state: {} }; + }); + + const mockMaintenanceWindows = [ + { + ...getMockMaintenanceWindow(), + categoryIds: ['test'] as unknown as MaintenanceWindow['categoryIds'], + id: 'test-id-1', + } as unknown as MaintenanceWindow, + { + ...getMockMaintenanceWindow(), + categoryIds: ['test'] as unknown as MaintenanceWindow['categoryIds'], + scopedQuery: { + kql: "kibana.alert.rule.name: 'test123'", + filters: [], + dsl: '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match_phrase":{"kibana.alert.rule.name":"test123"}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}', + }, + id: 'test-id-2', + } as unknown as MaintenanceWindow, + ]; + + maintenanceWindowClient.getActiveMaintenanceWindows.mockResolvedValueOnce( + mockMaintenanceWindows + ); + + alertsClient.updateAlertsMaintenanceWindowIdByScopedQuery.mockResolvedValue({ + alertIds: [], + maintenanceWindowIds: ['test-id-1', 'test-id-2'], + }); + + alertsService.createAlertsClient.mockImplementation(() => alertsClient); + + const taskRunner = new TaskRunner({ + ruleType, + taskInstance: mockedTaskInstance, + context: taskRunnerFactoryInitializerParams, + inMemoryMetrics, + }); + + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(mockedRawRuleSO); + await taskRunner.run(); + expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); + + expect(alertsClient.updateAlertsMaintenanceWindowIdByScopedQuery).toHaveBeenLastCalledWith({ + executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + maintenanceWindows: mockMaintenanceWindows, + ruleId: '1', + spaceId: 'default', + }); + + expect(alertingEventLogger.setMaintenanceWindowIds).toHaveBeenCalledWith(['test-id-1']); + expect(alertingEventLogger.setMaintenanceWindowIds).toHaveBeenCalledWith([ + 'test-id-1', + 'test-id-2', + ]); + }); + test.each(ephemeralTestParams)( 'skips firing actions for active alert if alert is muted %s', async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 76d12d820fa23..4a1245f29ee82 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -422,22 +422,29 @@ export class TaskRunner< ); } - const maintenanceWindowIds = activeMaintenanceWindows - .filter(({ categoryIds }) => { - // If category IDs array doesn't exist: allow all - if (!Array.isArray(categoryIds)) { - return true; - } - // If category IDs array exist: check category - if ((categoryIds as string[]).includes(ruleType.category)) { - return true; - } - return false; - }) - .map(({ id }) => id); + const maintenanceWindows = activeMaintenanceWindows.filter(({ categoryIds }) => { + // If category IDs array doesn't exist: allow all + if (!Array.isArray(categoryIds)) { + return true; + } + // If category IDs array exist: check category + if ((categoryIds as string[]).includes(ruleType.category)) { + return true; + } + return false; + }); - if (maintenanceWindowIds.length) { - this.alertingEventLogger.setMaintenanceWindowIds(maintenanceWindowIds); + const maintenanceWindowsWithoutScopedQuery = maintenanceWindows.filter( + ({ scopedQuery }) => !scopedQuery + ); + + const maintenanceWindowsWithoutScopedQueryIds = maintenanceWindowsWithoutScopedQuery.map( + ({ id }) => id + ); + + // Set the event log MW Id field the first time with MWs without scoped queries + if (maintenanceWindowsWithoutScopedQuery.length) { + this.alertingEventLogger.setMaintenanceWindowIds(maintenanceWindowsWithoutScopedQueryIds); } const { updatedRuleTypeState } = await this.timer.runWithTimer( @@ -520,7 +527,9 @@ export class TaskRunner< }, logger: this.logger, flappingSettings, - ...(maintenanceWindowIds.length ? { maintenanceWindowIds } : {}), + ...(maintenanceWindowsWithoutScopedQueryIds.length + ? { maintenanceWindowIds: maintenanceWindowsWithoutScopedQueryIds } + : {}), getTimeRange: (timeWindow) => getTimeRange(this.logger, queryDelaySettings, timeWindow), }) @@ -563,15 +572,12 @@ export class TaskRunner< ); await this.timer.runWithTimer(TaskRunnerTimerSpan.ProcessAlerts, async () => { - alertsClient.processAndLogAlerts({ - eventLogger: this.alertingEventLogger, - ruleRunMetricsStore, - shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts(), + alertsClient.processAlerts({ flappingSettings, notifyOnActionGroupChange: notifyWhen === RuleNotifyWhen.CHANGE || some(actions, (action) => action.frequency?.notifyWhen === RuleNotifyWhen.CHANGE), - maintenanceWindowIds, + maintenanceWindowIds: maintenanceWindowsWithoutScopedQueryIds, }); }); @@ -579,6 +585,36 @@ export class TaskRunner< await alertsClient.persistAlerts(); }); + let updateAlertsMaintenanceWindowResult = null; + + try { + updateAlertsMaintenanceWindowResult = + await alertsClient.updateAlertsMaintenanceWindowIdByScopedQuery?.({ + ruleId: rule.id, + spaceId, + executionUuid: this.executionId, + maintenanceWindows, + }); + } catch (e) { + this.logger.debug( + `Failed to update alert matched by maintenance window scoped query for rule ${ruleLabel}.` + ); + } + + // Set the event log MW ids again, this time including the ids that matched alerts with + // scoped query + if (updateAlertsMaintenanceWindowResult?.maintenanceWindowIds) { + this.alertingEventLogger.setMaintenanceWindowIds( + updateAlertsMaintenanceWindowResult.maintenanceWindowIds + ); + } + + alertsClient.logAlerts({ + eventLogger: this.alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts(), + }); + const executionHandler = new ExecutionHandler({ rule, ruleType: this.ruleType, @@ -593,7 +629,6 @@ export class TaskRunner< previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null, alertingEventLogger: this.alertingEventLogger, actionsClient: await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest), - maintenanceWindowIds, alertsClient, }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts index d38736bb84905..2cf4174d7bb28 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts @@ -774,19 +774,24 @@ describe('Task Runner', () => { expect(alertsClientToUse.checkLimitUsage).toHaveBeenCalled(); expect(alertsClientNotToUse.checkLimitUsage).not.toHaveBeenCalled(); - expect(alertsClientToUse.processAndLogAlerts).toHaveBeenCalledWith({ - eventLogger: alertingEventLogger, - ruleRunMetricsStore, - shouldLogAlerts: true, + expect(alertsClientToUse.processAlerts).toHaveBeenCalledWith({ + notifyOnActionGroupChange: false, flappingSettings: { enabled: true, lookBackWindow: 20, statusChangeThreshold: 4, }, - notifyOnActionGroupChange: false, maintenanceWindowIds: [], }); - expect(alertsClientNotToUse.processAndLogAlerts).not.toHaveBeenCalled(); + + expect(alertsClientToUse.logAlerts).toHaveBeenCalledWith({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: true, + }); + + expect(alertsClientNotToUse.processAlerts).not.toHaveBeenCalled(); + expect(alertsClientNotToUse.logAlerts).not.toHaveBeenCalled(); expect(alertsClientToUse.persistAlerts).toHaveBeenCalled(); expect(alertsClientNotToUse.persistAlerts).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 958ec20fe0136..33417bcfe9c44 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -87,7 +87,6 @@ export interface ExecutionHandlerOptions< ruleLabel: string; previousStartedAt: Date | null; actionsClient: PublicMethodsOf; - maintenanceWindowIds?: string[]; alertsClient: IAlertsClient; } diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index d5e0b331833d5..4867adaf9b9a0 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -179,6 +179,8 @@ export interface SummarizedAlertsChunk { data: AlertHit[]; } +export type ScopedQueryAlerts = Record; + export interface SummarizedAlerts { new: SummarizedAlertsChunk; ongoing: SummarizedAlertsChunk; diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts index 8bd5ebfad1b21..34367ce2d8b4d 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts @@ -552,7 +552,7 @@ function getPatternFiringAlertsAsDataRuleType() { id: 'test.patternFiringAad', name: 'Test: Firing on a Pattern and writing Alerts as Data', actionGroups: [{ id: 'default', name: 'Default' }], - category: 'kibana', + category: 'management', producer: 'alertsFixture', defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/index.ts index 0b8e43039c33a..47157e8d55d05 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/index.ts @@ -16,5 +16,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./builtin_alert_types')); loadTestFile(require.resolve('./maintenance_window_flows')); + loadTestFile(require.resolve('./maintenance_window_scoped_query')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_flows.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_flows.ts index ac92bb080f550..d0b8c7e8825a2 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_flows.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_flows.ts @@ -5,12 +5,19 @@ * 2.0. */ -import { IValidatedEvent } from '@kbn/event-log-plugin/server'; -import moment from 'moment'; import expect from '@kbn/expect'; -import { Spaces } from '../../../scenarios'; -import { getUrlPrefix, getTestRuleData, ObjectRemover, getEventLog } from '../../../../common/lib'; +import { ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + createRule, + createAction, + createMaintenanceWindow, + getActiveMaintenanceWindows, + finishMaintenanceWindow, + getRuleEvents, + expectNoActionsFired, + runSoon, +} from './test_helpers'; // eslint-disable-next-line import/no-default-export export default function maintenanceWindowFlowsTests({ getService }: FtrProviderContext) { @@ -31,45 +38,78 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC }; // Create action and rule - const action = await createAction(); - const rule = await createRule({ actionId: action.id, pattern }); + const action = await createAction({ + supertest, + objectRemover, + }); + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + }); // Run the first time - active await getRuleEvents({ id: rule.id, action: 1, activeInstance: 1, + retry, + getService, }); // Run again - active, 2 action - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 2, activeInstance: 2, + retry, + getService, }); // Create active maintenance window - const maintenanceWindow = await createMaintenanceWindow(); - const activeMaintenanceWindows = await getActiveMaintenanceWindows(); + const maintenanceWindow = await createMaintenanceWindow({ + supertest, + objectRemover, + }); + const activeMaintenanceWindows = await getActiveMaintenanceWindows({ + supertest, + }); expect(activeMaintenanceWindows[0].id).eql(maintenanceWindow.id); // Run again - recovered, 3 actions, fired in MW - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 3, activeInstance: 2, recoveredInstance: 1, + retry, + getService, }); // Run again - active, 3 actions, new active action NOT fired in MW - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 3, activeInstance: 3, recoveredInstance: 1, + retry, + getService, }); }); @@ -79,36 +119,99 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC }; // Create active maintenance window - const maintenanceWindow = await createMaintenanceWindow(); - const activeMaintenanceWindows = await getActiveMaintenanceWindows(); + const maintenanceWindow = await createMaintenanceWindow({ + supertest, + objectRemover, + }); + const activeMaintenanceWindows = await getActiveMaintenanceWindows({ + supertest, + }); expect(activeMaintenanceWindows[0].id).eql(maintenanceWindow.id); // Create action and rule - const action = await createAction(); - const rule = await createRule({ actionId: action.id, pattern }); + const action = await createAction({ + supertest, + objectRemover, + }); + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + }); // Run the first time - active - await getRuleEvents({ id: rule.id, activeInstance: 1 }); + await getRuleEvents({ + id: rule.id, + activeInstance: 1, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // Run again - active - await runSoon(rule.id); - await getRuleEvents({ id: rule.id, activeInstance: 2 }); + await runSoon({ + id: rule.id, + supertest, + retry, + }); + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // Run again - recovered - await runSoon(rule.id); - await getRuleEvents({ id: rule.id, activeInstance: 2, recoveredInstance: 1 }); + await runSoon({ + id: rule.id, + supertest, + retry, + }); + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + recoveredInstance: 1, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // Run again - active again - await runSoon(rule.id); - await getRuleEvents({ id: rule.id, activeInstance: 3, recoveredInstance: 1 }); + await runSoon({ + id: rule.id, + supertest, + retry, + }); + await getRuleEvents({ + id: rule.id, + activeInstance: 3, + recoveredInstance: 1, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); }); it('alerts triggered within a MW should not fire actions if active or recovered outside a MW', async () => { @@ -117,43 +220,103 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC }; // Create active maintenance window - const maintenanceWindow = await createMaintenanceWindow(); - const activeMaintenanceWindows = await getActiveMaintenanceWindows(); + const maintenanceWindow = await createMaintenanceWindow({ + supertest, + objectRemover, + }); + const activeMaintenanceWindows = await getActiveMaintenanceWindows({ + supertest, + }); expect(activeMaintenanceWindows[0].id).eql(maintenanceWindow.id); // Create action and rule - const action = await createAction(); - const rule = await createRule({ actionId: action.id, pattern }); + const action = await createAction({ + supertest, + objectRemover, + }); + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + }); // Run the first time - active - await getRuleEvents({ id: rule.id, activeInstance: 1 }); + await getRuleEvents({ + id: rule.id, + activeInstance: 1, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // End the maintenance window - await finishMaintenanceWindow(maintenanceWindow.id); - const empty = await getActiveMaintenanceWindows(); + await finishMaintenanceWindow({ + id: maintenanceWindow.id, + supertest, + }); + const empty = await getActiveMaintenanceWindows({ + supertest, + }); expect(empty).eql([]); // Run again - active - await runSoon(rule.id); - await getRuleEvents({ id: rule.id, activeInstance: 2 }); + await runSoon({ + id: rule.id, + supertest, + retry, + }); + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // Run again - recovered - await runSoon(rule.id); - await getRuleEvents({ id: rule.id, activeInstance: 2, recoveredInstance: 1 }); + await runSoon({ + id: rule.id, + supertest, + retry, + }); + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + recoveredInstance: 1, + retry, + getService, + }); - await expectNoActionsFired(rule.id); + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); // Run again - active again, this time fire the action since its a new alert instance - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 1, activeInstance: 3, recoveredInstance: 1, + retry, + getService, }); }); @@ -164,208 +327,78 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC // Create active maintenance window const maintenanceWindow = await createMaintenanceWindow({ - category_ids: ['observability'], + overwrites: { + category_ids: ['observability'], + }, + supertest, + objectRemover, + }); + const activeMaintenanceWindows = await getActiveMaintenanceWindows({ + supertest, }); - const activeMaintenanceWindows = await getActiveMaintenanceWindows(); expect(activeMaintenanceWindows[0].id).eql(maintenanceWindow.id); expect(activeMaintenanceWindows[0].category_ids).eql(['observability']); // Create action and rule - const action = await createAction(); - const rule = await createRule({ actionId: action.id, pattern }); + const action = await await createAction({ + supertest, + objectRemover, + }); + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + }); // Run 4 times - firing each time await getRuleEvents({ id: rule.id, action: 1, activeInstance: 1, + retry, + getService, + }); + await runSoon({ + id: rule.id, + supertest, + retry, }); - await runSoon(rule.id); await getRuleEvents({ id: rule.id, action: 2, activeInstance: 2, + retry, + getService, }); - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 3, activeInstance: 2, recoveredInstance: 1, + retry, + getService, }); - await runSoon(rule.id); + await runSoon({ + id: rule.id, + supertest, + retry, + }); await getRuleEvents({ id: rule.id, action: 4, activeInstance: 3, recoveredInstance: 1, + retry, + getService, }); }); - - // Helper functions: - async function createRule({ - actionId, - pattern, - }: { - actionId: string; - pattern: { instance: boolean[] }; - }) { - const { body: createdRule } = await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) - .set('kbn-xsrf', 'foo') - .send( - getTestRuleData({ - name: 'test-rule', - rule_type_id: 'test.patternFiring', - schedule: { interval: '24h' }, - throttle: null, - notify_when: 'onActiveAlert', - params: { - pattern, - }, - actions: [ - { - id: actionId, - group: 'default', - params: {}, - }, - { - id: actionId, - group: 'recovered', - params: {}, - }, - ], - }) - ) - .expect(200); - - objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); - return createdRule; - } - - async function createAction() { - const { body: createdAction } = await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) - .set('kbn-xsrf', 'foo') - .send({ - name: 'MY action', - connector_type_id: 'test.noop', - config: {}, - secrets: {}, - }) - .expect(200); - - objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - return createdAction; - } - - async function createMaintenanceWindow(overwrites?: any) { - const { body: window } = await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/maintenance_window`) - .set('kbn-xsrf', 'foo') - .send({ - title: 'test-maintenance-window-1', - duration: 60 * 60 * 1000, // 1 hr - r_rule: { - dtstart: moment.utc().toISOString(), - tzid: 'UTC', - freq: 0, // yearly - count: 1, - }, - ...overwrites, - }) - .expect(200); - - objectRemover.add(Spaces.space1.id, window.id, 'rules/maintenance_window', 'alerting', true); - return window; - } - - async function getActiveMaintenanceWindows() { - const { body: activeMaintenanceWindows } = await supertest - .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/maintenance_window/_active`) - .set('kbn-xsrf', 'foo') - .expect(200); - - return activeMaintenanceWindows; - } - - function finishMaintenanceWindow(id: string) { - return supertest - .post( - `${getUrlPrefix( - Spaces.space1.id - )}/internal/alerting/rules/maintenance_window/${id}/_finish` - ) - .set('kbn-xsrf', 'foo') - .expect(200); - } - - async function getRuleEvents({ - id, - action, - newInstance, - activeInstance, - recoveredInstance, - }: { - id: string; - action?: number; - newInstance?: number; - activeInstance?: number; - recoveredInstance?: number; - }) { - const actions: Array<[string, { equal: number }]> = []; - if (action) { - actions.push(['execute-action', { equal: action }]); - } - if (newInstance) { - actions.push(['new-instance', { equal: newInstance }]); - } - if (activeInstance) { - actions.push(['active-instance', { equal: activeInstance }]); - } - if (recoveredInstance) { - actions.push(['recovered-instance', { equal: recoveredInstance }]); - } - return retry.try(async () => { - return await getEventLog({ - getService, - spaceId: Spaces.space1.id, - type: 'alert', - id, - provider: 'alerting', - actions: new Map(actions), - }); - }); - } - - async function expectNoActionsFired(id: string) { - const events = await retry.try(async () => { - const { body: result } = await supertest - .get(`${getUrlPrefix(Spaces.space1.id)}/_test/event_log/alert/${id}/_find?per_page=5000`) - .expect(200); - - if (!result.total) { - throw new Error('no events found yet'); - } - return result.data as IValidatedEvent[]; - }); - - const actionEvents = events.filter((event) => { - return event?.event?.action === 'execute-action'; - }); - - expect(actionEvents.length).eql(0); - } - - function runSoon(id: string) { - return retry.try(async () => { - await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${id}/_run_soon`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); - } }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts new file mode 100644 index 0000000000000..7ce59b292218c --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts @@ -0,0 +1,179 @@ +/* + * 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 type { Alert } from '@kbn/alerts-as-data-utils'; +import { ALERT_MAINTENANCE_WINDOW_IDS } from '@kbn/rule-data-utils'; +import { ObjectRemover } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + createRule, + createAction, + createMaintenanceWindow, + getRuleEvents, + expectNoActionsFired, + runSoon, +} from './test_helpers'; + +const alertAsDataIndex = '.internal.alerts-test.patternfiring.alerts-default-000001'; + +// eslint-disable-next-line import/no-default-export +export default function maintenanceWindowScopedQueryTests({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('es'); + + describe('maintenanceWindowScopedQuery', () => { + const objectRemover = new ObjectRemover(supertestWithoutAuth); + + afterEach(async () => { + await objectRemover.removeAll(); + await es.deleteByQuery({ + index: alertAsDataIndex, + query: { + match_all: {}, + }, + conflicts: 'proceed', + }); + }); + + it('should associate alerts muted by maintenance window scoped query', async () => { + const pattern = { + instance: [true, true, false, true], + }; + // Create active maintenance window + const maintenanceWindow = await createMaintenanceWindow({ + supertest, + objectRemover, + overwrites: { + scoped_query: { + kql: 'kibana.alert.rule.name: "test-rule"', + filters: [], + }, + category_ids: ['management'], + }, + }); + + // Create action and rule + const action = await await createAction({ + supertest, + objectRemover, + }); + + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + overwrites: { + rule_type_id: 'test.patternFiringAad', + }, + }); + + // Run the first time - active + await getRuleEvents({ + id: rule.id, + activeInstance: 1, + retry, + getService, + }); + + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); + + // Ensure we wrote the new maintenance window ID to the alert doc + const result = await es.search({ + index: alertAsDataIndex, + body: { query: { match_all: {} } }, + }); + + expect(result.hits.hits[0]?._source?.[ALERT_MAINTENANCE_WINDOW_IDS]).eql([ + maintenanceWindow.id, + ]); + + await runSoon({ + id: rule.id, + supertest, + retry, + }); + + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); + + await expectNoActionsFired({ + id: rule.id, + supertest, + retry, + }); + }); + + it('should not associate alerts if scoped query does not match the alert', async () => { + const pattern = { + instance: [true, true, false, true], + }; + // Create active maintenance window + await createMaintenanceWindow({ + supertest, + objectRemover, + overwrites: { + scoped_query: { + kql: 'kibana.alert.rule.name: "wrong-rule"', + filters: [], + }, + category_ids: ['management'], + }, + }); + + // Create action and rule + const action = await await createAction({ + supertest, + objectRemover, + }); + + const rule = await createRule({ + actionId: action.id, + pattern, + supertest, + objectRemover, + overwrites: { + rule_type_id: 'test.patternFiringAad', + }, + }); + + // Run the first time - active - has action + await getRuleEvents({ + id: rule.id, + action: 1, + activeInstance: 1, + retry, + getService, + }); + + await runSoon({ + id: rule.id, + supertest, + retry, + }); + + await getRuleEvents({ + id: rule.id, + action: 2, + activeInstance: 2, + retry, + getService, + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/test_helpers.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/test_helpers.ts new file mode 100644 index 0000000000000..a898cc14b9104 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/test_helpers.ts @@ -0,0 +1,227 @@ +/* + * 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 moment from 'moment'; +import type { RetryService } from '@kbn/ftr-common-functional-services'; +import type { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import type { SuperTest, Test } from 'supertest'; +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getUrlPrefix, getTestRuleData, ObjectRemover, getEventLog } from '../../../../common/lib'; +import { Spaces } from '../../../scenarios'; + +export const createRule = async ({ + actionId, + pattern, + supertest, + objectRemover, + overwrites, +}: { + actionId: string; + pattern: { instance: boolean[] }; + supertest: SuperTest; + objectRemover: ObjectRemover; + overwrites?: any; +}) => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + name: 'test-rule', + rule_type_id: 'test.patternFiring', + schedule: { interval: '24h' }, + throttle: null, + notify_when: 'onActiveAlert', + params: { + pattern, + }, + actions: [ + { + id: actionId, + group: 'default', + params: {}, + }, + { + id: actionId, + group: 'recovered', + params: {}, + }, + ], + ...overwrites, + }) + ) + .expect(200); + + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + return createdRule; +}; + +export const createAction = async ({ + supertest, + objectRemover, +}: { + supertest: SuperTest; + objectRemover: ObjectRemover; +}) => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + return createdAction; +}; + +export const createMaintenanceWindow = async ({ + overwrites, + supertest, + objectRemover, +}: { + overwrites?: any; + supertest: SuperTest; + objectRemover: ObjectRemover; +}) => { + const { body: window } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-maintenance-window-1', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: moment.utc().toISOString(), + tzid: 'UTC', + freq: 0, // yearly + count: 1, + }, + ...overwrites, + }) + .expect(200); + + objectRemover.add(Spaces.space1.id, window.id, 'rules/maintenance_window', 'alerting', true); + return window; +}; + +export const getActiveMaintenanceWindows = async ({ + supertest, +}: { + supertest: SuperTest; +}) => { + const { body: activeMaintenanceWindows } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/maintenance_window/_active`) + .set('kbn-xsrf', 'foo') + .expect(200); + + return activeMaintenanceWindows; +}; + +export const finishMaintenanceWindow = async ({ + id, + supertest, +}: { + id: string; + supertest: SuperTest; +}) => { + return supertest + .post( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/maintenance_window/${id}/_finish` + ) + .set('kbn-xsrf', 'foo') + .expect(200); +}; + +export const getRuleEvents = async ({ + id, + action, + newInstance, + activeInstance, + recoveredInstance, + retry, + getService, +}: { + id: string; + action?: number; + newInstance?: number; + activeInstance?: number; + recoveredInstance?: number; + retry: RetryService; + getService: FtrProviderContext['getService']; +}) => { + const actions: Array<[string, { equal: number }]> = []; + if (action) { + actions.push(['execute-action', { equal: action }]); + } + if (newInstance) { + actions.push(['new-instance', { equal: newInstance }]); + } + if (activeInstance) { + actions.push(['active-instance', { equal: activeInstance }]); + } + if (recoveredInstance) { + actions.push(['recovered-instance', { equal: recoveredInstance }]); + } + return retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions: new Map(actions), + }); + }); +}; + +export const expectNoActionsFired = async ({ + id, + supertest, + retry, +}: { + id: string; + supertest: SuperTest; + retry: RetryService; +}) => { + const events = await retry.try(async () => { + const { body: result } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/_test/event_log/alert/${id}/_find?per_page=5000`) + .expect(200); + + if (!result.total) { + throw new Error('no events found yet'); + } + return result.data as IValidatedEvent[]; + }); + + const actionEvents = events.filter((event) => { + return event?.event?.action === 'execute-action'; + }); + + expect(actionEvents.length).eql(0); +}; + +export const runSoon = async ({ + id, + supertest, + retry, +}: { + id: string; + supertest: SuperTest; + retry: RetryService; +}) => { + return retry.try(async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${id}/_run_soon`) + .set('kbn-xsrf', 'foo') + .expect(204); + }); +};