diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.test.ts index c7e15cf1f974a..997035ce1df45 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.test.ts @@ -137,4 +137,28 @@ describe('generateMaintenanceWindowEvents', () => { expect(result[result.length - 1].gte).toEqual('2023-03-30T00:00:00.000Z'); expect(result[result.length - 1].lte).toEqual('2023-03-30T01:00:00.000Z'); }); + + it('should generate events starting with start date', () => { + const result = generateMaintenanceWindowEvents({ + duration: 1 * 60 * 60 * 1000, + expirationDate: moment(new Date('2023-02-27T00:00:00.000Z')) + .tz('UTC') + .add(5, 'weeks') + .toISOString(), + rRule: { + tzid: 'UTC', + freq: Frequency.WEEKLY, + interval: 1, + byweekday: ['WE', 'TU', 'TH'], + dtstart: '2023-01-27T00:00:00.000Z', + }, + startDate: '2023-03-01T00:00:00.000Z', + }); + + expect(result[0].lte).toEqual('2023-03-01T01:00:00.000Z'); // events started after start date + expect(result[0].gte).toEqual('2023-03-01T00:00:00.000Z'); + + expect(result[result.length - 1].lte).toEqual('2023-03-30T01:00:00.000Z'); // events ended before expiration date + expect(result[result.length - 1].gte).toEqual('2023-03-30T00:00:00.000Z'); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.ts index 458d24aef7602..51feeb714c590 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/generate_maintenance_window_events.ts @@ -15,21 +15,23 @@ export interface GenerateMaintenanceWindowEventsParams { rRule: RRuleParams; expirationDate: string; duration: number; + startDate?: string; } export const generateMaintenanceWindowEvents = ({ rRule, expirationDate, duration, + startDate, }: GenerateMaintenanceWindowEventsParams) => { const { dtstart, until, wkst, byweekday, ...rest } = rRule; - const startDate = new Date(dtstart); + const rRuleStartDate = new Date(dtstart); const endDate = new Date(expirationDate); const rRuleOptions = { ...rest, - dtstart: startDate, + dtstart: rRuleStartDate, until: until ? new Date(until) : null, wkst: wkst ? Weekday[wkst] : null, byweekday: byweekday ?? null, @@ -37,7 +39,8 @@ export const generateMaintenanceWindowEvents = ({ try { const recurrenceRule = new RRule(rRuleOptions); - const occurrenceDates = recurrenceRule.between(startDate, endDate); + const eventStartDate = startDate ? new Date(startDate) : rRuleStartDate; + const occurrenceDates = recurrenceRule.between(eventStartDate, endDate); return occurrenceDates.map((date) => { return { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.test.ts new file mode 100644 index 0000000000000..bba49cadbf6c3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { Frequency } from '@kbn/rrule'; +import { getMaintenanceWindowExpirationDate } from './get_maintenance_window_expiration_date'; + +describe('getMaintenanceWindowExpirationDate', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2023-03-25T00:30:00.000Z')); + }); + + afterAll(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should return +1 year expiration date', () => { + const result = getMaintenanceWindowExpirationDate({ + rRule: { + tzid: 'UTC', + freq: Frequency.WEEKLY, + byweekday: ['MO', 'FR'], + interval: 1, + dtstart: '2023-03-25T01:00:00.000Z', + }, + duration: 1 * 60 * 60 * 1000, + }); + expect(result).toEqual('2024-03-25T00:30:00.000Z'); + }); + + it('should return expiration date based on duration', () => { + const result = getMaintenanceWindowExpirationDate({ + rRule: { + tzid: 'UTC', + dtstart: '2023-03-25T09:00:00.000Z', + }, + duration: 1 * 60 * 60 * 1000, + }); + expect(result).toEqual('2023-03-25T10:00:00.000Z'); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.ts new file mode 100644 index 0000000000000..b7a461931ae14 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_expiration_date.ts @@ -0,0 +1,26 @@ +/* + * 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 { RRuleParams } from '../../../../common'; + +// Returns a date in ISO format one year in the future if the rule is recurring or until the end of the MW if it is not recurring. +export const getMaintenanceWindowExpirationDate = ({ + rRule, + duration, +}: { + rRule: RRuleParams; + duration: number; +}): string => { + let expirationDate; + if (rRule.interval || rRule.freq) { + expirationDate = moment().utc().add(1, 'year').toISOString(); + } else { + expirationDate = moment(rRule.dtstart).utc().add(duration, 'ms').toISOString(); + } + + return expirationDate; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_status.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_status.ts new file mode 100644 index 0000000000000..ba0fd4fbcd233 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/get_maintenance_window_status.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MaintenanceWindowStatus } from '../../../../common'; + +export const getMaintenanceWindowStatus = () => { + return { + [MaintenanceWindowStatus.Running]: '(maintenance-window.attributes.events: "now")', + [MaintenanceWindowStatus.Upcoming]: + '(not maintenance-window.attributes.events: "now" and maintenance-window.attributes.events > "now")', + [MaintenanceWindowStatus.Finished]: + '(not maintenance-window.attributes.events >= "now" and maintenance-window.attributes.expirationDate >"now")', + [MaintenanceWindowStatus.Archived]: '(maintenance-window.attributes.expirationDate < "now")', + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/index.ts index 473de1f777d88..a305963ef3247 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/lib/index.ts @@ -15,3 +15,5 @@ export { getMaintenanceWindowDateAndStatus, findRecentEventWithStatus, } from './get_maintenance_window_date_and_status'; + +export { getMaintenanceWindowExpirationDate } from './get_maintenance_window_expiration_date'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.ts index 9e6c9c3fa94d5..27496a02e6d89 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.ts @@ -5,7 +5,6 @@ * 2.0. */ -import moment from 'moment'; import Boom from '@hapi/boom'; import { SavedObjectsUtils } from '@kbn/core/server'; import type { Filter } from '@kbn/es-query'; @@ -22,6 +21,7 @@ import { } from '../../transforms'; import { createMaintenanceWindowSo } from '../../../../data/maintenance_window'; import { createMaintenanceWindowParamsSchema } from './schemas'; +import { getMaintenanceWindowExpirationDate } from '../../lib'; export async function createMaintenanceWindow( context: MaintenanceWindowClientContext, @@ -65,7 +65,12 @@ export async function createMaintenanceWindow( } const id = SavedObjectsUtils.generateId(); - const expirationDate = moment().utc().add(1, 'year').toISOString(); + + const expirationDate = getMaintenanceWindowExpirationDate({ + rRule, + duration, + }); + const modificationMetadata = await getModificationMetadata(); const events = generateMaintenanceWindowEvents({ rRule, expirationDate, duration }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts index a37adfa6834de..0a6c4a1b1e175 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts @@ -9,7 +9,6 @@ import Boom from '@hapi/boom'; import type { KueryNode } from '@kbn/es-query'; import { fromKueryExpression } from '@kbn/es-query'; import type { MaintenanceWindowClientContext } from '../../../../../common'; -import { MaintenanceWindowStatus } from '../../../../../common'; import { transformMaintenanceWindowAttributesToMaintenanceWindow } from '../../transforms'; import { findMaintenanceWindowSo } from '../../../../data/maintenance_window'; import type { @@ -18,23 +17,16 @@ import type { MaintenanceWindowsStatus, } from './types'; import { findMaintenanceWindowsParamsSchema } from './schemas'; +import { getMaintenanceWindowStatus } from '../../lib/get_maintenance_window_status'; export const getStatusFilter = ( status?: MaintenanceWindowsStatus[] ): KueryNode | string | undefined => { if (!status || status.length === 0) return undefined; - - const statusToQueryMapping = { - [MaintenanceWindowStatus.Running]: '(maintenance-window.attributes.events: "now")', - [MaintenanceWindowStatus.Upcoming]: - '(not maintenance-window.attributes.events: "now" and maintenance-window.attributes.events > "now")', - [MaintenanceWindowStatus.Finished]: - '(not maintenance-window.attributes.events >= "now" and maintenance-window.attributes.expirationDate >"now")', - [MaintenanceWindowStatus.Archived]: '(maintenance-window.attributes.expirationDate < "now")', - }; + const mwStatusQuery = getMaintenanceWindowStatus(); const fullQuery = status - .map((value) => statusToQueryMapping[value]) + .map((value) => mwStatusQuery[value]) .filter(Boolean) .join(' or '); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.ts b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.ts index dac7799155e5c..a42a7e87a5be3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.ts @@ -17,7 +17,8 @@ import { generateMaintenanceWindowEvents, shouldRegenerateEvents, mergeEvents, -} from '../../lib/generate_maintenance_window_events'; + getMaintenanceWindowExpirationDate, +} from '../../lib'; import { retryIfConflicts } from '../../../../lib/retry_if_conflicts'; import { transformMaintenanceWindowAttributesToMaintenanceWindow, @@ -98,7 +99,11 @@ async function updateWithOCC( throw Boom.badRequest('Cannot edit archived maintenance windows'); } - const expirationDate = moment.utc().add(1, 'year').toISOString(); + const expirationDate = getMaintenanceWindowExpirationDate({ + rRule: maintenanceWindow.rRule, + duration: maintenanceWindow.duration, + }); + const modificationMetadata = await getModificationMetadata(); let events = generateMaintenanceWindowEvents({ diff --git a/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.test.ts b/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.test.ts new file mode 100644 index 0000000000000..7d1df143f70de --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.test.ts @@ -0,0 +1,839 @@ +/* + * 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 { fromKueryExpression, type KueryNode } from '@kbn/es-query'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { getMockMaintenanceWindow } from '../data/maintenance_window/test_helpers'; +import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '../types'; +import { + getSOFinder, + generateEvents, + getStatusFilter, + updateMaintenanceWindowsEvents, + createEventsGeneratorTaskRunner, +} from './task'; + +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const logger: ReturnType = loggingSystemMock.createLogger(); + +const finishedMaintenanceWindowMock = { + id: '1', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 1', + enabled: true, + duration: 7200000, + expirationDate: '2025-04-26T09:00:00.000Z', + events: [], + rRule: { + count: 10, + interval: 1, + freq: 3, + dtstart: '2024-04-24T12:30:37.011Z', + tzid: 'UTC', + }, + }), + references: [], +}; + +const nonRecurringMaintenanceWindowMock = { + id: '2', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 2', + enabled: true, + duration: 86400000, + expirationDate: '2025-04-26T13:57:24.383Z', + events: [ + { + gte: '2025-04-22T09:00:37.011Z', + lte: '2025-04-22T17:00:37.011Z', + }, + ], + rRule: { dtstart: '2025-04-22T12:30:37.011Z', tzid: 'UTC' }, + }), + references: [], +}; + +const upcomingMaintenanceWindowMock = { + id: '3', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 3', + enabled: true, + duration: 7200000, + expirationDate: '2025-04-24T00:00:00.000Z', + events: [ + { + gte: '2025-04-20T09:00:00.000Z', + lte: '2025-04-20T17:00:00.000Z', + }, + ], + rRule: { + until: '2028-12-31T12:00:00.000Z', + interval: 1, + freq: 1, + dtstart: '2025-04-20T09:00:00.000Z', + tzid: 'UTC', + }, + }), + references: [], +}; + +const runningMaintenanceWindowMock = { + id: '4', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 4', + enabled: true, + duration: 28800000, + expirationDate: '2025-04-29T00:00:00.000Z', + events: [ + { + gte: '2025-04-23T05:00:00.000Z', + lte: '2025-04-23T17:00:00.000Z', + }, + ], + rRule: { + until: '2026-12-31T12:00:00.000Z', + interval: 1, + freq: 0, + dtstart: '2025-04-23T05:00:00.000Z', + tzid: 'UTC', + }, + }), + references: [], +}; + +const finishedWithEventsMaintenanceWindowMock = { + id: '5', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 5', + enabled: true, + duration: 7200000, + expirationDate: '2025-04-26T09:00:00.000Z', + events: [ + { + gte: '2025-03-31T00:00:00.011Z', + lte: '2025-03-31T10:00:00.011Z', + }, + { + gte: '2025-04-07T00:00:00.011Z', + lte: '2025-04-07T10:00:00.011Z', + }, + { + gte: '2025-04-14T00:00:00.011Z', + lte: '2025-04-14T10:00:00.011Z', + }, + { + gte: '2025-04-21T00:00:00.011Z', + lte: '2025-04-21T10:00:00.011Z', + }, + ], + rRule: { + byweekday: ['+1MO'], + count: 4, + interval: 1, + freq: 2, + dtstart: '2025-03-31T00:00:00.011Z', + tzid: 'UTC', + }, + }), + references: [], +}; + +const runningMaintenanceWindowMock1 = { + id: '6', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: getMockMaintenanceWindow({ + title: 'MW 6', + enabled: true, + duration: 28800000, + expirationDate: '2025-04-16T15:00:00.000Z', // expiration date is 1 week before current date + events: [ + { + gte: '2025-04-15T05:00:00.000Z', + lte: '2025-04-15T13:00:00.000Z', + }, + ], + rRule: { + until: '2025-05-30T12:00:00.000Z', + interval: 1, + freq: 2, + dtstart: '2025-04-15T05:00:00.000Z', + tzid: 'UTC', + }, + }), + references: [], +}; + +const statusFilter: KueryNode = fromKueryExpression( + `maintenance-window.attributes.events is now or (not maintenance-window.attributes.events is now and maintenance-window.attributes.events range gt now) or (not maintenance-window.attributes.events range gte now and maintenance-window.attributes.expirationDate range gt now)` +); + +const mockCreatePointInTimeFinderAsInternalUser = ( + response = { + saved_objects: [ + finishedMaintenanceWindowMock, + nonRecurringMaintenanceWindowMock, + upcomingMaintenanceWindowMock, + runningMaintenanceWindowMock, + ], + } as unknown +) => { + internalSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValueOnce({ + close: jest.fn(), + find: function* asyncGenerator() { + yield response; + }, + }); +}; + +describe('Maintenance window events generator task', () => { + const mockCurrentDate = new Date('2025-04-23T09:00:00.000Z'); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(mockCurrentDate); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('getStatusFilter', () => { + test('should build status filter', () => { + expect(getStatusFilter()).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "not", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + "gt", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + "gte", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "not", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.expirationDate", + }, + "gt", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + }); + }); + + describe('getSOFinder', () => { + it('should return finder', () => { + mockCreatePointInTimeFinderAsInternalUser(); + + const result = getSOFinder({ + savedObjectsClient: internalSavedObjectsRepository, + logger, + filter: statusFilter as KueryNode, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "close": [MockFunction], + "find": [Function], + } + `); + }); + }); + + describe('generateEvents', () => { + test('should handle empty maintenance windows', async () => { + const totalMWs = await generateEvents({ + maintenanceWindowsSO: [], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(totalMWs).toEqual([]); + }); + + test('should not generate events when finished maintenance window', async () => { + const totalMWs = await generateEvents({ + maintenanceWindowsSO: [finishedWithEventsMaintenanceWindowMock], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(totalMWs).toEqual([]); + }); + + test('should not generate new events when events are empty', async () => { + const totalMWs = await generateEvents({ + maintenanceWindowsSO: [finishedMaintenanceWindowMock], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(totalMWs).toEqual([]); + }); + + test('should ignore maintenance window if it does not have recurring schedule', async () => { + const totalMWs = await generateEvents({ + maintenanceWindowsSO: [finishedMaintenanceWindowMock, nonRecurringMaintenanceWindowMock], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(totalMWs).toEqual([]); + }); + + test('should return multiple maintenance windows with new events', async () => { + const upcomingMaintenanceWindowAttributes = { + ...upcomingMaintenanceWindowMock.attributes, + events: [ + { + gte: '2025-05-20T09:00:00.000Z', + lte: '2025-05-20T11:00:00.000Z', + }, + { + gte: '2025-06-20T09:00:00.000Z', + lte: '2025-06-20T11:00:00.000Z', + }, + { + gte: '2025-07-20T09:00:00.000Z', + lte: '2025-07-20T11:00:00.000Z', + }, + { + gte: '2025-08-20T09:00:00.000Z', + lte: '2025-08-20T11:00:00.000Z', + }, + { + gte: '2025-09-20T09:00:00.000Z', + lte: '2025-09-20T11:00:00.000Z', + }, + { + gte: '2025-10-20T09:00:00.000Z', + lte: '2025-10-20T11:00:00.000Z', + }, + { + gte: '2025-11-20T09:00:00.000Z', + lte: '2025-11-20T11:00:00.000Z', + }, + { + gte: '2025-12-20T09:00:00.000Z', + lte: '2025-12-20T11:00:00.000Z', + }, + { + gte: '2026-01-20T09:00:00.000Z', + lte: '2026-01-20T11:00:00.000Z', + }, + { + gte: '2026-02-20T09:00:00.000Z', + lte: '2026-02-20T11:00:00.000Z', + }, + { + gte: '2026-03-20T09:00:00.000Z', + lte: '2026-03-20T11:00:00.000Z', + }, + { + gte: '2026-04-20T09:00:00.000Z', + lte: '2026-04-20T11:00:00.000Z', + }, + ], + expirationDate: moment(mockCurrentDate).tz('UTC').endOf('day').add(1, 'year').toISOString(), + }; + + const total = await generateEvents({ + maintenanceWindowsSO: [ + finishedMaintenanceWindowMock, + nonRecurringMaintenanceWindowMock, + upcomingMaintenanceWindowMock, + runningMaintenanceWindowMock, + ], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(total).toEqual([ + { + id: '3', + ...upcomingMaintenanceWindowAttributes, + eventEndTime: '2025-04-20T17:00:00.000Z', + eventStartTime: '2025-04-20T09:00:00.000Z', + status: 'finished', + }, + { + id: '4', + eventEndTime: '2025-04-23T17:00:00.000Z', + eventStartTime: '2025-04-23T05:00:00.000Z', + status: 'running', + ...runningMaintenanceWindowMock.attributes, + expirationDate: moment(mockCurrentDate) + .tz('UTC') + .endOf('day') + .add(1, 'year') + .toISOString(), + events: [ + { + gte: '2026-04-23T05:00:00.000Z', + lte: '2026-04-23T13:00:00.000Z', + }, + ], + }, + ]); + }); + + test('should generate events for 2 weeks maintenance window', async () => { + const totalMWs = await generateEvents({ + maintenanceWindowsSO: [runningMaintenanceWindowMock1], + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(totalMWs).toEqual([ + { + id: '6', + ...runningMaintenanceWindowMock1.attributes, + eventEndTime: '2025-04-15T13:00:00.000Z', + eventStartTime: '2025-04-15T05:00:00.000Z', + expirationDate: moment(mockCurrentDate) + .tz('UTC') + .endOf('day') + .add(1, 'year') + .toISOString(), + events: [ + { + gte: '2025-04-29T05:00:00.000Z', + lte: '2025-04-29T13:00:00.000Z', + }, + { + gte: '2025-05-06T05:00:00.000Z', + lte: '2025-05-06T13:00:00.000Z', + }, + { + gte: '2025-05-13T05:00:00.000Z', + lte: '2025-05-13T13:00:00.000Z', + }, + { + gte: '2025-05-20T05:00:00.000Z', + lte: '2025-05-20T13:00:00.000Z', + }, + { + gte: '2025-05-27T05:00:00.000Z', + lte: '2025-05-27T13:00:00.000Z', + }, + { + gte: '2025-06-03T05:00:00.000Z', + lte: '2025-06-03T13:00:00.000Z', + }, + ], + status: 'archived', + }, + ]); + }); + }); + + describe('updateMaintenanceWindowsEvents', () => { + test('should not update any maintenance windows when there are none', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [], + }); + + const total = await updateMaintenanceWindowsEvents({ + logger, + savedObjectsClient: internalSavedObjectsRepository, + soFinder: internalSavedObjectsRepository.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + filter: statusFilter as KueryNode, + }), + startRangeDate: '2025-04-23T09:00:00.000Z', + }); + + expect(internalSavedObjectsRepository.bulkUpdate).not.toHaveBeenCalled(); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total updated maintenance windows "0"`); + }); + + test('should update single maintenance window', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...finishedWithEventsMaintenanceWindowMock, + attributes: { + ...finishedWithEventsMaintenanceWindowMock.attributes, + rRule: { ...finishedWithEventsMaintenanceWindowMock.attributes.rRule, count: 5 }, + }, + }, + ], + }); + + internalSavedObjectsRepository.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + { + id: '5', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: { + ...finishedWithEventsMaintenanceWindowMock.attributes, + events: [ + { + gte: '2025-04-28T00:00:00.011Z', + lte: '2025-04-28T02:00:00.011Z', + }, + ], + expirationDate: moment(mockCurrentDate).tz('UTC').add(1, 'year').toISOString(), + }, + references: [], + }, + ], + }); + const total = await updateMaintenanceWindowsEvents({ + soFinder: internalSavedObjectsRepository.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + filter: statusFilter as KueryNode, + }), + startRangeDate: '2025-04-23T09:00:00.000Z', + logger, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(internalSavedObjectsRepository.bulkUpdate).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.bulkUpdate).toHaveBeenNthCalledWith(1, [ + { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: '5', + attributes: { + ...finishedWithEventsMaintenanceWindowMock.attributes, + rRule: { + ...finishedWithEventsMaintenanceWindowMock.attributes.rRule, + count: 5, + }, + events: [ + { + gte: '2025-04-28T00:00:00.011Z', + lte: '2025-04-28T02:00:00.011Z', + }, + ], + expirationDate: moment(mockCurrentDate) + .tz('UTC') + .endOf('day') + .add(1, 'year') + .toISOString(), + }, + }, + ]); + expect(total).toEqual(1); + expect(logger.debug).toHaveBeenCalledWith(`Total updated maintenance windows "1"`); + }); + + test('should update multiple maintenance windows with new events', async () => { + const upcomingMaintenanceWindowAttributes = { + ...upcomingMaintenanceWindowMock.attributes, + events: [ + { + gte: '2025-05-20T09:00:00.000Z', + lte: '2025-05-20T11:00:00.000Z', + }, + { + gte: '2025-06-20T09:00:00.000Z', + lte: '2025-06-20T11:00:00.000Z', + }, + { + gte: '2025-07-20T09:00:00.000Z', + lte: '2025-07-20T11:00:00.000Z', + }, + { + gte: '2025-08-20T09:00:00.000Z', + lte: '2025-08-20T11:00:00.000Z', + }, + { + gte: '2025-09-20T09:00:00.000Z', + lte: '2025-09-20T11:00:00.000Z', + }, + { + gte: '2025-10-20T09:00:00.000Z', + lte: '2025-10-20T11:00:00.000Z', + }, + { + gte: '2025-11-20T09:00:00.000Z', + lte: '2025-11-20T11:00:00.000Z', + }, + { + gte: '2025-12-20T09:00:00.000Z', + lte: '2025-12-20T11:00:00.000Z', + }, + { + gte: '2026-01-20T09:00:00.000Z', + lte: '2026-01-20T11:00:00.000Z', + }, + { + gte: '2026-02-20T09:00:00.000Z', + lte: '2026-02-20T11:00:00.000Z', + }, + { + gte: '2026-03-20T09:00:00.000Z', + lte: '2026-03-20T11:00:00.000Z', + }, + { + gte: '2026-04-20T09:00:00.000Z', + lte: '2026-04-20T11:00:00.000Z', + }, + ], + expirationDate: moment(mockCurrentDate).tz('UTC').endOf('day').add(1, 'year').toISOString(), + }; + + mockCreatePointInTimeFinderAsInternalUser(); + + internalSavedObjectsRepository.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + { + id: '3', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: upcomingMaintenanceWindowAttributes, + references: [], + }, + { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: '4', + attributes: { + ...runningMaintenanceWindowMock.attributes, + expirationDate: moment(mockCurrentDate) + .tz('UTC') + .endOf('day') + .add(1, 'year') + .toISOString(), + events: [ + { + gte: '2026-04-23T05:00:00.000Z', + lte: '2026-04-23T13:00:00.000Z', + }, + ], + }, + references: [], + }, + ], + }); + + const total = await updateMaintenanceWindowsEvents({ + soFinder: internalSavedObjectsRepository.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + filter: statusFilter as KueryNode, + }), + startRangeDate: '2025-04-23T09:00:00.000Z', + logger, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(internalSavedObjectsRepository.bulkUpdate).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.bulkUpdate).toHaveBeenNthCalledWith(1, [ + { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: '3', + attributes: upcomingMaintenanceWindowAttributes, + }, + { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: '4', + attributes: { + ...runningMaintenanceWindowMock.attributes, + expirationDate: moment(mockCurrentDate) + .tz('UTC') + .endOf('day') + .add(1, 'year') + .toISOString(), + events: [ + { + gte: '2026-04-23T05:00:00.000Z', + lte: '2026-04-23T13:00:00.000Z', + }, + ], + }, + }, + ]); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total updated maintenance windows "2"`); + }); + + test('should handle errors during update', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [runningMaintenanceWindowMock], + }); + + internalSavedObjectsRepository.bulkUpdate.mockRejectedValueOnce( + new Error('something went wrong') + ); + + const total = await updateMaintenanceWindowsEvents({ + startRangeDate: '2025-05-23T09:00:00.000Z', + logger, + savedObjectsClient: internalSavedObjectsRepository, + soFinder: internalSavedObjectsRepository.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + filter: statusFilter as KueryNode, + }), + }); + + expect(logger.error).toHaveBeenCalledWith( + 'MW event generator: Failed to update events in maintenance windows saved object". Error: something went wrong' + ); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total updated maintenance windows "0"`); + }); + + test('logs error when bulkUpdate returns any errored saved object', async () => { + mockCreatePointInTimeFinderAsInternalUser(); + + internalSavedObjectsRepository.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + { + id: '3', + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + }, + { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: '4', + error: { + error: 'NotFound', + message: 'NotFound', + statusCode: 404, + }, + references: [], + attributes: {}, + }, + ], + }); + + const total = await updateMaintenanceWindowsEvents({ + startRangeDate: '2025-05-23T09:00:00.000Z', + logger, + savedObjectsClient: internalSavedObjectsRepository, + soFinder: internalSavedObjectsRepository.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + filter: statusFilter as KueryNode, + }), + }); + + expect(logger.error).toHaveBeenCalledWith( + 'MW event generator: Failed to update maintenance window "4". Error: NotFound' + ); + + expect(total).toEqual(1); + }); + }); + + describe('createEventsGeneratorTaskRunner', () => { + it('should log when the task is cancelled', async () => { + const startDependencies = [ + { + savedObjectsRepositoryMock: internalSavedObjectsRepository, + }, + ]; + const getStartServices = jest.fn().mockResolvedValue(startDependencies); + const mwTask = createEventsGeneratorTaskRunner(logger, getStartServices)(); + + await mwTask.run(); + await mwTask.cancel(); + + expect(logger.debug).toHaveBeenCalledWith( + 'Cancelling maintenance windows events generator task - execution error due to timeout.' + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.ts b/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.ts new file mode 100644 index 0000000000000..da2e7829f4f9f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/maintenance_window_events/task.ts @@ -0,0 +1,309 @@ +/* + * 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 { + SavedObject, + ISavedObjectsRepository, + ISavedObjectsPointInTimeFinder, + StartServicesAccessor, +} from '@kbn/core/server'; +import { type Logger } from '@kbn/core/server'; +import type { + IntervalSchedule, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { KueryNode } from '@kbn/es-query'; +import { fromKueryExpression, nodeBuilder } from '@kbn/es-query'; +import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, MaintenanceWindowStatus } from '../../common'; +import type { AlertingPluginsStart } from '../plugin'; +import type { MaintenanceWindowAttributes } from '../data/maintenance_window/types'; +import { + transformMaintenanceWindowAttributesToMaintenanceWindow, + transformMaintenanceWindowToMaintenanceWindowAttributes, +} from '../application/maintenance_window/transforms'; +import { generateMaintenanceWindowEvents as generateMaintenanceWindowEventsViaRRule } from '../application/maintenance_window/lib/generate_maintenance_window_events'; +import { getMaintenanceWindowStatus } from '../application/maintenance_window/lib/get_maintenance_window_status'; + +export const MAINTENANCE_WINDOW_EVENTS_TASK_TYPE = 'maintenance-window:generate-events'; + +export const MAINTENANCE_WINDOW_EVENTS_TASK_ID = `${MAINTENANCE_WINDOW_EVENTS_TASK_TYPE}-generator`; +export const SCHEDULE: IntervalSchedule = { interval: '1d' }; + +export function initializeMaintenanceWindowEventsGenerator( + logger: Logger, + taskManager: TaskManagerSetupContract, + coreStartServices: StartServicesAccessor +) { + registerMaintenanceWindowEventsGeneratorTask(logger, taskManager, coreStartServices); +} + +export async function scheduleMaintenanceWindowEventsGenerator( + logger: Logger, + taskManager: TaskManagerStartContract +) { + try { + await taskManager.ensureScheduled({ + id: MAINTENANCE_WINDOW_EVENTS_TASK_ID, + taskType: MAINTENANCE_WINDOW_EVENTS_TASK_TYPE, + schedule: SCHEDULE, + state: {}, + params: {}, + }); + } catch (e) { + logger.error(`Error scheduling ${MAINTENANCE_WINDOW_EVENTS_TASK_ID}, received ${e.message}`); + } +} + +function registerMaintenanceWindowEventsGeneratorTask( + logger: Logger, + taskManager: TaskManagerSetupContract, + coreStartServices: StartServicesAccessor +) { + taskManager.registerTaskDefinitions({ + [MAINTENANCE_WINDOW_EVENTS_TASK_TYPE]: { + title: 'Maintenance window events generator task', + createTaskRunner: createEventsGeneratorTaskRunner(logger, coreStartServices), + timeout: '30m', + }, + }); +} + +export function createEventsGeneratorTaskRunner( + logger: Logger, + coreStartServices: StartServicesAccessor +) { + return () => { + let cancelled = false; + let soFinder: ISavedObjectsPointInTimeFinder | null; + + return { + async run() { + try { + const [{ savedObjects }] = await coreStartServices(); + + const savedObjectsClient = savedObjects.createInternalRepository([ + MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + ]); + + // we are using total 2 weeks range to find maintenance windows that are expiring + const startRangeDate = moment().startOf('day').utc().subtract(1, 'week').toISOString(); // 1 week before current date + const endRangeDate = moment().startOf('day').utc().add(1, 'week').toISOString(); // 1 week after current date + + const startRangeFilter = nodeBuilder.range( + 'maintenance-window.attributes.expirationDate', + 'gte', + startRangeDate + ); + const endRangeFilter = nodeBuilder.range( + 'maintenance-window.attributes.expirationDate', + 'lte', + endRangeDate + ); + + const statusFilter = getStatusFilter(); + + const filter = nodeBuilder.and([startRangeFilter, endRangeFilter, statusFilter]); + + soFinder = getSOFinder({ + savedObjectsClient, + logger, + filter, + }); + + const totalMaintenanceWindowsWithGeneratedEvents = await updateMaintenanceWindowsEvents({ + savedObjectsClient, + logger, + startRangeDate, + soFinder, + }); + + logger.debug( + `Maintenance windows events generator task updated ${totalMaintenanceWindowsWithGeneratedEvents} maintenance windows successfully` + ); + } catch (e) { + logger.warn(`Error executing maintenance windows events generator task: ${e.message}`); + } + }, + async cancel() { + if (cancelled) { + return; + } + + logger.debug( + `Cancelling maintenance windows events generator task - execution error due to timeout.` + ); + + cancelled = true; + + await soFinder?.close(); + + return; + }, + }; + }; +} + +export function getStatusFilter() { + const mwStatusQuery = getMaintenanceWindowStatus(); + + const fullQuery = [ + MaintenanceWindowStatus.Running, + MaintenanceWindowStatus.Upcoming, + MaintenanceWindowStatus.Finished, + ] + .map((value) => mwStatusQuery[value]) + .filter(Boolean) + .join(' or '); + + return fromKueryExpression(fullQuery); +} + +export const updateMaintenanceWindowsEvents = async ({ + soFinder, + savedObjectsClient, + logger, + startRangeDate, +}: { + logger: Logger; + savedObjectsClient: ISavedObjectsRepository; + soFinder: ISavedObjectsPointInTimeFinder | null; + startRangeDate: string; +}) => { + let totalUpdatedMaintenanceWindows = 0; + let mwsWithNewEvents = []; + let mwSOWithErrors = 0; + + if (soFinder) { + for await (const findResults of soFinder.find()) { + try { + mwsWithNewEvents = await generateEvents({ + maintenanceWindowsSO: findResults.saved_objects, + startRangeDate, + }); + + if (mwsWithNewEvents.length) { + const bulkUpdateReq = mwsWithNewEvents.map((mw) => { + const updatedMaintenanceWindowAttributes = + transformMaintenanceWindowToMaintenanceWindowAttributes({ + ...mw, + }); + + return { + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + id: mw.id, + attributes: updatedMaintenanceWindowAttributes, + }; + }); + + const result = await savedObjectsClient.bulkUpdate( + bulkUpdateReq + ); + + for (const savedObject of result.saved_objects) { + if (savedObject.error) { + logger.error( + `MW event generator: Failed to update maintenance window "${savedObject.id}". Error: ${savedObject.error.message}` + ); + mwSOWithErrors++; + } + } + + totalUpdatedMaintenanceWindows = + totalUpdatedMaintenanceWindows + (result.saved_objects.length - mwSOWithErrors); + } + } catch (e) { + logger.error( + `MW event generator: Failed to update events in maintenance windows saved object". Error: ${e.message}` + ); + } + } + + await soFinder.close(); + } + + logger.debug(`Total updated maintenance windows "${totalUpdatedMaintenanceWindows}"`); + + return totalUpdatedMaintenanceWindows; +}; + +export function getSOFinder({ + savedObjectsClient, + logger, + filter, +}: { + logger: Logger; + savedObjectsClient: ISavedObjectsRepository; + filter: KueryNode; +}): ISavedObjectsPointInTimeFinder | null { + try { + return savedObjectsClient.createPointInTimeFinder({ + type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + namespaces: ['*'], + perPage: 1000, + sortField: 'updatedAt', + sortOrder: 'desc', + filter, + }); + } catch (e) { + logger.error( + `MW event generator: Failed instantiate a createPointInTimeFinder instance". Error: ${e.message}` + ); + return null; + } +} + +export async function generateEvents({ + maintenanceWindowsSO, + startRangeDate, +}: { + maintenanceWindowsSO: Array>; + startRangeDate: string; +}) { + const maintenanceWindows = maintenanceWindowsSO.map((savedObject) => + transformMaintenanceWindowAttributesToMaintenanceWindow({ + attributes: savedObject.attributes, + id: savedObject.id, + }) + ); + + // 1 year from the task run date (current date) till the end of the day + const newExpirationDate = moment().utc().endOf('day').add(1, 'year').toISOString(); + + try { + const mwWithGeneratedEvents = maintenanceWindows + // filtering the maintenance windows that have recurring schedule and events + .filter( + (maintenanceWindow) => + (maintenanceWindow.rRule.interval !== undefined || + maintenanceWindow.rRule.freq !== undefined) && + maintenanceWindow.events.length + ) + .map((filteredMaintenanceWindow) => { + const { rRule, duration, expirationDate: oldExpirationDate } = filteredMaintenanceWindow; + + const newEvents = generateMaintenanceWindowEventsViaRRule({ + rRule, + expirationDate: newExpirationDate, + duration, + startDate: startRangeDate, // here start range date is 1 week before current expiration date + }); + + return { + ...filteredMaintenanceWindow, + expirationDate: newEvents.length ? newExpirationDate : oldExpirationDate, + events: [...newEvents], + }; + }) + .filter((mappedMW) => mappedMW.expirationDate === newExpirationDate); + + return mwWithGeneratedEvents; + } catch (e) { + throw new Error(`Failed to generate events for maintenance windows. Error: ${e.message}`); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/plugin.ts b/x-pack/platform/plugins/shared/alerting/server/plugin.ts index 205aba6a75ad2..31709d2f6b3eb 100644 --- a/x-pack/platform/plugins/shared/alerting/server/plugin.ts +++ b/x-pack/platform/plugins/shared/alerting/server/plugin.ts @@ -88,6 +88,10 @@ import { scheduleApiKeyInvalidatorTask, } from './invalidate_pending_api_keys/task'; import { scheduleAlertingHealthCheck, initializeAlertingHealth } from './health'; +import { + initializeMaintenanceWindowEventsGenerator, + scheduleMaintenanceWindowEventsGenerator, +} from './maintenance_window_events/task'; import type { AlertingConfig, AlertingRulesConfig } from './config'; import { getHealth } from './health/get_health'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; @@ -415,6 +419,12 @@ export class AlertingPlugin { initializeAlertingHealth(this.logger, plugins.taskManager, core.getStartServices()); + initializeMaintenanceWindowEventsGenerator( + this.logger, + plugins.taskManager, + core.getStartServices + ); + core.http.registerRouteHandlerContext( 'alerting', this.createRouteHandlerContext(core) @@ -726,6 +736,7 @@ export class AlertingPlugin { scheduleApiKeyInvalidatorTask(this.telemetryLogger, this.config, plugins.taskManager).catch( () => {} ); // it shouldn't reject, but just in case + scheduleMaintenanceWindowEventsGenerator(this.logger, plugins.taskManager).catch(() => {}); return { listTypes: ruleTypeRegistry!.list.bind(this.ruleTypeRegistry!), diff --git a/x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/routes.ts b/x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index ef29b7a368126..7277bf0943dad 100644 --- a/x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/platform/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -477,6 +477,32 @@ export function defineRoutes( } ); + router.post( + { + path: `/api/alerts_fixture/maintenance_window_events_generation/_run_soon`, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: {}, + }, + async function ( + _: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const taskId = `maintenance-window:generate-events-generator`; + try { + const taskManager = await taskManagerStart; + return res.ok({ body: await taskManager.runSoon(taskId) }); + } catch (err) { + return res.ok({ body: { id: taskId, error: `${err}` } }); + } + } + ); + router.get( { path: '/api/alerts_fixture/rule/{id}/_get_api_key', diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/events_generation.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/events_generation.ts new file mode 100644 index 0000000000000..2565c4a5f6bb3 --- /dev/null +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/events_generation.ts @@ -0,0 +1,259 @@ +/* + * 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 moment from 'moment'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import type { TaskManagerDoc } from '../../../../common/lib'; +import { ObjectRemover } from '../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function eventsGenerationTaskTests({ getService }: FtrProviderContext) { + const retry = getService('retry'); + const es = getService('es'); + const supertest = getService('supertest'); + const objectRemover = new ObjectRemover(supertest); + + describe('Events Generation', () => { + let differenceInDays: number; + + before(async () => { + await createAndUpdateTestMaintenanceWindows(); + }); + afterEach(() => objectRemover.removeAll()); + + it('should generate events for MWs expiring within 1 week', async () => { + // Trigger task execution + await runEventsGenerationTask(); + + // verify task ran today + await retry.try(async () => { + const taskResult: TaskManagerDoc[] = await getTaskStatus(); + expect(taskResult.length).to.eql(1); + + const now = moment().utc(); + const runAt = taskResult[0].task.runAt; + differenceInDays = now.diff(moment(runAt), 'days'); + + expect(differenceInDays).to.be.eql(0); + + return taskResult; + }); + + // verify 2 maintenance windows are updated + await retry.try(async () => { + const maintenanceWindowsResult = await getUpdatedMaintenanceWindows(); + expect(maintenanceWindowsResult.length).to.eql(2); + + return maintenanceWindowsResult; + }); + }); + + const createAndUpdateTestMaintenanceWindows = async (): Promise => { + const maintenanceWindowIds: string[] = []; + + // Recurring which ends after a year + const recurring1 = await createMaintenanceWindow({ + title: 'Test recurring 1', + enabled: false, + schedule: { + custom: { + duration: '1h', + start: moment().utc().subtract(2, 'days').toISOString(), + recurring: { + every: '1d', + end: moment().utc().add(1, 'year').toISOString(), + }, + }, + }, + }); + + // should be updated as expiration date is within 1 week + await updateMaintenanceWindowSO({ + id: recurring1, + expirationDate: moment().utc().add(6, 'days').toISOString(), + }); + + maintenanceWindowIds.push(recurring1); + + // Recurring which ends after 10 days + const recurring2 = await createMaintenanceWindow({ + title: 'Test recurring 2', + enabled: false, + schedule: { + custom: { + duration: '20h', + start: moment().utc().subtract(6, 'months').toISOString(), + recurring: { + every: '1w', + onWeekDay: ['MO', 'FR'], + end: moment().utc().add(1, 'day').toISOString(), + }, + }, + }, + }); + + // should be updated as expiration date is within 1 week + await updateMaintenanceWindowSO({ + id: recurring2, + expirationDate: moment().utc().add(2, 'days').toISOString(), + }); + + maintenanceWindowIds.push(recurring2); + + // non recurring + const nonRecurring = await createMaintenanceWindow({ + title: 'Test non recurring', + schedule: { + custom: { + duration: '3d', + start: moment().utc().add(2, 'days').toISOString(), + }, + }, + }); + + // should not be updated as non recurring + await updateMaintenanceWindowSO({ + id: nonRecurring, + expirationDate: moment().utc().add(5, 'days').toISOString(), + }); + + maintenanceWindowIds.push(nonRecurring); + + // archived + const archived = await createMaintenanceWindow( + { + title: 'Test archived', + enabled: false, + schedule: { + custom: { + duration: '1h', + start: moment().utc().subtract(1, 'month').toISOString(), + recurring: { + every: '1M', + occurrences: 10, + onMonth: [2, 4, 6, 8, 10], + }, + }, + }, + }, + true + ); + + // should not be updated as archived + await updateMaintenanceWindowSO({ + id: archived, + expirationDate: moment().utc().subtract(5, 'days').toISOString(), + }); + + maintenanceWindowIds.push(archived); + + // running + const running = await createMaintenanceWindow({ + title: 'Test running', + enabled: false, + schedule: { + custom: { + duration: '8h', + start: moment().utc().subtract(3, 'hours').toISOString(), + recurring: { + every: '1d', + end: moment().utc().add(2, 'years').toISOString(), + onMonthDay: [1, 15], + }, + }, + }, + }); + + // should not be updated as expiration date is more than 1 week away + await updateMaintenanceWindowSO({ + id: running, + expirationDate: moment().utc().add(10, 'days').toISOString(), + }); + + maintenanceWindowIds.push(running); + + return maintenanceWindowIds; + }; + + const createMaintenanceWindow = async (params: object, archive = false): Promise => { + const res = await supertest + .post(`/api/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send(params) + .expect(200); + + if (archive) { + await supertest + .post(`/api/maintenance_window/${res.body.id}/_archive`) + .set('kbn-xsrf', 'true') + .expect(200); + } + return res.body.id; + }; + + async function updateMaintenanceWindowSO({ + id, + expirationDate, + }: { + id: string; + expirationDate: string; + }) { + await es.update({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `maintenance-window:${id}`, + doc: { + 'maintenance-window': { expirationDate }, + }, + }); + } + + async function runEventsGenerationTask() { + await supertest + .post('/api/alerts_fixture/maintenance_window_events_generation/_run_soon') + .set('kbn-xsrf', 'xxx') + .expect(200); + } + + // get maintenance windows which have expiration date after 1 year from today + async function getUpdatedMaintenanceWindows() { + const result = await es.search({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + query: { + bool: { + filter: [ + { term: { type: 'maintenance-window' } }, + { + range: { + 'maintenance-window.expirationDate': { + gte: moment().utc().startOf('day').add(1, 'year').toISOString(), + }, + }, + }, + ], + }, + }, + }); + + return result.hits.hits.map((hit) => hit._source); + } + + async function getTaskStatus(): Promise { + const result = await es.search({ + index: '.kibana_task_manager', + query: { + bool: { + filter: [{ term: { 'task.taskType': 'maintenance-window:generate-events' } }], + }, + }, + }); + + return result.hits.hits.map((hit) => hit._source) as TaskManagerDoc[]; + } + }); +} diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/index.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/index.ts index 63be04413a60c..f5718befa8ff1 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/index.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/index.ts @@ -37,6 +37,9 @@ export default function maintenanceWindowTests({ loadTestFile, getService }: Ftr loadTestFile(require.resolve('./internal/finish_maintenance_window')); loadTestFile(require.resolve('./internal/find_maintenance_windows')); loadTestFile(require.resolve('./internal/active_maintenance_windows')); + + // event generation task + loadTestFile(require.resolve('./events_generation')); }); }); } diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 29d1231e54811..a241e258dd273 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -166,6 +166,7 @@ export default function ({ getService }: FtrProviderContext) { 'fleet:upgrade-agentless-deployments-task', 'fleet:upgrade_action:retry', 'logs-data-telemetry', + 'maintenance-window:generate-events', 'osquery:telemetry-configs', 'osquery:telemetry-packs', 'osquery:telemetry-saved-queries',