diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 70be71f08184a..6294bb171a50e 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -351,7 +351,7 @@ enabled: - x-pack/test/reporting_functional/reporting_and_timeout.config.ts - x-pack/test/rule_registry/security_and_spaces/config_basic.ts - x-pack/test/rule_registry/security_and_spaces/config_trial.ts - - x-pack/test/rule_registry/spaces_only/config_basic.ts + - x-pack/test/rule_registry/spaces_only/config_trial_datastream.ts - x-pack/test/rule_registry/spaces_only/config_trial.ts - x-pack/test/saved_object_api_integration/security_and_spaces/config_basic.ts - x-pack/test/saved_object_api_integration/security_and_spaces/config_trial.ts diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 43df58235e68d..9b6dac38f0753 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -231,6 +231,7 @@ kibana_vars=( xpack.alerting.rules.run.actions.max xpack.alerting.rules.run.alerts.max xpack.alerting.rules.run.actions.connectorTypeOverrides + xpack.alerting.useDataStreamForAlerts xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval xpack.alerts.invalidateApiKeysTask.removalDelay diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts index e3942b26ee6fa..70916f5a75064 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { errors as EsErrors } from '@elastic/elasticsearch'; import { ReplaySubject, Subject } from 'rxjs'; import { AlertsService } from './alerts_service'; @@ -16,6 +17,7 @@ import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { AlertsClient } from '../alerts_client'; import { alertsClientMock } from '../alerts_client/alerts_client.mock'; +import { generateAlertingConfig } from '../test_utils'; jest.mock('../alerts_client'); @@ -63,6 +65,20 @@ const GetAliasResponse = { }, }; +const GetDataStreamResponse: IndicesGetDataStreamResponse = { + data_streams: [ + { + name: 'ignored', + generation: 1, + timestamp_field: { name: 'ignored' }, + hidden: true, + indices: [{ index_name: 'ignored', index_uuid: 'ignored' }], + status: 'green', + template: 'ignored', + }, + ], +}; + const IlmPutBody = { policy: { _meta: { @@ -88,6 +104,7 @@ interface GetIndexTemplatePutBodyOpts { useLegacyAlerts?: boolean; useEcs?: boolean; secondaryAlias?: string; + useDataStream?: boolean; } const getIndexTemplatePutBody = (opts?: GetIndexTemplatePutBodyOpts) => { const context = opts ? opts.context : undefined; @@ -95,25 +112,36 @@ const getIndexTemplatePutBody = (opts?: GetIndexTemplatePutBodyOpts) => { const useLegacyAlerts = opts ? opts.useLegacyAlerts : undefined; const useEcs = opts ? opts.useEcs : undefined; const secondaryAlias = opts ? opts.secondaryAlias : undefined; + const useDataStream = opts?.useDataStream ?? false; + + const indexPatterns = useDataStream + ? [`.alerts-${context ? context : 'test'}.alerts-${namespace}`] + : [`.internal.alerts-${context ? context : 'test'}.alerts-${namespace}-*`]; return { name: `.alerts-${context ? context : 'test'}.alerts-${namespace}-index-template`, body: { - index_patterns: [`.internal.alerts-${context ? context : 'test'}.alerts-${namespace}-*`], + index_patterns: indexPatterns, composed_of: [ ...(useEcs ? ['.alerts-ecs-mappings'] : []), `.alerts-${context ? `${context}.alerts` : 'test.alerts'}-mappings`, ...(useLegacyAlerts ? ['.alerts-legacy-alert-mappings'] : []), '.alerts-framework-mappings', ], + ...(useDataStream ? { data_stream: { hidden: true } } : {}), priority: namespace.length, template: { settings: { auto_expand_replicas: '0-1', hidden: true, - 'index.lifecycle': { - name: '.alerts-ilm-policy', - rollover_alias: `.alerts-${context ? context : 'test'}.alerts-${namespace}`, - }, + ...(useDataStream + ? {} + : { + 'index.lifecycle': { + name: '.alerts-ilm-policy', + rollover_alias: `.alerts-${context ? context : 'test'}.alerts-${namespace}`, + }, + }), + 'index.mapping.total_fields.limit': 2500, }, mappings: { @@ -186,7 +214,7 @@ describe('Alerts Service', () => { let pluginStop$: Subject; beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); logger = loggingSystemMock.createLogger(); pluginStop$ = new ReplaySubject(1); jest.spyOn(global.Math, 'random').mockReturnValue(0.01); @@ -195,1809 +223,2063 @@ describe('Alerts Service', () => { async () => SimulateTemplateResponse ); clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); }); afterEach(() => { pluginStop$.next(); pluginStop$.complete(); }); - describe('AlertsService()', () => { - test('should correctly initialize common resources', async () => { - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - expect(alertsService.isInitialized()).toEqual(true); - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(3); - - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - }); + for (const useDataStream of [false, true]) { + const label = useDataStream ? 'data streams' : 'aliases'; + const config = generateAlertingConfig(useDataStream); + + describe(`using ${label} for alert indices`, () => { + describe('AlertsService()', () => { + test('should correctly initialize common resources', async () => { + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + expect(alertsService.isInitialized()).toEqual(true); + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(3); + + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + }); - test('should log error and set initialized to false if adding ILM policy throws error', async () => { - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should log error and set initialized to false if adding ILM policy throws error', async () => { + if (useDataStream) return; - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); - expect(alertsService.isInitialized()).toEqual(false); + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - expect(logger.error).toHaveBeenCalledWith( - `Error installing ILM policy .alerts-ilm-policy - fail` - ); + expect(alertsService.isInitialized()).toEqual(false); - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - }); + expect(logger.error).toHaveBeenCalledWith( + `Error installing ILM policy .alerts-ilm-policy - fail` + ); - test('should log error and set initialized to false if creating/updating common component template throws error', async () => { - clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce(new Error('fail')); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); + }); - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + test('should log error and set initialized to false if creating/updating common component template throws error', async () => { + clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce(new Error('fail')); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); - expect(alertsService.isInitialized()).toEqual(false); - expect(logger.error).toHaveBeenCalledWith( - `Error installing component template .alerts-framework-mappings - fail` - ); + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - }); + expect(alertsService.isInitialized()).toEqual(false); + expect(logger.error).toHaveBeenCalledWith( + `Error installing component template .alerts-framework-mappings - fail` + ); - test('should update index template field limit and retry initialization if creating/updating common component template fails with field limit error', async () => { - clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce( - new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 400, - body: { - error: { - root_cause: [ - { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + }); + + test('should update index template field limit and retry initialization if creating/updating common component template fails with field limit error', async () => { + clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce( + new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { + error: { + root_cause: [ + { + type: 'illegal_argument_exception', + reason: + 'updating component template [.alerts-ecs-mappings] results in invalid composable template [.alerts-security.alerts-default-index-template] after templates are merged', + }, + ], type: 'illegal_argument_exception', reason: 'updating component template [.alerts-ecs-mappings] results in invalid composable template [.alerts-security.alerts-default-index-template] after templates are merged', - }, - ], - type: 'illegal_argument_exception', - reason: - 'updating component template [.alerts-ecs-mappings] results in invalid composable template [.alerts-security.alerts-default-index-template] after templates are merged', - caused_by: { - type: 'illegal_argument_exception', - reason: - 'composable template [.alerts-security.alerts-default-index-template] template after composition with component templates [.alerts-ecs-mappings, .alerts-security.alerts-mappings, .alerts-technical-mappings] is invalid', - caused_by: { - type: 'illegal_argument_exception', - reason: - 'invalid composite mappings for [.alerts-security.alerts-default-index-template]', caused_by: { type: 'illegal_argument_exception', - reason: 'Limit of total fields [1900] has been exceeded', + reason: + 'composable template [.alerts-security.alerts-default-index-template] template after composition with component templates [.alerts-ecs-mappings, .alerts-security.alerts-mappings, .alerts-technical-mappings] is invalid', + caused_by: { + type: 'illegal_argument_exception', + reason: + 'invalid composite mappings for [.alerts-security.alerts-default-index-template]', + caused_by: { + type: 'illegal_argument_exception', + reason: 'Limit of total fields [1900] has been exceeded', + }, + }, }, }, }, + }) + ) + ); + const existingIndexTemplate = { + name: 'test-template', + index_template: { + index_patterns: ['test*'], + composed_of: ['.alerts-framework-mappings'], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: '.alerts-ilm-policy', + rollover_alias: `.alerts-empty-default`, + }, + 'index.mapping.total_fields.limit': 1800, + }, + mappings: { + dynamic: false, + }, }, }, - }) - ) - ); - const existingIndexTemplate = { - name: 'test-template', - index_template: { - index_patterns: ['test*'], - composed_of: ['.alerts-framework-mappings'], - template: { - settings: { - auto_expand_replicas: '0-1', - hidden: true, - 'index.lifecycle': { - name: '.alerts-ilm-policy', - rollover_alias: `.alerts-empty-default`, + }; + clusterClient.indices.getIndexTemplate.mockResolvedValueOnce({ + index_templates: [existingIndexTemplate], + }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + expect(clusterClient.indices.getIndexTemplate).toHaveBeenCalledTimes(1); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(1); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith({ + name: existingIndexTemplate.name, + body: { + ...existingIndexTemplate.index_template, + template: { + ...existingIndexTemplate.index_template.template, + settings: { + ...existingIndexTemplate.index_template.template?.settings, + 'index.mapping.total_fields.limit': 2500, + }, }, - 'index.mapping.total_fields.limit': 1800, - }, - mappings: { - dynamic: false, - }, - }, - }, - }; - clusterClient.indices.getIndexTemplate.mockResolvedValueOnce({ - index_templates: [existingIndexTemplate], - }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - expect(clusterClient.indices.getIndexTemplate).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith({ - name: existingIndexTemplate.name, - body: { - ...existingIndexTemplate.index_template, - template: { - ...existingIndexTemplate.index_template.template, - settings: { - ...existingIndexTemplate.index_template.template?.settings, - 'index.mapping.total_fields.limit': 2500, }, - }, - }, - }); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - // 3x for framework, legacy-alert and ecs mappings, then 1 extra time to update component template - // after updating index template field limit - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - }); - }); + }); - describe('register()', () => { - let alertsService: AlertsService; - beforeEach(async () => { - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + // 3x for framework, legacy-alert and ecs mappings, then 1 extra time to update component template + // after updating index template field limit + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + }); }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - }); - - test('should correctly install resources for context when common initialization is complete', async () => { - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; - expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( - getIndexTemplatePutBody() - ); - expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, - }); - }); + describe('register()', () => { + let alertsService: AlertsService; + beforeEach(async () => { + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + }); - test('should correctly install resources for context when useLegacyAlerts is true', async () => { - alertsService.register({ ...TestRegistrationContext, useLegacyAlerts: true }); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; - expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( - getIndexTemplatePutBody({ useLegacyAlerts: true }) - ); - expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, - }); - }); + test('should correctly install resources for context when common initialization is complete', async () => { + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } else { + expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled(); + } + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; + expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( + getIndexTemplatePutBody({ useDataStream }) + ); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 2 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalledWith({ + expand_wildcards: 'all', + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-*', + name: '.alerts-test.alerts-*', + }); + } + }); - test('should correctly install resources for context when useEcs is true', async () => { - alertsService.register({ ...TestRegistrationContext, useEcs: true }); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; - expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( - getIndexTemplatePutBody({ useEcs: true }) - ); - expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, - }); - }); + test('should correctly install resources for context when useLegacyAlerts is true', async () => { + alertsService.register({ ...TestRegistrationContext, useLegacyAlerts: true }); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } else { + expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled(); + } + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; + expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( + getIndexTemplatePutBody({ useLegacyAlerts: true, useDataStream }) + ); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 2 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalledWith({ + expand_wildcards: 'all', + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-*', + name: '.alerts-test.alerts-*', + }); + } + }); - test('should correctly install resources for custom namespace on demand when isSpaceAware is true', async () => { - alertsService.register({ ...TestRegistrationContext, isSpaceAware: true }); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith( - 1, - getIndexTemplatePutBody() - ); - expect(clusterClient.indices.getAlias).toHaveBeenNthCalledWith(1, { - index: '.internal.alerts-test.alerts-default-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenNthCalledWith(1, { - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, - }); + test('should correctly install resources for context when useEcs is true', async () => { + alertsService.register({ ...TestRegistrationContext, useEcs: true }); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } else { + expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled(); + } + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; + expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( + getIndexTemplatePutBody({ useEcs: true, useDataStream }) + ); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 2 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenNthCalledWith(1, { + expand_wildcards: 'all', + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-*', + name: '.alerts-test.alerts-*', + }); + } + }); - await retryUntil( - 'context in namespace initialized', - async () => - (await getContextInitialized( - alertsService, - TestRegistrationContext.context, - 'another-namespace' - )) === true - ); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith( - 2, - getIndexTemplatePutBody({ namespace: 'another-namespace' }) - ); - expect(clusterClient.indices.getAlias).toHaveBeenNthCalledWith(2, { - index: '.internal.alerts-test.alerts-another-namespace-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.create).toHaveBeenNthCalledWith(2, { - index: '.internal.alerts-test.alerts-another-namespace-000001', - body: { - aliases: { - '.alerts-test.alerts-another-namespace': { - is_write_index: true, - }, - }, - }, - }); - }); + test('should correctly install resources for custom namespace on demand when isSpaceAware is true', async () => { + alertsService.register({ ...TestRegistrationContext, isSpaceAware: true }); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } else { + expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled(); + } + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith( + 1, + getIndexTemplatePutBody({ useDataStream }) + ); + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenNthCalledWith(1, { + expand_wildcards: 'all', + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenNthCalledWith(1, { + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenNthCalledWith(1, { + index: '.internal.alerts-test.alerts-default-*', + name: '.alerts-test.alerts-*', + }); + } + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 2 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + + clusterClient.indices.getDataStream.mockImplementationOnce(async () => ({ + data_streams: [], + })); + + await retryUntil( + 'context in namespace initialized', + async () => + (await getContextInitialized( + alertsService, + TestRegistrationContext.context, + 'another-namespace' + )) === true + ); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenNthCalledWith( + 2, + getIndexTemplatePutBody({ namespace: 'another-namespace', useDataStream }) + ); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 4); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 4 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 4); + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenNthCalledWith(1, { + name: '.alerts-test.alerts-another-namespace', + }); + expect(clusterClient.indices.getDataStream).toHaveBeenNthCalledWith(2, { + expand_wildcards: 'all', + name: '.alerts-test.alerts-another-namespace', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenNthCalledWith(2, { + index: '.internal.alerts-test.alerts-another-namespace-000001', + body: { + aliases: { + '.alerts-test.alerts-another-namespace': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenNthCalledWith(2, { + index: '.internal.alerts-test.alerts-another-namespace-*', + name: '.alerts-test.alerts-*', + }); + } + }); - test('should correctly install resources for context when secondaryAlias is defined', async () => { - alertsService.register({ ...TestRegistrationContext, secondaryAlias: 'another.alias' }); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; - expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( - getIndexTemplatePutBody({ secondaryAlias: 'another.alias' }) - ); - expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-*', - name: '.alerts-test.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, + test('should correctly install resources for context when secondaryAlias is defined', async () => { + if (useDataStream) return; + + alertsService.register({ ...TestRegistrationContext, secondaryAlias: 'another.alias' }); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + const componentTemplate4 = clusterClient.cluster.putComponentTemplate.mock.calls[3][0]; + expect(componentTemplate4.name).toEqual('.alerts-test.alerts-mappings'); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith( + getIndexTemplatePutBody({ secondaryAlias: 'another.alias', useDataStream }) + ); + expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-*', + name: '.alerts-test.alerts-*', + }); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, }, - }, - }, - }); - }); + }); + }); - test('should not install component template for context if fieldMap is empty', async () => { - alertsService.register({ - context: 'empty', - mappings: { fieldMap: {} }, - }); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService, 'empty')) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); - - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(3); - const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; - expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); - const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; - expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); - const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; - expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); - - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith({ - name: `.alerts-empty.alerts-default-index-template`, - body: { - index_patterns: [`.internal.alerts-empty.alerts-default-*`], - composed_of: ['.alerts-framework-mappings'], - priority: 7, - template: { - settings: { - auto_expand_replicas: '0-1', - hidden: true, - 'index.lifecycle': { - name: '.alerts-ilm-policy', - rollover_alias: `.alerts-empty.alerts-default`, + test('should not install component template for context if fieldMap is empty', async () => { + alertsService.register({ + context: 'empty', + mappings: { fieldMap: {} }, + }); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService, 'empty')) === true + ); + + if (!useDataStream) { + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith(IlmPutBody); + } else { + expect(clusterClient.ilm.putLifecycle).not.toHaveBeenCalled(); + } + + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(3); + const componentTemplate1 = clusterClient.cluster.putComponentTemplate.mock.calls[0][0]; + expect(componentTemplate1.name).toEqual('.alerts-framework-mappings'); + const componentTemplate2 = clusterClient.cluster.putComponentTemplate.mock.calls[1][0]; + expect(componentTemplate2.name).toEqual('.alerts-legacy-alert-mappings'); + const componentTemplate3 = clusterClient.cluster.putComponentTemplate.mock.calls[2][0]; + expect(componentTemplate3.name).toEqual('.alerts-ecs-mappings'); + + const template = { + name: `.alerts-empty.alerts-default-index-template`, + body: { + index_patterns: [ + useDataStream + ? `.alerts-empty.alerts-default` + : `.internal.alerts-empty.alerts-default-*`, + ], + composed_of: ['.alerts-framework-mappings'], + ...(useDataStream ? { data_stream: { hidden: true } } : {}), + priority: 7, + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + ...(useDataStream + ? {} + : { + 'index.lifecycle': { + name: '.alerts-ilm-policy', + rollover_alias: `.alerts-empty.alerts-default`, + }, + }), + 'index.mapping.total_fields.limit': 2500, + }, + mappings: { + _meta: { + kibana: { version: '8.8.0' }, + managed: true, + namespace: 'default', + }, + dynamic: false, + }, }, - 'index.mapping.total_fields.limit': 2500, - }, - mappings: { _meta: { kibana: { version: '8.8.0' }, managed: true, namespace: 'default', }, - dynamic: false, - }, - }, - _meta: { - kibana: { version: '8.8.0' }, - managed: true, - namespace: 'default', - }, - }, - }); - expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ - index: '.internal.alerts-empty.alerts-default-*', - name: '.alerts-empty.alerts-*', - }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-empty.alerts-default-000001', - body: { - aliases: { - '.alerts-empty.alerts-default': { - is_write_index: true, }, - }, - }, - }); - }); + }; + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(template); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalledWith({}); + expect(clusterClient.indices.getDataStream).toHaveBeenCalledWith({ + expand_wildcards: 'all', + name: '.alerts-empty.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-empty.alerts-default-000001', + body: { + aliases: { + '.alerts-empty.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + expect(clusterClient.indices.getAlias).toHaveBeenCalledWith({ + index: '.internal.alerts-empty.alerts-default-*', + name: '.alerts-empty.alerts-*', + }); + } + + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 1 : 2 + ); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + }); - test('should skip initialization if context already exists', async () => { - alertsService.register(TestRegistrationContext); - alertsService.register(TestRegistrationContext); + test('should skip initialization if context already exists', async () => { + alertsService.register(TestRegistrationContext); + alertsService.register(TestRegistrationContext); - expect(logger.debug).toHaveBeenCalledWith( - `Resources for context "test" have already been registered.` - ); - }); + expect(logger.debug).toHaveBeenCalledWith( + `Resources for context "test" have already been registered.` + ); + }); - test('should throw error if context already exists and has been registered with a different field map', async () => { - alertsService.register(TestRegistrationContext); - expect(() => { - alertsService.register({ - ...TestRegistrationContext, - mappings: { fieldMap: { anotherField: { type: 'keyword', required: false } } }, + test('should throw error if context already exists and has been registered with a different field map', async () => { + alertsService.register(TestRegistrationContext); + expect(() => { + alertsService.register({ + ...TestRegistrationContext, + mappings: { fieldMap: { anotherField: { type: 'keyword', required: false } } }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"test has already been registered with different options"` + ); }); - }).toThrowErrorMatchingInlineSnapshot( - `"test has already been registered with different options"` - ); - }); - test('should throw error if context already exists and has been registered with a different options', async () => { - alertsService.register(TestRegistrationContext); - expect(() => { - alertsService.register({ - ...TestRegistrationContext, - useEcs: true, + test('should throw error if context already exists and has been registered with a different options', async () => { + alertsService.register(TestRegistrationContext); + expect(() => { + alertsService.register({ + ...TestRegistrationContext, + useEcs: true, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"test has already been registered with different options"` + ); }); - }).toThrowErrorMatchingInlineSnapshot( - `"test has already been registered with different options"` - ); - }); - test('should not update index template if simulating template throws error', async () => { - clusterClient.indices.simulateTemplate.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(logger.error).toHaveBeenCalledWith( - `Failed to simulate index template mappings for .alerts-test.alerts-default-index-template; not applying mappings - fail` - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - // putIndexTemplate is skipped but other operations are called as expected - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); + test('should not update index template if simulating template throws error', async () => { + clusterClient.indices.simulateTemplate.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(logger.error).toHaveBeenCalledWith( + `Failed to simulate index template mappings for .alerts-test.alerts-default-index-template; not applying mappings - fail` + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + // putIndexTemplate is skipped but other operations are called as expected + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - test('should log error and set initialized to false if simulating template returns empty mappings', async () => { - clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ - ...SimulateTemplateResponse, - template: { - ...SimulateTemplateResponse.template, - mappings: {}, - }, - })); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ - result: false, - error: - 'Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping', - }); + test('should log error and set initialized to false if simulating template returns empty mappings', async () => { + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ + result: false, + error: + 'Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping', + }); + + expect(logger.error).toHaveBeenCalledWith( + new Error( + `No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping` + ) + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + }); - expect(logger.error).toHaveBeenCalledWith( - new Error( - `No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping` - ) - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('should log error and set initialized to false if updating index template throws error', async () => { + clusterClient.indices.putIndexTemplate.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ error: 'Failure during installation. fail', result: false }); + + expect(logger.error).toHaveBeenCalledWith( + `Error installing index template .alerts-test.alerts-default-index-template - fail` + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + }); - test('should log error and set initialized to false if updating index template throws error', async () => { - clusterClient.indices.putIndexTemplate.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ error: 'Failure during installation. fail', result: false }); - - expect(logger.error).toHaveBeenCalledWith( - `Error installing index template .alerts-test.alerts-default-index-template - fail` - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('should log error and set initialized to false if checking for concrete write index throws error', async () => { + clusterClient.indices.getAlias.mockRejectedValueOnce(new Error('fail')); + clusterClient.indices.getDataStream.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ error: 'Failure during installation. fail', result: false }); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Error fetching data stream for .alerts-test.alerts-default - fail` + : `Error fetching concrete indices for .internal.alerts-test.alerts-default-* pattern - fail` + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + }); - test('should log error and set initialized to false if checking for concrete write index throws error', async () => { - clusterClient.indices.getAlias.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ error: 'Failure during installation. fail', result: false }); - - expect(logger.error).toHaveBeenCalledWith( - `Error fetching concrete indices for .internal.alerts-test.alerts-default-* pattern - fail` - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('should not throw error if checking for concrete write index throws 404', async () => { + const error = new Error(`index doesn't exist`) as HTTPError; + error.statusCode = 404; + clusterClient.indices.getAlias.mockRejectedValueOnce(error); + clusterClient.indices.getDataStream.mockRejectedValueOnce(error); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + } + }); - test('should not throw error if checking for concrete write index throws 404', async () => { - const error = new Error(`index doesn't exist`) as HTTPError; - error.statusCode = 404; - clusterClient.indices.getAlias.mockRejectedValueOnce(error); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); + test('should log error and set initialized to false if updating index settings for existing indices throws error', async () => { + clusterClient.indices.putSettings.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ error: 'Failure during installation. fail', result: false }); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Failed to PUT index.mapping.total_fields.limit settings for .alerts-test.alerts-default: fail` + : `Failed to PUT index.mapping.total_fields.limit settings for alias_1: fail` + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - test('should log error and set initialized to false if updating index settings for existing indices throws error', async () => { - clusterClient.indices.putSettings.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ error: 'Failure during installation. fail', result: false }); - - expect(logger.error).toHaveBeenCalledWith( - `Failed to PUT index.mapping.total_fields.limit settings for alias alias_1: fail` - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('should skip updating index mapping for existing indices if simulate index template throws error', async () => { + clusterClient.indices.simulateIndexTemplate.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Ignored PUT mappings for .alerts-test.alerts-default; error generating simulated mappings: fail` + : `Ignored PUT mappings for alias_1; error generating simulated mappings: fail` + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + + // this is called to update backing indices, so not used with data streams + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + } + }); - test('should skip updating index mapping for existing indices if simulate index template throws error', async () => { - clusterClient.indices.simulateIndexTemplate.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(logger.error).toHaveBeenCalledWith( - `Ignored PUT mappings for alias alias_1; error generating simulated mappings: fail` - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); + test('should log error and set initialized to false if updating index mappings for existing indices throws error', async () => { + clusterClient.indices.putMapping.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ error: 'Failure during installation. fail', result: false }); + + if (useDataStream) { + expect(logger.error).toHaveBeenCalledWith( + `Failed to PUT mapping for .alerts-test.alerts-default: fail` + ); + } else { + expect(logger.error).toHaveBeenCalledWith(`Failed to PUT mapping for alias_1: fail`); + } + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - test('should log error and set initialized to false if updating index mappings for existing indices throws error', async () => { - clusterClient.indices.putMapping.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ error: 'Failure during installation. fail', result: false }); - - expect(logger.error).toHaveBeenCalledWith(`Failed to PUT mapping for alias alias_1: fail`); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('does not updating settings or mappings if no existing concrete indices', async () => { + clusterClient.indices.getAlias.mockImplementationOnce(async () => ({})); + clusterClient.indices.getDataStream.mockImplementationOnce(async () => ({ + data_streams: [], + })); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - test('does not updating settings or mappings if no existing concrete indices', async () => { - clusterClient.indices.getAlias.mockImplementationOnce(async () => ({})); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); + test('should log error and set initialized to false if concrete indices exist but none are write index', async () => { + // not applicable for data streams + if (useDataStream) return; - test('should log error and set initialized to false if concrete indices exist but none are write index', async () => { - clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-0001': { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: false, - is_hidden: true, - }, - alias_2: { - is_write_index: false, - is_hidden: true, + clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-0001': { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: false, + is_hidden: true, + }, + alias_2: { + is_write_index: false, + is_hidden: true, + }, + }, }, - }, - }, - })); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ - error: - 'Failure during installation. Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default', - result: false, - }); + })); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ + error: + 'Failure during installation. Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default', + result: false, + }); + + expect(logger.error).toHaveBeenCalledWith( + new Error( + `Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default` + ) + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + }); - expect(logger.error).toHaveBeenCalledWith( - new Error( - `Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default` - ) - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); + test('does not create new index if concrete write index exists', async () => { + // not applicable for data streams + if (useDataStream) return; - test('does not create new index if concrete write index exists', async () => { - clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-0001': { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - is_hidden: true, - }, - alias_2: { - is_write_index: false, - is_hidden: true, + clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-0001': { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + is_hidden: true, + }, + alias_2: { + is_write_index: false, + is_hidden: true, + }, + }, }, - }, - }, - })); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - }); - - test('should log error and set initialized to false if create concrete index throws error', async () => { - clusterClient.indices.create.mockRejectedValueOnce(new Error('fail')); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ error: 'Failure during installation. fail', result: false }); - - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); - - test('should not throw error if create concrete index throws resource_already_exists_exception error and write index already exists', async () => { - const error = new Error(`fail`) as EsError; - error.meta = { - body: { - error: { - type: 'resource_already_exists_exception', - }, - }, - }; - clusterClient.indices.create.mockRejectedValueOnce(error); - clusterClient.indices.get.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-000001': { - aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, - }, - })); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.get).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); - - test('should log error and set initialized to false if create concrete index throws resource_already_exists_exception error and write index does not already exists', async () => { - const error = new Error(`fail`) as EsError; - error.meta = { - body: { - error: { - type: 'resource_already_exists_exception', - }, - }, - }; - clusterClient.indices.create.mockRejectedValueOnce(error); - clusterClient.indices.get.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-000001': { - aliases: { '.alerts-test.alerts-default': { is_write_index: false } }, - }, - })); - - alertsService.register(TestRegistrationContext); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - expect( - await alertsService.getContextInitializationPromise( - TestRegistrationContext.context, - DEFAULT_NAMESPACE_STRING - ) - ).toEqual({ - error: - 'Failure during installation. Attempted to create index: .internal.alerts-test.alerts-default-000001 as the write index for alias: .alerts-test.alerts-default, but the index already exists and is not the write index for the alias', - result: false, - }); - - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); - expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.putMapping).toHaveBeenCalled(); - expect(clusterClient.indices.get).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - }); - }); - - describe('createAlertsClient()', () => { - let alertsService: AlertsService; - beforeEach(async () => { - (AlertsClient as jest.Mock).mockImplementation(() => alertsClient); - }); - - test('should create new AlertsClient', async () => { - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(AlertsClient).toHaveBeenCalledWith({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - kibanaVersion: '8.8.0', - }); - }); - - test('should return null if rule type has no alert definition', async () => { - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - const result = await alertsService.createAlertsClient({ - logger, - ruleType, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(result).toBe(null); - expect(AlertsClient).not.toHaveBeenCalled(); - }); - - test('should retry initializing common resources if common resource initialization failed', async () => { - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); - - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - - expect(alertsService.isInitialized()).toEqual(false); - - // Installing ILM policy failed so no calls to install context-specific resources - // should be made - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - const result = await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - - expect(AlertsClient).toHaveBeenCalledWith({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - kibanaVersion: '8.8.0', - }); - - expect(result).not.toBe(null); - expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.info).toHaveBeenCalledWith( - `Resource installation for "test" succeeded after retry` - ); - }); + })); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - test('should not retry initializing common resources if common resource initialization is in progress', async () => { - // this is the initial call that fails - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); + test('should log error and set initialized to false if create concrete index throws error', async () => { + // not applicable for data streams + if (useDataStream) return; + + clusterClient.indices.create.mockRejectedValueOnce(new Error('fail')); + clusterClient.indices.createDataStream.mockRejectedValueOnce(new Error('fail')); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ error: 'Failure during installation. fail', result: false }); + + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + }); - // this is the retry call that we'll artificially inflate the duration of - clusterClient.ilm.putLifecycle.mockImplementationOnce(async () => { - await new Promise((r) => setTimeout(r, 1000)); - return { acknowledged: true }; - }); + test('should not throw error if create concrete index throws resource_already_exists_exception error and write index already exists', async () => { + // not applicable for data streams + if (useDataStream) return; - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - - expect(alertsService.isInitialized()).toEqual(false); - - // Installing ILM policy failed so no calls to install context-specific resources - // should be made - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - // call createAlertsClient at the same time which will trigger the retries - const result = await Promise.all([ - alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, + const error = new Error(`fail`) as EsError; + error.meta = { + body: { + error: { + type: 'resource_already_exists_exception', + }, }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }), - alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, + }; + clusterClient.indices.create.mockRejectedValueOnce(error); + clusterClient.indices.get.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-000001': { + aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }), - ]); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).toHaveBeenCalled(); - expect(clusterClient.indices.create).toHaveBeenCalled(); - - expect(AlertsClient).toHaveBeenCalledWith({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - kibanaVersion: '8.8.0', - }); - - expect(result[0]).not.toBe(null); - expect(result[1]).not.toBe(null); - expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.info).toHaveBeenCalledWith( - `Resource installation for "test" succeeded after retry` - ); - expect(logger.info).toHaveBeenCalledWith( - `Skipped retrying common resource initialization because it is already being retried.` - ); - }); - - test('should retry initializing context specific resources if context specific resource initialization failed', async () => { - clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ - ...SimulateTemplateResponse, - template: { - ...SimulateTemplateResponse.template, - mappings: {}, - }, - })); - - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - const result = await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(AlertsClient).toHaveBeenCalledWith({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - kibanaVersion: '8.8.0', - }); - - expect(result).not.toBe(null); - expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.info).toHaveBeenCalledWith( - `Resource installation for "test" succeeded after retry` - ); - }); - - test('should not retry initializing context specific resources if context specific resource initialization is in progress', async () => { - // this is the initial call that fails - clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ - ...SimulateTemplateResponse, - template: { - ...SimulateTemplateResponse.template, - mappings: {}, - }, - })); + })); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + expect(clusterClient.indices.get).toHaveBeenCalled(); + expect(clusterClient.indices.create).toHaveBeenCalled(); + }); - // this is the retry call that we'll artificially inflate the duration of - clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => { - await new Promise((r) => setTimeout(r, 1000)); - return SimulateTemplateResponse; - }); + test('should log error and set initialized to false if create concrete index throws resource_already_exists_exception error and write index does not already exists', async () => { + // not applicable for data streams + if (useDataStream) return; - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - const createAlertsClientWithDelay = async (delayMs: number | null) => { - if (delayMs) { - await new Promise((r) => setTimeout(r, delayMs)); - } - - return await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, + const error = new Error(`fail`) as EsError; + error.meta = { + body: { + error: { + type: 'resource_already_exists_exception', + }, }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, + }; + clusterClient.indices.create.mockRejectedValueOnce(error); + clusterClient.indices.get.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-000001': { + aliases: { '.alerts-test.alerts-default': { is_write_index: false } }, + }, + })); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + expect( + await alertsService.getContextInitializationPromise( + TestRegistrationContext.context, + DEFAULT_NAMESPACE_STRING + ) + ).toEqual({ + error: + 'Failure during installation. Attempted to create index: .internal.alerts-test.alerts-default-000001 as the write index for alias: .alerts-test.alerts-default, but the index already exists and is not the write index for the alias', + result: false, + }); + + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalled(); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4); + expect(clusterClient.indices.simulateTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putMapping).toHaveBeenCalled(); + expect(clusterClient.indices.get).toHaveBeenCalled(); + expect(clusterClient.indices.create).toHaveBeenCalled(); }); - }; - - const result = await Promise.all([ - createAlertsClientWithDelay(null), - createAlertsClientWithDelay(1), - ]); - - expect(AlertsClient).toHaveBeenCalledTimes(2); - expect(AlertsClient).toHaveBeenCalledWith({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - kibanaVersion: '8.8.0', }); - expect(result[0]).not.toBe(null); - expect(result[1]).not.toBe(null); - expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); - - // Should only log the retry once because the second call should - // leverage the outcome of the first retry - expect( - logger.info.mock.calls.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (calls: any[]) => calls[0] === `Retrying resource initialization for context "test"` - ).length - ).toEqual(1); - expect( - logger.info.mock.calls.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (calls: any[]) => calls[0] === `Resource installation for "test" succeeded after retry` - ).length - ).toEqual(1); - }); + describe('createAlertsClient()', () => { + let alertsService: AlertsService; + beforeEach(async () => { + (AlertsClient as jest.Mock).mockImplementation(() => alertsClient); + }); - test('should throttle retries of initializing context specific resources', async () => { - // this is the initial call that fails - clusterClient.indices.simulateTemplate.mockImplementation(async () => ({ - ...SimulateTemplateResponse, - template: { - ...SimulateTemplateResponse.template, - mappings: {}, - }, - })); + test('should create new AlertsClient', async () => { + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - const createAlertsClientWithDelay = async (delayMs: number | null) => { - if (delayMs) { - await new Promise((r) => setTimeout(r, delayMs)); - } - - return await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, + kibanaVersion: '8.8.0', + }); }); - }; - - await Promise.all([ - createAlertsClientWithDelay(null), - createAlertsClientWithDelay(1), - createAlertsClientWithDelay(2), - ]); - - expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); - - // Should only log the retry once because the second and third retries should be throttled - expect( - logger.info.mock.calls.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (calls: any[]) => calls[0] === `Retrying resource initialization for context "test"` - ).length - ).toEqual(1); - }); - - test('should return null if retrying common resources initialization fails again', async () => { - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail again')); - - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - - expect(alertsService.isInitialized()).toEqual(false); - - // Installing ILM policy failed so no calls to install context-specific resources - // should be made - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - const result = await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - expect(result).toBe(null); - expect(AlertsClient).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.warn).toHaveBeenCalledWith( - `There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Original error: Failure during installation. fail; Error after retry: Failure during installation. fail again` - ); - }); - test('should return null if retrying common resources initialization fails again with same error', async () => { - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); - clusterClient.ilm.putLifecycle.mockRejectedValueOnce(new Error('fail')); - - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); - - expect(alertsService.isInitialized()).toEqual(false); - - // Installing ILM policy failed so no calls to install context-specific resources - // should be made - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(1); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - const result = await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); - expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); - expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); - expect(clusterClient.indices.create).not.toHaveBeenCalled(); - - expect(result).toBe(null); - expect(AlertsClient).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.warn).toHaveBeenCalledWith( - `There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Retry failed with error: Failure during installation. fail` - ); - }); - - test('should return null if retrying context specific initialization fails again', async () => { - clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ - ...SimulateTemplateResponse, - template: { - ...SimulateTemplateResponse.template, - mappings: {}, - }, - })); - clusterClient.indices.putIndexTemplate.mockRejectedValueOnce( - new Error('fail index template') - ); - - alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); - alertsService.register(TestRegistrationContext); - - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - const result = await alertsService.createAlertsClient({ - logger, - ruleType: ruleTypeWithAlertDefinition, - namespace: 'default', - rule: { - consumer: 'bar', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - id: '1', - name: 'rule-name', - parameters: { - bar: true, - }, - revision: 0, - spaceId: 'default', - tags: ['rule-', '-tags'], - }, - }); - - expect(AlertsClient).not.toHaveBeenCalled(); - expect(result).toBe(null); - expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); - expect(logger.info).toHaveBeenCalledWith( - `Retrying resource initialization for context "test"` - ); - expect(logger.warn).toHaveBeenCalledWith( - `There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Original error: Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping; Error after retry: Failure during installation. fail index template` - ); - }); - }); - - describe('retries', () => { - test('should retry adding ILM policy for transient ES errors', async () => { - clusterClient.ilm.putLifecycle - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should return null if rule type has no alert definition', async () => { + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + const result = await alertsService.createAlertsClient({ + logger, + ruleType, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(3); - }); + expect(result).toBe(null); + expect(AlertsClient).not.toHaveBeenCalled(); + }); - test('should retry adding component template for transient ES errors', async () => { - clusterClient.cluster.putComponentTemplate - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should retry initializing common resources if common resource initialization failed', async () => { + clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce(new Error('fail')); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + + expect(alertsService.isInitialized()).toEqual(false); + + // Installing ILM policy failed so no calls to install context-specific resources + // should be made + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 2); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + kibanaVersion: '8.8.0', + }); + + expect(result).not.toBe(null); + expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.info).toHaveBeenCalledWith( + `Resource installation for "test" succeeded after retry` + ); + }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(5); - }); + test('should not retry initializing common resources if common resource initialization is in progress', async () => { + // this is the initial call that fails + clusterClient.cluster.putComponentTemplate.mockRejectedValueOnce(new Error('fail')); + + // this is the retry call that we'll artificially inflate the duration of + clusterClient.cluster.putComponentTemplate.mockImplementationOnce(async () => { + await new Promise((r) => setTimeout(r, 1000)); + return { acknowledged: true }; + }); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + + expect(alertsService.isInitialized()).toEqual(false); + + // Installing ILM policy failed so no calls to install context-specific resources + // should be made + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + // call createAlertsClient at the same time which will trigger the retries + const result = await Promise.all([ + alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }), + alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }), + ]); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 2); + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).toHaveBeenCalled(); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + expect(clusterClient.indices.getDataStream).toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).toHaveBeenCalled(); + } + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + kibanaVersion: '8.8.0', + }); + + expect(result[0]).not.toBe(null); + expect(result[1]).not.toBe(null); + expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.info).toHaveBeenCalledWith( + `Resource installation for "test" succeeded after retry` + ); + expect(logger.info).toHaveBeenCalledWith( + `Skipped retrying common resource initialization because it is already being retried.` + ); + }); - test('should retry updating index template for transient ES errors', async () => { - clusterClient.indices.putIndexTemplate - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should retry initializing context specific resources if context specific resource initialization failed', async () => { + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - expect(alertsService.isInitialized()).toEqual(true); + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + kibanaVersion: '8.8.0', + }); + + expect(result).not.toBe(null); + expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.info).toHaveBeenCalledWith( + `Resource installation for "test" succeeded after retry` + ); + }); - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); + test('should not retry initializing context specific resources if context specific resource initialization is in progress', async () => { + // this is the initial call that fails + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + // this is the retry call that we'll artificially inflate the duration of + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => { + await new Promise((r) => setTimeout(r, 1000)); + return SimulateTemplateResponse; + }); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + const createAlertsClientWithDelay = async (delayMs: number | null) => { + if (delayMs) { + await new Promise((r) => setTimeout(r, delayMs)); + } - expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(3); - }); + return await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + }; + + const result = await Promise.all([ + createAlertsClientWithDelay(null), + createAlertsClientWithDelay(1), + ]); + + expect(AlertsClient).toHaveBeenCalledTimes(2); + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + kibanaVersion: '8.8.0', + }); + + expect(result[0]).not.toBe(null); + expect(result[1]).not.toBe(null); + expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); + + // Should only log the retry once because the second call should + // leverage the outcome of the first retry + expect( + logger.info.mock.calls.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (calls: any[]) => calls[0] === `Retrying resource initialization for context "test"` + ).length + ).toEqual(1); + expect( + logger.info.mock.calls.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (calls: any[]) => + calls[0] === `Resource installation for "test" succeeded after retry` + ).length + ).toEqual(1); + }); - test('should retry updating index settings for existing indices for transient ES errors', async () => { - clusterClient.indices.putSettings - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should throttle retries of initializing context specific resources', async () => { + // this is the initial call that fails + clusterClient.indices.simulateTemplate.mockImplementation(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + const createAlertsClientWithDelay = async (delayMs: number | null) => { + if (delayMs) { + await new Promise((r) => setTimeout(r, delayMs)); + } - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); + return await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + }; + + await Promise.all([ + createAlertsClientWithDelay(null), + createAlertsClientWithDelay(1), + createAlertsClientWithDelay(2), + ]); + + expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); + + // Should only log the retry once because the second and third retries should be throttled + expect( + logger.info.mock.calls.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (calls: any[]) => calls[0] === `Retrying resource initialization for context "test"` + ).length + ).toEqual(1); + }); - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); + test('should return null if retrying common resources initialization fails again', async () => { + let failCount = 0; + clusterClient.cluster.putComponentTemplate.mockImplementation(() => { + throw new Error(`fail ${++failCount}`); + }); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + + expect(alertsService.isInitialized()).toEqual(false); + + // Installing ILM policy failed so no calls to install context-specific resources + // should be made + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 2); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + expect(result).toBe(null); + expect(AlertsClient).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringMatching( + /There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Original error: Failure during installation\. fail \d+; Error after retry: Failure during installation\. fail \d+/ + ) + ); + }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(4); - }); + test('should return null if retrying common resources initialization fails again with same error', async () => { + clusterClient.cluster.putComponentTemplate.mockRejectedValue(new Error('fail')); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil('error log called', async () => logger.error.mock.calls.length > 0); + + expect(alertsService.isInitialized()).toEqual(false); + + // Installing component template failed so no calls to install context-specific resources + // should be made + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 1); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(useDataStream ? 0 : 2); + expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled(); + expect(clusterClient.indices.getAlias).not.toHaveBeenCalled(); + expect(clusterClient.indices.putSettings).not.toHaveBeenCalled(); + expect(clusterClient.indices.create).not.toHaveBeenCalled(); + + expect(result).toBe(null); + expect(AlertsClient).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.warn).toHaveBeenCalledWith( + `There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Retry failed with error: Failure during installation. fail` + ); + }); - test('should retry updating index mappings for existing indices for transient ES errors', async () => { - clusterClient.indices.putMapping - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', + test('should return null if retrying context specific initialization fails again', async () => { + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + clusterClient.indices.putIndexTemplate.mockRejectedValueOnce( + new Error('fail index template') + ); + + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + alertsService.register(TestRegistrationContext); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(AlertsClient).not.toHaveBeenCalled(); + expect(result).toBe(null); + expect(logger.info).not.toHaveBeenCalledWith(`Retrying common resource initialization`); + expect(logger.info).toHaveBeenCalledWith( + `Retrying resource initialization for context "test"` + ); + expect(logger.warn).toHaveBeenCalledWith( + `There was an error in the framework installing namespace-level resources and creating concrete indices for context "test" - Original error: Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping; Error after retry: Failure during installation. fail index template` + ); + }); }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); - - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); - - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(4); - }); + describe('retries', () => { + test('should retry adding ILM policy for transient ES errors', async () => { + if (useDataStream) return; + + clusterClient.ilm.putLifecycle + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(3); + }); - test('should retry creating concrete index for transient ES errors', async () => { - clusterClient.indices.create - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ index: 'index', shards_acknowledged: true, acknowledged: true }); - const alertsService = new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - }); + test('should retry adding component template for transient ES errors', async () => { + clusterClient.cluster.putComponentTemplate + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(5); + }); - await retryUntil( - 'alert service initialized', - async () => alertsService.isInitialized() === true - ); + test('should retry updating index template for transient ES errors', async () => { + clusterClient.indices.putIndexTemplate + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + expect(alertsService.isInitialized()).toEqual(true); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(3); + }); - alertsService.register(TestRegistrationContext); - await retryUntil( - 'context initialized', - async () => (await getContextInitialized(alertsService)) === true - ); + test('should retry updating index settings for existing indices for transient ES errors', async () => { + clusterClient.indices.putSettings + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (useDataStream) { + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(3); + } else { + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(4); + } + }); - expect(clusterClient.indices.create).toHaveBeenCalledTimes(3); - }); - }); + test('should retry updating index mappings for existing indices for transient ES errors', async () => { + clusterClient.indices.putMapping + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 3 : 4); + }); - describe('timeout', () => { - test('should short circuit initialization if timeout exceeded', async () => { - clusterClient.ilm.putLifecycle.mockImplementationOnce(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - return { acknowledged: true }; - }); - new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - timeoutMs: 10, + test('should retry creating concrete index for transient ES errors', async () => { + clusterClient.indices.getDataStream.mockImplementationOnce(async () => ({ + data_streams: [], + })); + clusterClient.indices.createDataStream + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + clusterClient.indices.create + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ index: 'index', shards_acknowledged: true, acknowledged: true }); + const alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + config, + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalledTimes(3); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledTimes(3); + } + }); }); - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - - expect(logger.error).toHaveBeenCalledWith(new Error(`Timeout: it took more than 10ms`)); - }); + describe('timeout', () => { + test('should short circuit initialization if timeout exceeded', async () => { + clusterClient.cluster.putComponentTemplate.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { acknowledged: true }; + }); + new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + timeoutMs: 10, + config, + }); + + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + + expect(logger.error).toHaveBeenCalledWith(new Error(`Timeout: it took more than 10ms`)); + }); - test('should short circuit initialization if pluginStop$ signal received but not throw error', async () => { - pluginStop$.next(); - new AlertsService({ - logger, - elasticsearchClientPromise: Promise.resolve(clusterClient), - pluginStop$, - kibanaVersion: '8.8.0', - timeoutMs: 10, + test('should short circuit initialization if pluginStop$ signal received but not throw error', async () => { + pluginStop$.next(); + new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + timeoutMs: 10, + config, + }); + + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + + expect(logger.error).toHaveBeenCalledWith( + new Error(`Server is stopping; must stop all async operations`) + ); + }); }); - - await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); - - expect(logger.error).toHaveBeenCalledWith( - new Error(`Server is stopping; must stop all async operations`) - ); }); - }); + } }); diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts index e8ecab61e76d9..1734257a8759f 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts @@ -20,6 +20,8 @@ import { getIndexTemplateAndPattern, } from './resource_installer_utils'; import { AlertInstanceContext, AlertInstanceState, IRuleTypeAlerts, RuleAlertData } from '../types'; +import { AlertingConfig } from '../config'; +import { DataStreamAdapter, getDataStreamAdapter } from './lib/data_stream_adapter'; import { createResourceInstallationHelper, errorResult, @@ -47,6 +49,7 @@ interface AlertsServiceParams { logger: Logger; pluginStop$: Observable; kibanaVersion: string; + config: AlertingConfig; elasticsearchClientPromise: Promise; timeoutMs?: number; } @@ -114,10 +117,13 @@ export class AlertsService implements IAlertsService { private resourceInitializationHelper: ResourceInstallationHelper; private registeredContexts: Map = new Map(); private commonInitPromise: Promise; + private dataStreamAdapter: DataStreamAdapter; constructor(private readonly options: AlertsServiceParams) { this.initialized = false; + this.dataStreamAdapter = getDataStreamAdapter(options.config); + // Kick off initialization of common assets and save the promise this.commonInitPromise = this.initializeCommon(this.options.timeoutMs); @@ -296,6 +302,7 @@ export class AlertsService implements IAlertsService { esClient, name: DEFAULT_ALERTS_ILM_POLICY_NAME, policy: DEFAULT_ALERTS_ILM_POLICY, + dataStreamAdapter: this.dataStreamAdapter, }), () => createOrUpdateComponentTemplate({ @@ -421,6 +428,7 @@ export class AlertsService implements IAlertsService { kibanaVersion: this.options.kibanaVersion, namespace, totalFieldsLimit: TOTAL_FIELDS_LIMIT, + dataStreamAdapter: this.dataStreamAdapter, }), }), async () => @@ -429,6 +437,7 @@ export class AlertsService implements IAlertsService { esClient, totalFieldsLimit: TOTAL_FIELDS_LIMIT, indexPatterns: indexTemplateAndPattern, + dataStreamAdapter: this.dataStreamAdapter, }), ]); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.test.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.test.ts index a4cb6a26d3767..e2ee309b123f5 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.test.ts @@ -6,7 +6,9 @@ */ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { errors as EsErrors } from '@elastic/elasticsearch'; +import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { createConcreteWriteIndex } from './create_concrete_write_index'; +import { getDataStreamAdapter } from './data_stream_adapter'; const randomDelayMultiplier = 0.01; const logger = loggingSystemMock.createLogger(); @@ -36,6 +38,10 @@ const GetAliasResponse = { }, }; +const GetDataStreamResponse = { + data_streams: ['any-content-here-means-already-exists'], +} as unknown as IndicesGetDataStreamResponse; + const SimulateTemplateResponse = { template: { aliases: { @@ -60,483 +66,609 @@ const IndexPatterns = { }; describe('createConcreteWriteIndex', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier); - }); - - it(`should call esClient to put index template`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + for (const useDataStream of [false, true]) { + const label = useDataStream ? 'data streams' : 'aliases'; + const dataStreamAdapter = getDataStreamAdapter({ useDataStreamForAlerts: useDataStream }); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier); }); - }); - - it(`should retry on transient ES errors`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - clusterClient.indices.create - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ - index: '.internal.alerts-test.alerts-default-000001', - shards_acknowledged: true, - acknowledged: true, + + describe(`using ${label} for alert indices`, () => { + it(`should call esClient to put index template`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + clusterClient.indices.getDataStream.mockImplementation(async () => ({ data_streams: [] })); + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalledWith({ + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + } }); - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); - expect(clusterClient.indices.create).toHaveBeenCalledTimes(3); - }); - - it(`should log and throw error if max retries exceeded`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - clusterClient.indices.create.mockRejectedValue(new EsErrors.ConnectionError('foo')); - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); - - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - foo`); - expect(clusterClient.indices.create).toHaveBeenCalledTimes(4); - }); - - it(`should log and throw error if ES throws error`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - clusterClient.indices.create.mockRejectedValueOnce(new Error('generic error')); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); - - expect(logger.error).toHaveBeenCalledWith( - `Error creating concrete write index - generic error` - ); - }); - - it(`should log and return if ES throws resource_already_exists_exception error and existing index is already write index`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - const error = new Error(`fail`) as EsError; - error.meta = { - body: { - error: { - type: 'resource_already_exists_exception', - }, - }, - }; - clusterClient.indices.create.mockRejectedValueOnce(error); - clusterClient.indices.get.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-000001': { - aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, - }, - })); + it(`should retry on transient ES errors`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + clusterClient.indices.getDataStream.mockImplementation(async () => ({ data_streams: [] })); + clusterClient.indices.create + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ + index: '.internal.alerts-test.alerts-default-000001', + shards_acknowledged: true, + acknowledged: true, + }); + clusterClient.indices.createDataStream + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ + acknowledged: true, + }); + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalledTimes(3); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledTimes(3); + } + }); - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + it(`should log and throw error if max retries exceeded`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + clusterClient.indices.getDataStream.mockImplementation(async () => ({ data_streams: [] })); + clusterClient.indices.create.mockRejectedValue(new EsErrors.ConnectionError('foo')); + clusterClient.indices.createDataStream.mockRejectedValue( + new EsErrors.ConnectionError('foo') + ); + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Error creating data stream .alerts-test.alerts-default - foo` + : `Error creating concrete write index - foo` + ); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalledTimes(4); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledTimes(4); + } + }); - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - }); - - it(`should retry getting index on transient ES error`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - const error = new Error(`fail`) as EsError; - error.meta = { - body: { - error: { - type: 'resource_already_exists_exception', - }, - }, - }; - clusterClient.indices.create.mockRejectedValueOnce(error); - clusterClient.indices.get - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-000001': { - aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, - }, - })); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + it(`should log and throw error if ES throws error`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + clusterClient.indices.getDataStream.mockImplementation(async () => ({ data_streams: [] })); + clusterClient.indices.create.mockRejectedValueOnce(new Error('generic error')); + clusterClient.indices.createDataStream.mockRejectedValueOnce(new Error('generic error')); + + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Error creating data stream .alerts-test.alerts-default - generic error` + : `Error creating concrete write index - generic error` + ); + }); - expect(clusterClient.indices.get).toHaveBeenCalledTimes(3); - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - }); - - it(`should log and throw error if ES throws resource_already_exists_exception error and existing index is not the write index`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => ({})); - const error = new Error(`fail`) as EsError; - error.meta = { - body: { - error: { - type: 'resource_already_exists_exception', - }, - }, - }; - clusterClient.indices.create.mockRejectedValueOnce(error); - clusterClient.indices.get.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-000001': { - aliases: { '.alerts-test.alerts-default': { is_write_index: false } }, - }, - })); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Attempted to create index: .internal.alerts-test.alerts-default-000001 as the write index for alias: .alerts-test.alerts-default, but the index already exists and is not the write index for the alias"` - ); - expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); - }); - - it(`should call esClient to put index template if get alias throws 404`, async () => { - const error = new Error(`not found`) as EsError; - error.statusCode = 404; - clusterClient.indices.getAlias.mockRejectedValueOnce(error); - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + it(`should log and return if ES throws resource_already_exists_exception error and existing index is already write index`, async () => { + if (useDataStream) return; - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + const error = new Error(`fail`) as EsError; + error.meta = { + body: { + error: { + type: 'resource_already_exists_exception', + }, }, - }, - }, - }); - }); - - it(`should log and throw error if get alias throws non-404 error`, async () => { - const error = new Error(`fatal error`) as EsError; - error.statusCode = 500; - clusterClient.indices.getAlias.mockRejectedValueOnce(error); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"fatal error"`); - expect(logger.error).toHaveBeenCalledWith( - `Error fetching concrete indices for .internal.alerts-test.alerts-default-* pattern - fatal error` - ); - }); - - it(`should update underlying settings and mappings of existing concrete indices if they exist`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + }; + clusterClient.indices.create.mockRejectedValueOnce(error); + clusterClient.indices.get.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-000001': { + aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, + }, + })); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + }); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, + it(`should retry getting index on transient ES error`, async () => { + if (useDataStream) return; + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + const error = new Error(`fail`) as EsError; + error.statusCode = 404; + error.meta = { + body: { + error: { + type: 'resource_already_exists_exception', + }, }, - }, - }, - }); + }; + clusterClient.indices.create.mockRejectedValueOnce(error); + clusterClient.indices.get + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-000001': { + aliases: { '.alerts-test.alerts-default': { is_write_index: true } }, + }, + })); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(clusterClient.indices.get).toHaveBeenCalledTimes(3); + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(2); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(2); - }); - - it(`should retry simulateIndexTemplate on transient ES errors`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockImplementation(async () => SimulateTemplateResponse); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + it(`should log and throw error if ES throws resource_already_exists_exception error and existing index is not the write index`, async () => { + if (useDataStream) return; - expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes(4); - }); - - it(`should retry getting alias on transient ES errors`, async () => { - clusterClient.indices.getAlias - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + clusterClient.indices.getAlias.mockImplementation(async () => ({})); + const error = new Error(`fail`) as EsError; + error.meta = { + body: { + error: { + type: 'resource_already_exists_exception', + }, + }, + }; + clusterClient.indices.create.mockRejectedValueOnce(error); + clusterClient.indices.get.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-000001': { + aliases: { '.alerts-test.alerts-default': { is_write_index: false } }, + }, + })); - expect(clusterClient.indices.getAlias).toHaveBeenCalledTimes(3); - }); - - it(`should retry settings update on transient ES errors`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putSettings - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + const ccwiPromise = createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(4); - }); - - it(`should log and throw error on settings update if max retries exceeded`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putSettings.mockRejectedValue(new EsErrors.ConnectionError('foo')); - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); - expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(7); - expect(logger.error).toHaveBeenCalledWith( - `Failed to PUT index.mapping.total_fields.limit settings for alias alias_1: foo` - ); - }); - - it(`should log and throw error on settings update if ES throws error`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putSettings.mockRejectedValue(new Error('generic error')); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); - - expect(logger.error).toHaveBeenCalledWith( - `Failed to PUT index.mapping.total_fields.limit settings for alias alias_1: generic error` - ); - }); - - it(`should retry mappings update on transient ES errors`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putMapping - .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) - .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) - .mockResolvedValue({ acknowledged: true }); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + await expect(() => ccwiPromise).rejects.toThrowErrorMatchingInlineSnapshot( + `"Attempted to create index: .internal.alerts-test.alerts-default-000001 as the write index for alias: .alerts-test.alerts-default, but the index already exists and is not the write index for the alias"` + ); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(4); - }); - - it(`should log and throw error on mappings update if max retries exceeded`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putMapping.mockRejectedValue(new EsErrors.ConnectionError('foo')); - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); - expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(7); - expect(logger.error).toHaveBeenCalledWith(`Failed to PUT mapping for alias alias_1: foo`); - }); - - it(`should log and throw error on mappings update if ES throws error`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - clusterClient.indices.putMapping.mockRejectedValue(new Error('generic error')); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); - - expect(logger.error).toHaveBeenCalledWith( - `Failed to PUT mapping for alias alias_1: generic error` - ); - }); - - it(`should log and return when simulating updated mappings throws error`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockRejectedValueOnce(new Error('fail')); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + expect(logger.error).toHaveBeenCalledWith(`Error creating concrete write index - fail`); + }); - expect(logger.error).toHaveBeenCalledWith( - `Ignored PUT mappings for alias alias_1; error generating simulated mappings: fail` - ); + it(`should call esClient to put index template if get alias throws 404`, async () => { + const error = new Error(`not found`) as EsError; + error.statusCode = 404; + clusterClient.indices.getAlias.mockRejectedValueOnce(error); + clusterClient.indices.getDataStream.mockRejectedValueOnce(error); + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).toHaveBeenCalledWith({ + name: '.alerts-test.alerts-default', + }); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + } + }); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, - }, - }, - }, - }); - }); - - it(`should log and return when simulating updated mappings returns null`, async () => { - clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); - clusterClient.indices.simulateIndexTemplate.mockImplementationOnce(async () => ({ - ...SimulateTemplateResponse, - template: { ...SimulateTemplateResponse.template, mappings: null }, - })); - - await createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }); + it(`should log and throw error if get alias throws non-404 error`, async () => { + const error = new Error(`fatal error`) as EsError; + error.statusCode = 500; + clusterClient.indices.getAlias.mockRejectedValueOnce(error); + clusterClient.indices.getDataStream.mockRejectedValueOnce(error); + + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"fatal error"`); + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Error fetching data stream for .alerts-test.alerts-default - fatal error` + : `Error fetching concrete indices for .internal.alerts-test.alerts-default-* pattern - fatal error` + ); + }); - expect(logger.error).toHaveBeenCalledWith( - `Ignored PUT mappings for alias alias_1; simulated mappings were empty` - ); + it(`should update underlying settings and mappings of existing concrete indices if they exist`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + if (!useDataStream) { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + } + + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 1 : 2); + }); + + it(`should retry simulateIndexTemplate on transient ES errors`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockImplementation(async () => SimulateTemplateResponse); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(clusterClient.indices.simulateIndexTemplate).toHaveBeenCalledTimes( + useDataStream ? 3 : 4 + ); + }); + + it(`should retry getting alias on transient ES errors`, async () => { + clusterClient.indices.getAlias + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + if (useDataStream) { + expect(clusterClient.indices.getDataStream).toHaveBeenCalledTimes(3); + } else { + expect(clusterClient.indices.getAlias).toHaveBeenCalledTimes(3); + } + }); + + it(`should retry settings update on transient ES errors`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putSettings + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 3 : 4); + }); + + it(`should log and throw error on settings update if max retries exceeded`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putSettings.mockRejectedValue(new EsErrors.ConnectionError('foo')); + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); + expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(useDataStream ? 4 : 7); + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Failed to PUT index.mapping.total_fields.limit settings for .alerts-test.alerts-default: foo` + : `Failed to PUT index.mapping.total_fields.limit settings for alias_1: foo` + ); + }); - expect(clusterClient.indices.create).toHaveBeenCalledWith({ - index: '.internal.alerts-test.alerts-default-000001', - body: { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: true, + it(`should log and throw error on settings update if ES throws error`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putSettings.mockRejectedValue(new Error('generic error')); + + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Failed to PUT index.mapping.total_fields.limit settings for .alerts-test.alerts-default: generic error` + : `Failed to PUT index.mapping.total_fields.limit settings for alias_1: generic error` + ); + }); + + it(`should retry mappings update on transient ES errors`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putMapping + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockRejectedValueOnce(new EsErrors.TimeoutError('timeout')) + .mockResolvedValue({ acknowledged: true }); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 3 : 4); + }); + + it(`should log and throw error on mappings update if max retries exceeded`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putMapping.mockRejectedValue(new EsErrors.ConnectionError('foo')); + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); + expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(useDataStream ? 4 : 7); + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Failed to PUT mapping for .alerts-test.alerts-default: foo` + : `Failed to PUT mapping for alias_1: foo` + ); + }); + + it(`should log and throw error on mappings update if ES throws error`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + clusterClient.indices.putMapping.mockRejectedValue(new Error('generic error')); + + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Failed to PUT mapping for .alerts-test.alerts-default: generic error` + : `Failed to PUT mapping for alias_1: generic error` + ); + }); + + it(`should log and return when simulating updated mappings throws error`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockRejectedValueOnce(new Error('fail')); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Ignored PUT mappings for .alerts-test.alerts-default; error generating simulated mappings: fail` + : `Ignored PUT mappings for alias_1; error generating simulated mappings: fail` + ); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + } + }); + + it(`should log and return when simulating updated mappings returns null`, async () => { + clusterClient.indices.getAlias.mockImplementation(async () => GetAliasResponse); + clusterClient.indices.getDataStream.mockImplementation(async () => GetDataStreamResponse); + clusterClient.indices.simulateIndexTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { ...SimulateTemplateResponse.template, mappings: null }, + })); + + await createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }); + + expect(logger.error).toHaveBeenCalledWith( + useDataStream + ? `Ignored PUT mappings for .alerts-test.alerts-default; simulated mappings were empty` + : `Ignored PUT mappings for alias_1; simulated mappings were empty` + ); + + if (useDataStream) { + expect(clusterClient.indices.createDataStream).not.toHaveBeenCalled(); + } else { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ + index: '.internal.alerts-test.alerts-default-000001', + body: { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: true, + }, + }, + }, + }); + } + }); + + it(`should throw error when there are concrete indices but none of them are the write index`, async () => { + if (useDataStream) return; + + clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ + '.internal.alerts-test.alerts-default-0001': { + aliases: { + '.alerts-test.alerts-default': { + is_write_index: false, + is_hidden: true, + }, + alias_2: { + is_write_index: false, + is_hidden: true, + }, + }, }, - }, - }, + })); + clusterClient.indices.simulateIndexTemplate.mockImplementation( + async () => SimulateTemplateResponse + ); + + await expect(() => + createConcreteWriteIndex({ + logger, + esClient: clusterClient, + indexPatterns: IndexPatterns, + totalFieldsLimit: 2500, + dataStreamAdapter, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default"` + ); + }); }); - }); - - it(`should throw error when there are concrete indices but none of them are the write index`, async () => { - clusterClient.indices.getAlias.mockImplementationOnce(async () => ({ - '.internal.alerts-test.alerts-default-0001': { - aliases: { - '.alerts-test.alerts-default': { - is_write_index: false, - is_hidden: true, - }, - alias_2: { - is_write_index: false, - is_hidden: true, - }, - }, - }, - })); - clusterClient.indices.simulateIndexTemplate.mockImplementation( - async () => SimulateTemplateResponse - ); - - await expect(() => - createConcreteWriteIndex({ - logger, - esClient: clusterClient, - indexPatterns: IndexPatterns, - totalFieldsLimit: 2500, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Indices matching pattern .internal.alerts-test.alerts-default-* exist but none are set as the write index for alias .alerts-test.alerts-default"` - ); - }); + } }); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts index 31aface312913..4e66f5f9b0a4e 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts @@ -10,8 +10,9 @@ import { Logger, ElasticsearchClient } from '@kbn/core/server'; import { get } from 'lodash'; import { IIndexPatternString } from '../resource_installer_utils'; import { retryTransientEsErrors } from './retry_transient_es_errors'; +import { DataStreamAdapter, getDataStreamAdapter } from './data_stream_adapter'; -interface ConcreteIndexInfo { +export interface ConcreteIndexInfo { index: string; alias: string; isWriteIndex: boolean; @@ -50,7 +51,7 @@ const updateTotalFieldLimitSetting = async ({ return; } catch (err) { logger.error( - `Failed to PUT index.mapping.total_fields.limit settings for alias ${alias}: ${err.message}` + `Failed to PUT index.mapping.total_fields.limit settings for ${alias}: ${err.message}` ); throw err; } @@ -74,7 +75,7 @@ const updateUnderlyingMapping = async ({ ); } catch (err) { logger.error( - `Ignored PUT mappings for alias ${alias}; error generating simulated mappings: ${err.message}` + `Ignored PUT mappings for ${alias}; error generating simulated mappings: ${err.message}` ); return; } @@ -82,7 +83,7 @@ const updateUnderlyingMapping = async ({ const simulatedMapping = get(simulatedIndexMapping, ['template', 'mappings']); if (simulatedMapping == null) { - logger.error(`Ignored PUT mappings for alias ${alias}; simulated mappings were empty`); + logger.error(`Ignored PUT mappings for ${alias}; simulated mappings were empty`); return; } @@ -94,20 +95,22 @@ const updateUnderlyingMapping = async ({ return; } catch (err) { - logger.error(`Failed to PUT mapping for alias ${alias}: ${err.message}`); + logger.error(`Failed to PUT mapping for ${alias}: ${err.message}`); throw err; } }; /** * Updates the underlying mapping for any existing concrete indices */ -const updateIndexMappings = async ({ +export const updateIndexMappings = async ({ logger, esClient, totalFieldsLimit, concreteIndices, }: UpdateIndexMappingsOpts) => { - logger.debug(`Updating underlying mappings for ${concreteIndices.length} indices.`); + logger.debug( + `Updating underlying mappings for ${concreteIndices.length} indices / data streams.` + ); // Update total field limit setting of found indices // Other index setting changes are not updated at this time @@ -125,11 +128,12 @@ const updateIndexMappings = async ({ ); }; -interface CreateConcreteWriteIndexOpts { +export interface CreateConcreteWriteIndexOpts { logger: Logger; esClient: ElasticsearchClient; totalFieldsLimit: number; indexPatterns: IIndexPatternString; + dataStreamAdapter?: DataStreamAdapter; } /** * Installs index template that uses installed component template @@ -137,107 +141,14 @@ interface CreateConcreteWriteIndexOpts { * conflicts. Simulate should return an empty mapping if a template * conflicts with an already installed template. */ -export const createConcreteWriteIndex = async ({ - logger, - esClient, - indexPatterns, - totalFieldsLimit, -}: CreateConcreteWriteIndexOpts) => { - logger.info(`Creating concrete write index - ${indexPatterns.name}`); +export const createConcreteWriteIndex = async (opts: CreateConcreteWriteIndexOpts) => { + const { logger, indexPatterns } = opts; - // check if a concrete write index already exists - let concreteIndices: ConcreteIndexInfo[] = []; - try { - // Specify both the index pattern for the backing indices and their aliases - // The alias prevents the request from finding other namespaces that could match the -* pattern - const response = await retryTransientEsErrors( - () => - esClient.indices.getAlias({ - index: indexPatterns.pattern, - name: indexPatterns.basePattern, - }), - { logger } - ); - - concreteIndices = Object.entries(response).flatMap(([index, { aliases }]) => - Object.entries(aliases).map(([aliasName, aliasProperties]) => ({ - index, - alias: aliasName, - isWriteIndex: aliasProperties.is_write_index ?? false, - })) - ); + // use the alias data stream adapter as "legacy" default + const dataStreamAdapter = + opts.dataStreamAdapter || getDataStreamAdapter({ useDataStreamForAlerts: false }); - logger.debug( - `Found ${concreteIndices.length} concrete indices for ${ - indexPatterns.name - } - ${JSON.stringify(concreteIndices)}` - ); - } catch (error) { - // 404 is expected if no concrete write indices have been created - if (error.statusCode !== 404) { - logger.error( - `Error fetching concrete indices for ${indexPatterns.pattern} pattern - ${error.message}` - ); - throw error; - } - } - - let concreteWriteIndicesExist = false; - // if a concrete write index already exists, update the underlying mapping - if (concreteIndices.length > 0) { - await updateIndexMappings({ logger, esClient, totalFieldsLimit, concreteIndices }); - - const concreteIndicesExist = concreteIndices.some( - (index) => index.alias === indexPatterns.alias - ); - concreteWriteIndicesExist = concreteIndices.some( - (index) => index.alias === indexPatterns.alias && index.isWriteIndex - ); - - // If there are some concrete indices but none of them are the write index, we'll throw an error - // because one of the existing indices should have been the write target. - if (concreteIndicesExist && !concreteWriteIndicesExist) { - throw new Error( - `Indices matching pattern ${indexPatterns.pattern} exist but none are set as the write index for alias ${indexPatterns.alias}` - ); - } - } - - // check if a concrete write index already exists - if (!concreteWriteIndicesExist) { - try { - await retryTransientEsErrors( - () => - esClient.indices.create({ - index: indexPatterns.name, - body: { - aliases: { - [indexPatterns.alias]: { - is_write_index: true, - }, - }, - }, - }), - { logger } - ); - } catch (error) { - logger.error(`Error creating concrete write index - ${error.message}`); - // If the index already exists and it's the write index for the alias, - // something else created it so suppress the error. If it's not the write - // index, that's bad, throw an error. - if (error?.meta?.body?.error?.type === 'resource_already_exists_exception') { - const existingIndices = await retryTransientEsErrors( - () => esClient.indices.get({ index: indexPatterns.name }), - { logger } - ); - if (!existingIndices[indexPatterns.name]?.aliases?.[indexPatterns.alias]?.is_write_index) { - throw Error( - `Attempted to create index: ${indexPatterns.name} as the write index for alias: ${indexPatterns.alias}, but the index already exists and is not the write index for the alias` - ); - } - } else { - throw error; - } - } - } + const label = dataStreamAdapter.isUsingDataStreams() ? 'data stream' : 'concrete write index'; + logger.info(`Creating ${label} - ${indexPatterns.name}`); + await dataStreamAdapter.createStream(opts); }; diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.test.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.test.ts index e47bc92eb5ae0..8cacdb1e97563 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.test.ts @@ -7,10 +7,12 @@ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { errors as EsErrors } from '@elastic/elasticsearch'; import { createOrUpdateIlmPolicy } from './create_or_update_ilm_policy'; +import { getDataStreamAdapter } from './data_stream_adapter'; const randomDelayMultiplier = 0.01; const logger = loggingSystemMock.createLogger(); const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; +const dataStreamAdapter = getDataStreamAdapter({ useDataStreamForAlerts: false }); const IlmPolicy = { _meta: { @@ -40,6 +42,7 @@ describe('createOrUpdateIlmPolicy', () => { esClient: clusterClient, name: 'test-policy', policy: IlmPolicy, + dataStreamAdapter, }); expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledWith({ @@ -58,6 +61,7 @@ describe('createOrUpdateIlmPolicy', () => { esClient: clusterClient, name: 'test-policy', policy: IlmPolicy, + dataStreamAdapter, }); expect(clusterClient.ilm.putLifecycle).toHaveBeenCalledTimes(3); @@ -71,6 +75,7 @@ describe('createOrUpdateIlmPolicy', () => { esClient: clusterClient, name: 'test-policy', policy: IlmPolicy, + dataStreamAdapter, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"foo"`); @@ -87,6 +92,7 @@ describe('createOrUpdateIlmPolicy', () => { esClient: clusterClient, name: 'test-policy', policy: IlmPolicy, + dataStreamAdapter, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"generic error"`); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.ts index d1c50b7474436..dfc967aa974d6 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_ilm_policy.ts @@ -8,12 +8,14 @@ import { IlmPolicy } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger, ElasticsearchClient } from '@kbn/core/server'; import { retryTransientEsErrors } from './retry_transient_es_errors'; +import { DataStreamAdapter } from './data_stream_adapter'; interface CreateOrUpdateIlmPolicyOpts { logger: Logger; esClient: ElasticsearchClient; name: string; policy: IlmPolicy; + dataStreamAdapter: DataStreamAdapter; } /** * Creates ILM policy if it doesn't already exist, updates it if it does @@ -23,7 +25,10 @@ export const createOrUpdateIlmPolicy = async ({ esClient, name, policy, + dataStreamAdapter, }: CreateOrUpdateIlmPolicyOpts) => { + if (dataStreamAdapter.isUsingDataStreams()) return; + logger.info(`Installing ILM policy ${name}`); try { diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.test.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.test.ts index d4ce203a0d0e3..2714f99c79fbc 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.test.ts @@ -7,12 +7,14 @@ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { errors as EsErrors } from '@elastic/elasticsearch'; import { getIndexTemplate, createOrUpdateIndexTemplate } from './create_or_update_index_template'; +import { createDataStreamAdapterMock } from './data_stream_adapter.mock'; +import { DataStreamAdapter } from './data_stream_adapter'; const randomDelayMultiplier = 0.01; const logger = loggingSystemMock.createLogger(); const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; -const IndexTemplate = (namespace: string = 'default') => ({ +const IndexTemplate = (namespace: string = 'default', useDataStream: boolean = false) => ({ name: `.alerts-test.alerts-${namespace}-index-template`, body: { _meta: { @@ -38,10 +40,14 @@ const IndexTemplate = (namespace: string = 'default') => ({ settings: { auto_expand_replicas: '0-1', hidden: true, - 'index.lifecycle': { - name: 'test-ilm-policy', - rollover_alias: `.alerts-test.alerts-${namespace}`, - }, + ...(useDataStream + ? {} + : { + 'index.lifecycle': { + name: 'test-ilm-policy', + rollover_alias: `.alerts-test.alerts-${namespace}`, + }, + }), 'index.mapping.total_fields.limit': 2500, }, }, @@ -65,7 +71,20 @@ const SimulateTemplateResponse = { }; describe('getIndexTemplate', () => { + let dataStreamAdapter: DataStreamAdapter; + let useDataStream: boolean; + + beforeEach(() => { + dataStreamAdapter = createDataStreamAdapterMock(); + useDataStream = dataStreamAdapter.isUsingDataStreams(); + }); + it(`should create index template with given parameters in default namespace`, () => { + dataStreamAdapter.getIndexTemplateFields = jest.fn().mockReturnValue({ + index_patterns: ['.internal.alerts-test.alerts-default-*'], + rollover_alias: '.alerts-test.alerts-default', + }); + expect( getIndexTemplate({ kibanaVersion: '8.6.1', @@ -80,11 +99,17 @@ describe('getIndexTemplate', () => { namespace: 'default', componentTemplateRefs: ['mappings1', 'framework-mappings'], totalFieldsLimit: 2500, + dataStreamAdapter, }) ).toEqual(IndexTemplate()); }); it(`should create index template with given parameters in custom namespace`, () => { + dataStreamAdapter.getIndexTemplateFields = jest.fn().mockReturnValue({ + index_patterns: ['.internal.alerts-test.alerts-another-space-*'], + rollover_alias: '.alerts-test.alerts-another-space', + }); + expect( getIndexTemplate({ kibanaVersion: '8.6.1', @@ -99,8 +124,9 @@ describe('getIndexTemplate', () => { namespace: 'another-space', componentTemplateRefs: ['mappings1', 'framework-mappings'], totalFieldsLimit: 2500, + dataStreamAdapter, }) - ).toEqual(IndexTemplate('another-space')); + ).toEqual(IndexTemplate('another-space', useDataStream)); }); }); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.ts b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.ts index a17fad2d875ed..bbe2ba666f1a9 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/create_or_update_index_template.ts @@ -14,6 +14,7 @@ import { Logger, ElasticsearchClient } from '@kbn/core/server'; import { isEmpty } from 'lodash'; import { IIndexPatternString } from '../resource_installer_utils'; import { retryTransientEsErrors } from './retry_transient_es_errors'; +import { DataStreamAdapter } from './data_stream_adapter'; interface GetIndexTemplateOpts { componentTemplateRefs: string[]; @@ -22,6 +23,7 @@ interface GetIndexTemplateOpts { kibanaVersion: string; namespace: string; totalFieldsLimit: number; + dataStreamAdapter: DataStreamAdapter; } export const getIndexTemplate = ({ @@ -31,6 +33,7 @@ export const getIndexTemplate = ({ kibanaVersion, namespace, totalFieldsLimit, + dataStreamAdapter, }: GetIndexTemplateOpts): IndicesPutIndexTemplateRequest => { const indexMetadata: Metadata = { kibana: { @@ -40,19 +43,27 @@ export const getIndexTemplate = ({ namespace, }; + const dataStreamFields = dataStreamAdapter.getIndexTemplateFields( + indexPatterns.alias, + indexPatterns.pattern + ); + + const indexLifecycle = { + name: ilmPolicyName, + rollover_alias: dataStreamFields.rollover_alias, + }; + return { name: indexPatterns.template, body: { - index_patterns: [indexPatterns.pattern], + ...(dataStreamFields.data_stream ? { data_stream: dataStreamFields.data_stream } : {}), + index_patterns: dataStreamFields.index_patterns, composed_of: componentTemplateRefs, template: { settings: { auto_expand_replicas: '0-1', hidden: true, - 'index.lifecycle': { - name: ilmPolicyName, - rollover_alias: indexPatterns.alias, - }, + ...(dataStreamAdapter.isUsingDataStreams() ? {} : { 'index.lifecycle': indexLifecycle }), 'index.mapping.total_fields.limit': totalFieldsLimit, }, mappings: { diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.mock.ts b/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.mock.ts new file mode 100644 index 0000000000000..8de9f7bcc1731 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.mock.ts @@ -0,0 +1,18 @@ +/* + * 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 { DataStreamAdapter, GetDataStreamAdapterOpts } from './data_stream_adapter'; + +export function createDataStreamAdapterMock(opts?: GetDataStreamAdapterOpts): DataStreamAdapter { + return { + isUsingDataStreams: jest.fn().mockReturnValue(false), + getIndexTemplateFields: jest.fn().mockReturnValue({ + index_patterns: ['index-pattern'], + }), + createStream: jest.fn(), + }; +} diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.ts b/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.ts new file mode 100644 index 0000000000000..89d1704095fad --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_service/lib/data_stream_adapter.ts @@ -0,0 +1,224 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import { + CreateConcreteWriteIndexOpts, + ConcreteIndexInfo, + updateIndexMappings, +} from './create_concrete_write_index'; +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +export interface DataStreamAdapter { + isUsingDataStreams(): boolean; + getIndexTemplateFields(alias: string, pattern: string): IndexTemplateFields; + createStream(opts: CreateConcreteWriteIndexOpts): Promise; +} + +export interface BulkOpProperties { + require_alias: boolean; +} + +export interface IndexTemplateFields { + data_stream?: { hidden: true }; + index_patterns: string[]; + rollover_alias?: string; +} + +export interface GetDataStreamAdapterOpts { + useDataStreamForAlerts: boolean; +} + +export function getDataStreamAdapter(opts: GetDataStreamAdapterOpts): DataStreamAdapter { + if (opts.useDataStreamForAlerts) { + return new DataStreamImplementation(); + } else { + return new AliasImplementation(); + } +} + +// implementation using data streams +class DataStreamImplementation implements DataStreamAdapter { + isUsingDataStreams(): boolean { + return true; + } + + getIndexTemplateFields(alias: string, pattern: string): IndexTemplateFields { + return { + data_stream: { hidden: true }, + index_patterns: [alias], + }; + } + + async createStream(opts: CreateConcreteWriteIndexOpts): Promise { + return createDataStream(opts); + } +} + +// implementation using aliases and backing indices +class AliasImplementation implements DataStreamAdapter { + isUsingDataStreams(): boolean { + return false; + } + + getIndexTemplateFields(alias: string, pattern: string): IndexTemplateFields { + return { + index_patterns: [pattern], + rollover_alias: alias, + }; + } + + async createStream(opts: CreateConcreteWriteIndexOpts): Promise { + return createAliasStream(opts); + } +} + +async function createDataStream(opts: CreateConcreteWriteIndexOpts): Promise { + const { logger, esClient, indexPatterns, totalFieldsLimit } = opts; + logger.info(`Creating data stream - ${indexPatterns.alias}`); + + // check if data stream exists + let dataStreamExists = false; + try { + const response = await retryTransientEsErrors( + () => esClient.indices.getDataStream({ name: indexPatterns.alias, expand_wildcards: 'all' }), + { logger } + ); + dataStreamExists = response.data_streams.length > 0; + } catch (error) { + if (error?.statusCode !== 404) { + logger.error(`Error fetching data stream for ${indexPatterns.alias} - ${error.message}`); + throw error; + } + } + + // if a data stream exists, update the underlying mapping + if (dataStreamExists) { + await updateIndexMappings({ + logger, + esClient, + totalFieldsLimit, + concreteIndices: [ + { alias: indexPatterns.alias, index: indexPatterns.alias, isWriteIndex: true }, + ], + }); + } else { + try { + await retryTransientEsErrors( + () => + esClient.indices.createDataStream({ + name: indexPatterns.alias, + }), + { logger } + ); + } catch (error) { + logger.error(`Error creating data stream ${indexPatterns.alias} - ${error.message}`); + throw error; + } + } +} + +async function createAliasStream(opts: CreateConcreteWriteIndexOpts): Promise { + const { logger, esClient, indexPatterns, totalFieldsLimit } = opts; + logger.info(`Creating concrete write index - ${indexPatterns.name}`); + + // check if a concrete write index already exists + let concreteIndices: ConcreteIndexInfo[] = []; + try { + // Specify both the index pattern for the backing indices and their aliases + // The alias prevents the request from finding other namespaces that could match the -* pattern + const response = await retryTransientEsErrors( + () => + esClient.indices.getAlias({ + index: indexPatterns.pattern, + name: indexPatterns.basePattern, + }), + { logger } + ); + + concreteIndices = Object.entries(response).flatMap(([index, { aliases }]) => + Object.entries(aliases).map(([aliasName, aliasProperties]) => ({ + index, + alias: aliasName, + isWriteIndex: aliasProperties.is_write_index ?? false, + })) + ); + + logger.debug( + `Found ${concreteIndices.length} concrete indices for ${ + indexPatterns.name + } - ${JSON.stringify(concreteIndices)}` + ); + } catch (error) { + // 404 is expected if no concrete write indices have been created + if (error.statusCode !== 404) { + logger.error( + `Error fetching concrete indices for ${indexPatterns.pattern} pattern - ${error.message}` + ); + throw error; + } + } + + let concreteWriteIndicesExist = false; + // if a concrete write index already exists, update the underlying mapping + if (concreteIndices.length > 0) { + await updateIndexMappings({ logger, esClient, totalFieldsLimit, concreteIndices }); + + const concreteIndicesExist = concreteIndices.some( + (index) => index.alias === indexPatterns.alias + ); + concreteWriteIndicesExist = concreteIndices.some( + (index) => index.alias === indexPatterns.alias && index.isWriteIndex + ); + + // If there are some concrete indices but none of them are the write index, we'll throw an error + // because one of the existing indices should have been the write target. + if (concreteIndicesExist && !concreteWriteIndicesExist) { + throw new Error( + `Indices matching pattern ${indexPatterns.pattern} exist but none are set as the write index for alias ${indexPatterns.alias}` + ); + } + } + + // check if a concrete write index already exists + if (!concreteWriteIndicesExist) { + try { + await retryTransientEsErrors( + () => + esClient.indices.create({ + index: indexPatterns.name, + body: { + aliases: { + [indexPatterns.alias]: { + is_write_index: true, + }, + }, + }, + }), + { logger } + ); + } catch (error) { + logger.error(`Error creating concrete write index - ${error.message}`); + // If the index already exists and it's the write index for the alias, + // something else created it so suppress the error. If it's not the write + // index, that's bad, throw an error. + if (error?.meta?.body?.error?.type === 'resource_already_exists_exception') { + const existingIndices = await retryTransientEsErrors( + () => esClient.indices.get({ index: indexPatterns.name }), + { logger } + ); + if (!existingIndices[indexPatterns.name]?.aliases?.[indexPatterns.alias]?.is_write_index) { + throw Error( + `Attempted to create index: ${indexPatterns.name} as the write index for alias: ${indexPatterns.alias}, but the index already exists and is not the write index for the alias` + ); + } + } else { + throw error; + } + } + } +} diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index 7df579771a91c..777d72efc699b 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -36,6 +36,7 @@ describe('config validation', () => { }, }, }, + "useDataStreamForAlerts": false, } `); }); diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index b1b9817ce1b9f..81ac0a0ce8308 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -66,6 +66,13 @@ export const configSchema = schema.object({ enableFrameworkAlerts: schema.boolean({ defaultValue: true }), cancelAlertsOnRuleTimeout: schema.boolean({ defaultValue: true }), rules: rulesSchema, + useDataStreamForAlerts: schema.boolean({ defaultValue: false }), + // useDataStreamForAlerts: schema.conditional( + // schema.contextRef('serverless'), + // true, + // schema.boolean({ defaultValue: true }), + // schema.never() + // ), }); export type AlertingConfig = TypeOf; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index ebfe1586031fa..ae42c665d73cf 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -32,6 +32,7 @@ export type { ExecutorType, IRuleTypeAlerts, GetViewInAppRelativeUrlFnOpts, + DataStreamAdapter, } from './types'; export { RuleNotifyWhen } from '../common'; export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config'; @@ -64,6 +65,7 @@ export { createConcreteWriteIndex, installWithTimeout, } from './alerts_service'; +export { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext); diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index a4902fccb4f04..f6870ed228300 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -35,6 +35,7 @@ const createSetupMock = () => { enabled: jest.fn(), getContextInitializationPromise: jest.fn(), }, + getDataStreamAdapter: jest.fn(), }; return mock; }; @@ -189,3 +190,5 @@ export const alertsMock = { export const ruleMonitoringServiceMock = { create: createRuleMonitoringServiceMock }; export const ruleLastRunServiceMock = { create: createRuleLastRunServiceMock }; + +export { createDataStreamAdapterMock } from './alerts_service/lib/data_stream_adapter.mock'; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index e14ba71c6c1bf..0c15227370499 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -36,30 +36,7 @@ jest.mock('./alerts_service/alerts_service', () => ({ })); import { SharePluginStart } from '@kbn/share-plugin/server'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; - -const generateAlertingConfig = (): AlertingConfig => ({ - healthCheck: { - interval: '5m', - }, - enableFrameworkAlerts: false, - invalidateApiKeysTask: { - interval: '5m', - removalDelay: '1h', - }, - maxEphemeralActionsPerAlert: 10, - cancelAlertsOnRuleTimeout: true, - rules: { - minimumScheduleInterval: { value: '1m', enforce: false }, - run: { - actions: { - max: 1000, - }, - alerts: { - max: 1000, - }, - }, - }, -}); +import { generateAlertingConfig } from './test_utils'; const sampleRuleType: RuleType = { id: 'test', @@ -105,7 +82,8 @@ describe('Alerting Plugin', () => { plugin = new AlertingPlugin(context); // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() - await plugin.setup(setupMocks, mockPlugins); + plugin.setup(setupMocks, mockPlugins); + await waitForSetupComplete(setupMocks); expect(setupMocks.status.set).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false); @@ -123,7 +101,8 @@ describe('Alerting Plugin', () => { const usageCollectionSetup = createUsageCollectionSetupMock(); // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() - await plugin.setup(setupMocks, { ...mockPlugins, usageCollection: usageCollectionSetup }); + plugin.setup(setupMocks, { ...mockPlugins, usageCollection: usageCollectionSetup }); + await waitForSetupComplete(setupMocks); expect(usageCollectionSetup.createUsageCounter).toHaveBeenCalled(); expect(usageCollectionSetup.registerCollector).toHaveBeenCalled(); @@ -137,7 +116,8 @@ describe('Alerting Plugin', () => { plugin = new AlertingPlugin(context); // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() - const setupContract = await plugin.setup(setupMocks, mockPlugins); + const setupContract = plugin.setup(setupMocks, mockPlugins); + await waitForSetupComplete(setupMocks); expect(AlertsService).toHaveBeenCalled(); @@ -150,7 +130,8 @@ describe('Alerting Plugin', () => { ); plugin = new AlertingPlugin(context); - const setupContract = await plugin.setup(setupMocks, mockPlugins); + const setupContract = plugin.setup(setupMocks, mockPlugins); + await waitForSetupComplete(setupMocks); expect(setupContract.getConfig()).toEqual({ isUsingSecurity: false, @@ -167,7 +148,8 @@ describe('Alerting Plugin', () => { generateAlertingConfig() ); plugin = new AlertingPlugin(context); - setup = await plugin.setup(setupMocks, mockPlugins); + setup = plugin.setup(setupMocks, mockPlugins); + await waitForSetupComplete(setupMocks); }); it('should throw error when license type is invalid', async () => { @@ -428,3 +410,20 @@ function mockFeatures() { ]); return features; } + +type CoreSetupMocks = ReturnType; + +const WaitForSetupAttempts = 10; +const WaitForSetupDelay = 200; +const WaitForSetupSeconds = (WaitForSetupAttempts * WaitForSetupDelay) / 1000; + +export async function waitForSetupComplete(setupMocks: CoreSetupMocks) { + let attempts = 0; + while (setupMocks.status.set.mock.calls.length < 1) { + attempts++; + await new Promise((resolve) => setTimeout(resolve, WaitForSetupDelay)); + if (attempts > WaitForSetupAttempts) { + throw new Error(`setupMocks.status.set was not called within ${WaitForSetupSeconds} seconds`); + } + } +} diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 9e81e1f53e87d..3fa652851c917 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -97,6 +97,7 @@ import { } from './alerts_service'; import { rulesSettingsFeature } from './rules_settings_feature'; import { maintenanceWindowFeature } from './maintenance_window_feature'; +import { DataStreamAdapter, getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -138,6 +139,7 @@ export interface PluginSetupContract { getSecurityHealth: () => Promise; getConfig: () => AlertingRulesConfig; frameworkAlerts: PublicFrameworkAlertsService; + getDataStreamAdapter: () => DataStreamAdapter; } export interface PluginStartContract { @@ -205,6 +207,7 @@ export class AlertingPlugin { private inMemoryMetrics: InMemoryMetrics; private alertsService: AlertsService | null; private pluginStop$: Subject; + private dataStreamAdapter: DataStreamAdapter; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); @@ -219,6 +222,7 @@ export class AlertingPlugin { this.kibanaVersion = initializerContext.env.packageInfo.version; this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics')); this.pluginStop$ = new ReplaySubject(1); + this.dataStreamAdapter = getDataStreamAdapter(this.config); } public setup( @@ -264,6 +268,7 @@ export class AlertingPlugin { logger: this.logger, pluginStop$: this.pluginStop$, kibanaVersion: this.kibanaVersion, + config: this.config, elasticsearchClientPromise: core .getStartServices() .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), @@ -414,6 +419,7 @@ export class AlertingPlugin { return Promise.resolve(errorResult(`Framework alerts service not available`)); }, }, + getDataStreamAdapter: () => this.dataStreamAdapter, }; } 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 73d60259b8610..de4292e9fe9fc 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 @@ -68,6 +68,7 @@ import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; import { AlertsService } from '../alerts_service'; import { ReplaySubject } from 'rxjs'; import { IAlertsClient } from '../alerts_client/types'; +import { generateAlertingConfig } from '../test_utils'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -327,6 +328,7 @@ describe('Task Runner', () => { pluginStop$: new ReplaySubject(1), kibanaVersion: '8.8.0', elasticsearchClientPromise: Promise.resolve(clusterClient), + config: generateAlertingConfig(), }); const spy = jest .spyOn(alertsService, 'getContextInitializationPromise') @@ -423,6 +425,7 @@ describe('Task Runner', () => { pluginStop$: new ReplaySubject(1), kibanaVersion: '8.8.0', elasticsearchClientPromise: Promise.resolve(clusterClient), + config: generateAlertingConfig(), }); const spy = jest .spyOn(alertsService, 'getContextInitializationPromise') diff --git a/x-pack/plugins/alerting/server/test_utils/index.ts b/x-pack/plugins/alerting/server/test_utils/index.ts index 589dae529cee6..553a44b152520 100644 --- a/x-pack/plugins/alerting/server/test_utils/index.ts +++ b/x-pack/plugins/alerting/server/test_utils/index.ts @@ -6,6 +6,7 @@ */ import { RawAlertInstance } from '../../common'; +import { AlertingConfig } from '../config'; interface Resolvable { resolve: (arg: T) => void; @@ -45,3 +46,30 @@ export function alertsWithAnyUUID( } return newAlerts; } + +export function generateAlertingConfig(useDataStreamForAlerts = false): AlertingConfig { + return { + healthCheck: { + interval: '5m', + }, + enableFrameworkAlerts: false, + invalidateApiKeysTask: { + interval: '5m', + removalDelay: '1h', + }, + maxEphemeralActionsPerAlert: 10, + cancelAlertsOnRuleTimeout: true, + rules: { + minimumScheduleInterval: { value: '1m', enforce: false }, + run: { + actions: { + max: 1000, + }, + alerts: { + max: 1000, + }, + }, + }, + useDataStreamForAlerts, + }; +} diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index c7e8294759657..bee42c98dc075 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -468,3 +468,5 @@ export interface RawRule extends SavedObjectAttributes { revision: number; running?: boolean | null; } + +export type { DataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 42ebef4c41f6f..fcd962e0f88be 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -100,6 +100,8 @@ export class RuleRegistryPlugin this.security = plugins.security; + const dataStreamAdapter = plugins.alerting.getDataStreamAdapter(); + this.ruleDataService = new RuleDataService({ logger, kibanaVersion, @@ -112,6 +114,7 @@ export class RuleRegistryPlugin }, frameworkAlerts: plugins.alerting.frameworkAlerts, pluginStop$: this.pluginStop$, + dataStreamAdapter, }); this.ruleDataService.initializeService(); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts index 751c21a08cf8d..dc6470c4739ce 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.mock.ts @@ -44,5 +44,6 @@ export const createRuleDataClientMock = ( bulk, }) ), + isUsingDataStreams: jest.fn(() => false), }; }; diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts index 9c280cbcd51a8..3d798b0104c71 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts @@ -55,6 +55,7 @@ function getRuleDataClientOptions({ waitUntilReadyForWriting: waitUntilReadyForWriting ?? Promise.resolve(right(scopedClusterClient) as WaitResult), logger, + isUsingDataStreams: false, }; } @@ -106,6 +107,7 @@ describe('RuleDataClient', () => { body: query, ignore_unavailable: true, index: `.alerts-observability.apm.alerts*`, + seq_no_primary_term: true, }); }); @@ -128,6 +130,7 @@ describe('RuleDataClient', () => { body: query, ignore_unavailable: true, index: `.alerts-observability.apm.alerts-test`, + seq_no_primary_term: true, }); }); @@ -345,9 +348,13 @@ describe('RuleDataClient', () => { await delay(); await expect(() => writer.bulk({})).rejects.toThrowErrorMatchingInlineSnapshot( - `"something went wrong!"` + `"error writing to index: something went wrong!"` + ); + expect(logger.error).toHaveBeenNthCalledWith( + 1, + 'error writing to index: something went wrong!', + error ); - expect(logger.error).toHaveBeenNthCalledWith(1, error); expect(ruleDataClient.isWriteEnabled()).toBe(true); }); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts index b56bc41efd292..0c2e0941a1ea6 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts @@ -32,6 +32,7 @@ export interface RuleDataClientConstructorOptions { waitUntilReadyForReading: Promise; waitUntilReadyForWriting: Promise; logger: Logger; + isUsingDataStreams: boolean; } export type WaitResult = Either; @@ -39,6 +40,7 @@ export type WaitResult = Either; export class RuleDataClient implements IRuleDataClient { private _isWriteEnabled: boolean = false; private _isWriterCacheEnabled: boolean = true; + private _isUsingDataStreams: boolean = false; // Writers cached by namespace private writerCache: Map; @@ -48,6 +50,7 @@ export class RuleDataClient implements IRuleDataClient { constructor(private readonly options: RuleDataClientConstructorOptions) { this.writeEnabled = this.options.isWriteEnabled; this.writerCacheEnabled = this.options.isWriterCacheEnabled; + this._isUsingDataStreams = this.options.isUsingDataStreams; this.writerCache = new Map(); } @@ -83,6 +86,10 @@ export class RuleDataClient implements IRuleDataClient { this._isWriterCacheEnabled = isEnabled; } + public isUsingDataStreams(): boolean { + return this._isUsingDataStreams; + } + public getReader(options: { namespace?: string } = {}): IRuleDataReader { const { indexInfo } = this.options; const indexPattern = indexInfo.getPatternForReading(options.namespace); @@ -109,6 +116,7 @@ export class RuleDataClient implements IRuleDataClient { ...request, index: indexPattern, ignore_unavailable: true, + seq_no_primary_term: true, })) as unknown as ESSearchResponse; } catch (err) { this.options.logger.error(`Error performing search in RuleDataClient - ${err.message}`); @@ -215,7 +223,7 @@ export class RuleDataClient implements IRuleDataClient { if (this.clusterClient) { const requestWithDefaultParameters = { ...request, - require_alias: true, + require_alias: !this._isUsingDataStreams, index: alias, }; @@ -223,17 +231,18 @@ export class RuleDataClient implements IRuleDataClient { meta: true, }); + // TODO: #160572 - add support for version conflict errors, in case alert was updated + // some other way between the time it was fetched and the time it was updated. if (response.body.errors) { - const error = new errors.ResponseError(response); - this.options.logger.error(error); + throw new errors.ResponseError(response); } return response; } else { this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); } } catch (error) { - this.options.logger.error(error); - throw error; + this.options.logger.error(`error writing to index: ${error.message}`, error); + throw new Error(`error writing to index: ${error.message}`, { cause: error }); } }, }; diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index dc8199c1e2963..a7da8069739f4 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -18,6 +18,7 @@ export interface IRuleDataClient { indexNameWithNamespace(namespace: string): string; kibanaVersion: string; isWriteEnabled(): boolean; + isUsingDataStreams(): boolean; getReader(options?: { namespace?: string }): IRuleDataReader; getWriter(options?: { namespace?: string }): Promise; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.test.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.test.ts index b27b90713c99e..a6fc03568f28f 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.test.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.test.ts @@ -13,6 +13,9 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; import { Dataset } from './index_options'; import { IndexInfo } from './index_info'; import { ECS_COMPONENT_TEMPLATE_NAME } from '@kbn/alerting-plugin/server'; +import type { DataStreamAdapter } from '@kbn/alerting-plugin/server'; +import { getDataStreamAdapter } from '@kbn/alerting-plugin/server/alerts_service/lib/data_stream_adapter'; + import { elasticsearchServiceMock, ElasticsearchClientMock } from '@kbn/core/server/mocks'; import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../common/assets'; @@ -23,9 +26,11 @@ const frameworkAlertsService = { describe('resourceInstaller', () => { let pluginStop$: Subject; + let dataStreamAdapter: DataStreamAdapter; beforeEach(() => { pluginStop$ = new ReplaySubject(1); + dataStreamAdapter = getDataStreamAdapter({ useDataStreamForAlerts: false }); }); afterEach(() => { @@ -45,6 +50,7 @@ describe('resourceInstaller', () => { getClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); installer.installCommonResources(); expect(getClusterClient).not.toHaveBeenCalled(); @@ -62,6 +68,7 @@ describe('resourceInstaller', () => { getClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); const indexOptions = { feature: AlertConsumers.LOGS, @@ -93,6 +100,7 @@ describe('resourceInstaller', () => { getClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); await installer.installCommonResources(); @@ -123,6 +131,7 @@ describe('resourceInstaller', () => { enabled: () => true, }, pluginStop$, + dataStreamAdapter, }); await installer.installCommonResources(); @@ -148,6 +157,7 @@ describe('resourceInstaller', () => { getClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -183,6 +193,7 @@ describe('resourceInstaller', () => { enabled: () => true, }, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -239,6 +250,7 @@ describe('resourceInstaller', () => { getClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -290,6 +302,7 @@ describe('resourceInstaller', () => { getContextInitializationPromise: async () => ({ result: true }), }, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -326,6 +339,7 @@ describe('resourceInstaller', () => { enabled: () => true, }, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -367,6 +381,7 @@ describe('resourceInstaller', () => { getContextInitializationPromise: async () => ({ result: true }), }, pluginStop$, + dataStreamAdapter, }); const indexOptions = { @@ -440,6 +455,7 @@ describe('resourceInstaller', () => { getClusterClient: async () => mockClusterClient, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }; const indexOptions = { feature: AlertConsumers.OBSERVABILITY, @@ -496,10 +512,10 @@ describe('resourceInstaller', () => { expect(errorMessages).toMatchInlineSnapshot(` Array [ Array [ - "Ignored PUT mappings for alias alias_1; error generating simulated mappings: expecting simulateIndexTemplate() to throw", + "Ignored PUT mappings for alias_1; error generating simulated mappings: expecting simulateIndexTemplate() to throw", ], Array [ - "Ignored PUT mappings for alias alias_2; error generating simulated mappings: expecting simulateIndexTemplate() to throw", + "Ignored PUT mappings for alias_2; error generating simulated mappings: expecting simulateIndexTemplate() to throw", ], ] `); @@ -522,10 +538,10 @@ describe('resourceInstaller', () => { expect(errorMessages).toMatchInlineSnapshot(` Array [ Array [ - "Ignored PUT mappings for alias alias_1; simulated mappings were empty", + "Ignored PUT mappings for alias_1; simulated mappings were empty", ], Array [ - "Ignored PUT mappings for alias alias_2; simulated mappings were empty", + "Ignored PUT mappings for alias_2; simulated mappings were empty", ], ] `); diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts index 2956552bd78d2..225df2ffe1b89 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts @@ -20,6 +20,7 @@ import { installWithTimeout, TOTAL_FIELDS_LIMIT, type PublicFrameworkAlertsService, + type DataStreamAdapter, } from '@kbn/alerting-plugin/server'; import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../common/assets'; import { technicalComponentTemplate } from '../../common/assets/component_templates/technical_component_template'; @@ -34,6 +35,7 @@ interface ConstructorOptions { disabledRegistrationContexts: string[]; frameworkAlerts: PublicFrameworkAlertsService; pluginStop$: Observable; + dataStreamAdapter: DataStreamAdapter; } export type IResourceInstaller = PublicMethodsOf; @@ -78,6 +80,7 @@ export class ResourceInstaller { esClient: clusterClient, name: DEFAULT_ALERTS_ILM_POLICY_NAME, policy: DEFAULT_ALERTS_ILM_POLICY, + dataStreamAdapter: this.options.dataStreamAdapter, }), createOrUpdateComponentTemplate({ logger, @@ -143,6 +146,7 @@ export class ResourceInstaller { esClient: clusterClient, name: indexInfo.getIlmPolicyName(), policy: ilmPolicy, + dataStreamAdapter: this.options.dataStreamAdapter, }); } @@ -245,6 +249,7 @@ export class ResourceInstaller { kibanaVersion: indexInfo.kibanaVersion, namespace, totalFieldsLimit: TOTAL_FIELDS_LIMIT, + dataStreamAdapter: this.options.dataStreamAdapter, }), }); @@ -253,6 +258,7 @@ export class ResourceInstaller { esClient: clusterClient, totalFieldsLimit: TOTAL_FIELDS_LIMIT, indexPatterns, + dataStreamAdapter: this.options.dataStreamAdapter, }); } } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.test.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.test.ts index 2ba9146f4f2db..b2736ee7f3cfe 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.test.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.test.ts @@ -14,6 +14,9 @@ import { Dataset } from './index_options'; import { RuleDataClient } from '../rule_data_client/rule_data_client'; import { createRuleDataClientMock as mockCreateRuleDataClient } from '../rule_data_client/rule_data_client.mock'; +import { createDataStreamAdapterMock } from '@kbn/alerting-plugin/server/mocks'; +import type { DataStreamAdapter } from '@kbn/alerting-plugin/server'; + jest.mock('../rule_data_client/rule_data_client', () => ({ RuleDataClient: jest.fn().mockImplementation(() => mockCreateRuleDataClient()), })); @@ -25,10 +28,12 @@ const frameworkAlertsService = { describe('ruleDataPluginService', () => { let pluginStop$: Subject; + let dataStreamAdapter: DataStreamAdapter; beforeEach(() => { jest.resetAllMocks(); pluginStop$ = new ReplaySubject(1); + dataStreamAdapter = createDataStreamAdapterMock(); }); afterEach(() => { @@ -50,6 +55,7 @@ describe('ruleDataPluginService', () => { isWriterCacheEnabled: true, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); expect(ruleDataService.isRegistrationContextDisabled('observability.logs')).toBe(true); }); @@ -67,6 +73,7 @@ describe('ruleDataPluginService', () => { isWriterCacheEnabled: true, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); expect(ruleDataService.isRegistrationContextDisabled('observability.apm')).toBe(false); }); @@ -86,6 +93,7 @@ describe('ruleDataPluginService', () => { isWriterCacheEnabled: true, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); expect(ruleDataService.isWriteEnabled('observability.logs')).toBe(false); @@ -106,6 +114,7 @@ describe('ruleDataPluginService', () => { isWriterCacheEnabled: true, frameworkAlerts: frameworkAlertsService, pluginStop$, + dataStreamAdapter, }); const indexOptions = { feature: AlertConsumers.LOGS, diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index 62f8cc88ca221..b17b10d5b7d26 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -11,7 +11,7 @@ import type { ValidFeatureId } from '@kbn/rule-data-utils'; import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import { type PublicFrameworkAlertsService } from '@kbn/alerting-plugin/server'; +import type { PublicFrameworkAlertsService, DataStreamAdapter } from '@kbn/alerting-plugin/server'; import { INDEX_PREFIX } from '../config'; import { type IRuleDataClient, RuleDataClient, WaitResult } from '../rule_data_client'; import { IndexInfo } from './index_info'; @@ -94,6 +94,7 @@ interface ConstructorOptions { disabledRegistrationContexts: string[]; frameworkAlerts: PublicFrameworkAlertsService; pluginStop$: Observable; + dataStreamAdapter: DataStreamAdapter; } export class RuleDataService implements IRuleDataService { @@ -116,6 +117,7 @@ export class RuleDataService implements IRuleDataService { isWriteEnabled: options.isWriteEnabled, frameworkAlerts: options.frameworkAlerts, pluginStop$: options.pluginStop$, + dataStreamAdapter: options.dataStreamAdapter, }); this.installCommonResources = Promise.resolve(right('ok')); @@ -222,6 +224,7 @@ export class RuleDataService implements IRuleDataService { waitUntilReadyForReading, waitUntilReadyForWriting, logger: this.options.logger, + isUsingDataStreams: this.options.dataStreamAdapter.isUsingDataStreams(), }); } diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts index 993405dd33e1f..7e8e0ac73f907 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.test.ts @@ -97,7 +97,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: [ // alert documents - { index: { _id: expect.any(String) } }, + { create: { _id: expect.any(String) } }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -105,7 +105,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], }), - { index: { _id: expect.any(String) } }, + { create: { _id: expect.any(String) } }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -120,7 +120,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: expect.arrayContaining([ // evaluation documents - { index: {} }, + { create: {} }, expect.objectContaining({ [EVENT_KIND]: 'event', }), @@ -151,6 +151,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -168,6 +171,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 1, + _primary_term: 3, }, ], }, @@ -222,7 +228,15 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: [ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { + index: { + _id: 'TEST_ALERT_0_UUID', + _index: 'alerts-index-name', + if_primary_term: 2, + if_seq_no: 4, + require_alias: false, + }, + }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_WORKFLOW_STATUS]: 'closed', @@ -232,7 +246,15 @@ describe('createLifecycleExecutor', () => { [EVENT_ACTION]: 'active', [EVENT_KIND]: 'signal', }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { + index: { + _id: 'TEST_ALERT_1_UUID', + _index: 'alerts-index-name', + if_primary_term: 3, + if_seq_no: 1, + require_alias: false, + }, + }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_WORKFLOW_STATUS]: 'open', @@ -279,6 +301,9 @@ describe('createLifecycleExecutor', () => { labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc [TAGS]: ['source-tag1', 'source-tag2'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -296,6 +321,9 @@ describe('createLifecycleExecutor', () => { labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc [TAGS]: ['source-tag3', 'source-tag4'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -347,7 +375,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: expect.arrayContaining([ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_STATUS]: ALERT_STATUS_RECOVERED, @@ -356,7 +384,7 @@ describe('createLifecycleExecutor', () => { [EVENT_ACTION]: 'close', [EVENT_KIND]: 'signal', }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -510,6 +538,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -527,6 +558,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -622,6 +656,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -639,6 +676,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -733,6 +773,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -749,6 +792,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -841,6 +887,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -857,6 +906,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -957,7 +1009,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: [ // alert documents - { index: { _id: expect.any(String) } }, + { create: { _id: expect.any(String) } }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -966,7 +1018,7 @@ describe('createLifecycleExecutor', () => { [TAGS]: ['source-tag1', 'source-tag2', 'rule-tag1', 'rule-tag2'], [ALERT_MAINTENANCE_WINDOW_IDS]: maintenanceWindowIds, }), - { index: { _id: expect.any(String) } }, + { create: { _id: expect.any(String) } }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -1013,6 +1065,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1030,6 +1085,9 @@ describe('createLifecycleExecutor', () => { [SPACE_IDS]: ['fake-space-id'], labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -1086,7 +1144,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: [ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_WORKFLOW_STATUS]: 'closed', @@ -1095,7 +1153,7 @@ describe('createLifecycleExecutor', () => { [EVENT_ACTION]: 'active', [EVENT_KIND]: 'signal', }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_WORKFLOW_STATUS]: 'open', @@ -1141,6 +1199,9 @@ describe('createLifecycleExecutor', () => { labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must show up in the written doc [TAGS]: ['source-tag1', 'source-tag2'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1158,6 +1219,9 @@ describe('createLifecycleExecutor', () => { labels: { LABEL_0_KEY: 'LABEL_0_VALUE' }, // this must not show up in the written doc [TAGS]: ['source-tag3', 'source-tag4'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -1210,7 +1274,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: expect.arrayContaining([ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_STATUS]: ALERT_STATUS_RECOVERED, @@ -1219,7 +1283,7 @@ describe('createLifecycleExecutor', () => { [EVENT_ACTION]: 'close', [EVENT_KIND]: 'signal', }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -1269,6 +1333,9 @@ describe('createLifecycleExecutor', () => { [ALERT_WORKFLOW_STATUS]: 'closed', [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1285,6 +1352,9 @@ describe('createLifecycleExecutor', () => { [ALERT_WORKFLOW_STATUS]: 'open', [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1301,6 +1371,9 @@ describe('createLifecycleExecutor', () => { [ALERT_WORKFLOW_STATUS]: 'open', [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1317,6 +1390,9 @@ describe('createLifecycleExecutor', () => { [ALERT_WORKFLOW_STATUS]: 'open', [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -1432,7 +1508,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: [ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_WORKFLOW_STATUS]: 'closed', @@ -1441,7 +1517,7 @@ describe('createLifecycleExecutor', () => { [EVENT_ACTION]: 'active', [EVENT_KIND]: 'signal', }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_WORKFLOW_STATUS]: 'open', @@ -1450,7 +1526,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [ALERT_FLAPPING]: false, }), - { index: { _id: 'TEST_ALERT_2_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', [ALERT_WORKFLOW_STATUS]: 'open', @@ -1459,7 +1535,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [ALERT_FLAPPING]: true, }), - { index: { _id: 'TEST_ALERT_3_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', [ALERT_WORKFLOW_STATUS]: 'open', @@ -1493,6 +1569,9 @@ describe('createLifecycleExecutor', () => { [ALERT_STATUS]: ALERT_STATUS_ACTIVE, [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1508,6 +1587,9 @@ describe('createLifecycleExecutor', () => { [ALERT_STATUS]: ALERT_STATUS_ACTIVE, [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1523,6 +1605,9 @@ describe('createLifecycleExecutor', () => { [ALERT_STATUS]: ALERT_STATUS_ACTIVE, [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, { _source: { @@ -1538,6 +1623,9 @@ describe('createLifecycleExecutor', () => { [ALERT_STATUS]: ALERT_STATUS_ACTIVE, [SPACE_IDS]: ['fake-space-id'], }, + _index: 'alerts-index-name', + _seq_no: 4, + _primary_term: 2, }, ], }, @@ -1637,7 +1725,7 @@ describe('createLifecycleExecutor', () => { expect.objectContaining({ body: expect.arrayContaining([ // alert document - { index: { _id: 'TEST_ALERT_0_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_0_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_0', [ALERT_STATUS]: ALERT_STATUS_RECOVERED, @@ -1645,7 +1733,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [ALERT_FLAPPING]: false, }), - { index: { _id: 'TEST_ALERT_1_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_1_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_1', [ALERT_STATUS]: ALERT_STATUS_RECOVERED, @@ -1653,7 +1741,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [ALERT_FLAPPING]: false, }), - { index: { _id: 'TEST_ALERT_2_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_2_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_2', [ALERT_STATUS]: ALERT_STATUS_ACTIVE, @@ -1661,7 +1749,7 @@ describe('createLifecycleExecutor', () => { [EVENT_KIND]: 'signal', [ALERT_FLAPPING]: true, }), - { index: { _id: 'TEST_ALERT_3_UUID' } }, + { index: expect.objectContaining({ _id: 'TEST_ALERT_3_UUID' }) }, expect.objectContaining({ [ALERT_INSTANCE_ID]: 'TEST_ALERT_3', [ALERT_STATUS]: ALERT_STATUS_RECOVERED, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index ce2570f7e5bcc..c72e810ce2d81 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -216,10 +216,14 @@ export const createLifecycleExecutor = `[Rule Registry] Tracking ${allAlertIds.length} alerts (${newAlertIds.length} new, ${trackedAlertStates.length} previous)` ); - const trackedAlertsDataMap: Record< - string, - { indexName: string; fields: Partial } - > = {}; + interface TrackedAlertData { + indexName: string; + fields: Partial; + seqNo: number | undefined; + primaryTerm: number | undefined; + } + + const trackedAlertsDataMap: Record = {}; if (trackedAlertStates.length) { const result = await fetchExistingAlerts( @@ -230,10 +234,18 @@ export const createLifecycleExecutor = result.forEach((hit) => { const alertInstanceId = hit._source ? hit._source[ALERT_INSTANCE_ID] : void 0; if (alertInstanceId && hit._source) { - trackedAlertsDataMap[alertInstanceId] = { - indexName: hit._index, - fields: hit._source, - }; + if (hit._seq_no == null) { + logger.error(`missing _seq_no on alert instance ${alertInstanceId}`); + } else if (hit._primary_term == null) { + logger.error(`missing _primary_term on alert instance ${alertInstanceId}`); + } else { + trackedAlertsDataMap[alertInstanceId] = { + indexName: hit._index, + fields: hit._source, + seqNo: hit._seq_no, + primaryTerm: hit._primary_term, + }; + } } }); } @@ -308,6 +320,8 @@ export const createLifecycleExecutor = return { indexName: alertData?.indexName, + seqNo: alertData?.seqNo, + primaryTerm: alertData?.primaryTerm, event, flappingHistory, flapping, @@ -335,10 +349,22 @@ export const createLifecycleExecutor = logger.debug(`[Rule Registry] Preparing to index ${allEventsToIndex.length} alerts.`); await ruleDataClientWriter.bulk({ - body: allEventsToIndex.flatMap(({ event, indexName }) => [ + body: allEventsToIndex.flatMap(({ event, indexName, seqNo, primaryTerm }) => [ indexName - ? { index: { _id: event[ALERT_UUID]!, _index: indexName, require_alias: false } } - : { index: { _id: event[ALERT_UUID]! } }, + ? { + index: { + _id: event[ALERT_UUID]!, + _index: indexName, + if_seq_no: seqNo, + if_primary_term: primaryTerm, + require_alias: false, + }, + } + : { + create: { + _id: event[ALERT_UUID]!, + }, + }, event, ]), refresh: 'wait_for', diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 971b4ec735086..bbdd4806b55e7 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -16,7 +16,6 @@ import { } from '@kbn/rule-data-utils'; import { loggerMock } from '@kbn/logging-mocks'; import { castArray, omit } from 'lodash'; -import { RuleDataClient } from '../rule_data_client'; import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock'; import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory'; import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; @@ -30,7 +29,7 @@ function createRule(shouldWriteAlerts: boolean = true) { const ruleDataClientMock = createRuleDataClientMock(); const factory = createLifecycleRuleTypeFactory({ - ruleDataClient: ruleDataClientMock as unknown as RuleDataClient, + ruleDataClient: ruleDataClientMock, logger: loggerMock.create(), }); @@ -227,7 +226,7 @@ describe('createLifecycleRuleTypeFactory', () => { const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[0][0].body!; - const documents = body.filter((op: any) => !('index' in op)) as any[]; + const documents: any[] = body.filter((op: any) => !isOpDoc(op)); const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); @@ -347,9 +346,10 @@ describe('createLifecycleRuleTypeFactory', () => { ).bulk.mock.calls[0][0].body ?.concat() .reverse() - .find( - (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' - ) as Record; + .find((doc: any) => !isOpDoc(doc) && doc['service.name'] === 'opbeans-node') as Record< + string, + any + >; // @ts-ignore 4.3.5 upgrade helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ @@ -390,7 +390,7 @@ describe('createLifecycleRuleTypeFactory', () => { expect((await helpers.ruleDataClientMock.getWriter()).bulk).toHaveBeenCalledTimes(2); const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[1][0].body!; - const documents = body.filter((op: any) => !('index' in op)) as any[]; + const documents: any[] = body.filter((op: any) => !isOpDoc(op)); const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); @@ -429,13 +429,16 @@ describe('createLifecycleRuleTypeFactory', () => { ).bulk.mock.calls[0][0].body ?.concat() .reverse() - .find( - (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' - ) as Record; + .find((doc: any) => !isOpDoc(doc) && doc['service.name'] === 'opbeans-node') as Record< + string, + any + >; helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ hits: { - hits: [{ _source: lastOpbeansNodeDoc } as any], + hits: [ + { _source: lastOpbeansNodeDoc, _index: 'a', _primary_term: 4, _seq_no: 2 } as any, + ], total: { value: 1, relation: 'eq', @@ -465,7 +468,7 @@ describe('createLifecycleRuleTypeFactory', () => { const body = (await helpers.ruleDataClientMock.getWriter()).bulk.mock.calls[1][0].body!; - const documents = body.filter((op: any) => !('index' in op)) as any[]; + const documents: any[] = body.filter((op: any) => !isOpDoc(op)); const opbeansJavaAlertDoc = documents.find( (doc) => castArray(doc['service.name'])[0] === 'opbeans-java' @@ -487,3 +490,9 @@ describe('createLifecycleRuleTypeFactory', () => { }); }); }); + +function isOpDoc(doc: any) { + if (doc?.index?._id) return true; + if (doc?.create?._id) return true; + return false; +} diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts index 3d38293626e16..e3f9e5c24240e 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts @@ -9,6 +9,7 @@ import { createOrUpdateComponentTemplate, createOrUpdateIlmPolicy, createOrUpdateIndexTemplate, + getDataStreamAdapter, } from '@kbn/alerting-plugin/server'; import { loggingSystemMock, @@ -59,6 +60,7 @@ jest.mock('@kbn/alerting-plugin/server', () => ({ createOrUpdateComponentTemplate: jest.fn(), createOrUpdateIlmPolicy: jest.fn(), createOrUpdateIndexTemplate: jest.fn(), + getDataStreamAdapter: jest.fn(), })); jest.mock('./utils/create_datastream', () => ({ @@ -114,6 +116,8 @@ describe('RiskEngineDataClient', () => { it('should initialize risk engine resources', async () => { await riskEngineDataClient.initializeResources({ namespace: 'default' }); + expect(getDataStreamAdapter).toHaveBeenCalledWith({ useDataStreamForAlerts: false }); + expect(createOrUpdateIlmPolicy).toHaveBeenCalledWith({ logger, esClient, diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts index f338686f3ceac..4d887d3c6f602 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts @@ -12,11 +12,11 @@ import { createOrUpdateComponentTemplate, createOrUpdateIlmPolicy, createOrUpdateIndexTemplate, + getDataStreamAdapter, } from '@kbn/alerting-plugin/server'; import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import type { Logger, ElasticsearchClient } from '@kbn/core/server'; - import { riskScoreFieldMap, getIndexPattern, @@ -217,6 +217,8 @@ export class RiskEngineDataClient { esClient, name: ilmPolicyName, policy: ilmPolicy, + // this will change w/serverless, since it doesn't have ILM + dataStreamAdapter: getDataStreamAdapter({ useDataStreamForAlerts: false }), }), createOrUpdateComponentTemplate({ logger: this.options.logger, diff --git a/x-pack/test/rule_registry/common/config.ts b/x-pack/test/rule_registry/common/config.ts index 703e71a8613b3..9f2f17523509d 100644 --- a/x-pack/test/rule_registry/common/config.ts +++ b/x-pack/test/rule_registry/common/config.ts @@ -17,6 +17,7 @@ interface CreateTestConfigOptions { disabledPlugins?: string[]; ssl?: boolean; testFiles?: string[]; + useDataStreamForAlerts?: boolean; } // test.not-enabled is specifically not enabled @@ -80,6 +81,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + `--xpack.alerting.useDataStreamForAlerts=${options.useDataStreamForAlerts ?? false}`, '--xpack.eventLog.logEntries=true', ...disabledPlugins .filter((k) => k !== 'security') diff --git a/x-pack/test/rule_registry/common/lib/helpers/cleanup_registry_indices.ts b/x-pack/test/rule_registry/common/lib/helpers/cleanup_registry_indices.ts index 82652e5726fcf..9bae7fbf9daf5 100644 --- a/x-pack/test/rule_registry/common/lib/helpers/cleanup_registry_indices.ts +++ b/x-pack/test/rule_registry/common/lib/helpers/cleanup_registry_indices.ts @@ -7,16 +7,37 @@ import expect from '@kbn/expect'; import { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; + import { GetService } from '../../types'; +import { isUsingDataStreamForAlerts } from './use_data_stream_for_alerts'; export const cleanupRegistryIndices = async (getService: GetService, client: IRuleDataClient) => { const es = getService('es'); + const useDataStream = isUsingDataStreamForAlerts(getService); + const dataStreams = await es.indices.getDataStream({ + name: '.alerts-*', + expand_wildcards: 'all', + include_defaults: true, + }); + const dataStreamNames = dataStreams.data_streams.map((dataStream) => dataStream.name); + if (useDataStream) { + expect(dataStreamNames.length).to.be.greaterThan(0); + await es.indices.deleteDataStream({ name: dataStreamNames }, { ignore: [404] }); + } else { + expect(dataStreamNames.length).to.be(0); + } + const aliasMap = await es.indices.get({ index: `${client.indexName}*`, allow_no_indices: true, expand_wildcards: 'open', }); const indices = Object.keys(aliasMap); - expect(indices.length > 0).to.be(true); - return es.indices.delete({ index: indices }, { ignore: [404] }); + + if (useDataStream) { + expect(indices.length).to.be(0); + } else { + expect(indices.length).to.be.greaterThan(0); + await es.indices.delete({ index: indices }, { ignore: [404] }); + } }; diff --git a/x-pack/test/rule_registry/common/lib/helpers/index.ts b/x-pack/test/rule_registry/common/lib/helpers/index.ts index 25128a5807320..2c53374cc1cff 100644 --- a/x-pack/test/rule_registry/common/lib/helpers/index.ts +++ b/x-pack/test/rule_registry/common/lib/helpers/index.ts @@ -14,3 +14,4 @@ export * from './cleanup_target_indices'; export * from './cleanup_registry_indices'; export * from './delete_alert'; export * from './mock_alert_factory'; +export * from './use_data_stream_for_alerts'; diff --git a/x-pack/test/rule_registry/common/lib/helpers/use_data_stream_for_alerts.ts b/x-pack/test/rule_registry/common/lib/helpers/use_data_stream_for_alerts.ts new file mode 100644 index 0000000000000..f9035fe3260cd --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/helpers/use_data_stream_for_alerts.ts @@ -0,0 +1,18 @@ +/* + * 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 { GetService } from '../../types'; + +export function isUsingDataStreamForAlerts(getService: GetService): boolean { + const configService = getService('config'); + + const serverArgStrings: string[] = configService.getAll().kbnTestServer.serverArgs; + const serverArgs = new Set(serverArgStrings); + const result = serverArgs.has('--xpack.alerting.useDataStreamForAlerts=true'); + + return result; +} diff --git a/x-pack/test/rule_registry/spaces_only/config_basic.ts b/x-pack/test/rule_registry/spaces_only/config_trial_datastream.ts similarity index 82% rename from x-pack/test/rule_registry/spaces_only/config_basic.ts rename to x-pack/test/rule_registry/spaces_only/config_trial_datastream.ts index 5a2ee4c1c1178..7ab7c708597a2 100644 --- a/x-pack/test/rule_registry/spaces_only/config_basic.ts +++ b/x-pack/test/rule_registry/spaces_only/config_trial_datastream.ts @@ -9,8 +9,9 @@ import { createTestConfig } from '../common/config'; // eslint-disable-next-line import/no-default-export export default createTestConfig('spaces_only', { - license: 'basic', + license: 'trial', disabledPlugins: ['security'], ssl: false, - testFiles: [require.resolve('./tests/basic')], + testFiles: [require.resolve('./tests/trial')], + useDataStreamForAlerts: true, }); diff --git a/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts b/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts deleted file mode 100644 index 01be475d18132..0000000000000 --- a/x-pack/test/rule_registry/spaces_only/tests/basic/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { createSpaces, deleteSpaces } from '../../../common/lib/authentication'; - -// eslint-disable-next-line import/no-default-export -export default ({ loadTestFile, getService }: FtrProviderContext): void => { - describe('rule registry spaces only: trial', function () { - before(async () => { - await createSpaces(getService); - }); - - after(async () => { - await deleteSpaces(getService); - }); - }); -}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/lifecycle_executor.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/lifecycle_executor.ts index 80334e09f6999..25ba57f46e364 100644 --- a/x-pack/test/rule_registry/spaces_only/tests/trial/lifecycle_executor.ts +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/lifecycle_executor.ts @@ -29,6 +29,7 @@ import { } from '@kbn/rule-registry-plugin/server/utils/create_lifecycle_executor'; import { Dataset, IRuleDataClient, RuleDataService } from '@kbn/rule-registry-plugin/server'; import { RuleExecutorOptions } from '@kbn/alerting-plugin/server'; +import { getDataStreamAdapter } from '@kbn/alerting-plugin/server/alerts_service/lib/data_stream_adapter'; import type { FtrProviderContext } from '../../../common/ftr_provider_context'; import { MockRuleParams, @@ -37,13 +38,17 @@ import { MockAlertState, MockAllowedActionGroups, } from '../../../common/types'; -import { cleanupRegistryIndices, getMockAlertFactory } from '../../../common/lib/helpers'; +import { + cleanupRegistryIndices, + getMockAlertFactory, + isUsingDataStreamForAlerts, +} from '../../../common/lib/helpers'; // eslint-disable-next-line import/no-default-export export default function createLifecycleExecutorApiTest({ getService }: FtrProviderContext) { const es = getService('es'); - const log = getService('log'); + const useDataStreamForAlerts = isUsingDataStreamForAlerts(getService); const fakeLogger = (msg: string, meta?: Meta) => meta ? log.debug(msg, meta) : log.debug(msg); @@ -65,6 +70,8 @@ export default function createLifecycleExecutorApiTest({ getService }: FtrProvid return Promise.resolve(client); }; + const dataStreamAdapter = getDataStreamAdapter({ useDataStreamForAlerts }); + describe('createLifecycleExecutor', () => { let ruleDataClient: IRuleDataClient; let pluginStop$: Subject; @@ -86,6 +93,7 @@ export default function createLifecycleExecutorApiTest({ getService }: FtrProvid getContextInitializationPromise: async () => ({ result: false }), }, pluginStop$, + dataStreamAdapter, }); // This initializes the service. This happens immediately after the creation @@ -201,6 +209,7 @@ export default function createLifecycleExecutorApiTest({ getService }: FtrProvid lookBackWindow: 20, statusChangeThreshold: 4, }, + dataStreamAdapter, } as unknown as RuleExecutorOptions< MockRuleParams, WrappedLifecycleRuleState, diff --git a/x-pack/test_serverless/api_integration/config.base.ts b/x-pack/test_serverless/api_integration/config.base.ts index a60591c4008cf..b2e2042874801 100644 --- a/x-pack/test_serverless/api_integration/config.base.ts +++ b/x-pack/test_serverless/api_integration/config.base.ts @@ -29,6 +29,7 @@ export function createTestConfig(options: CreateTestConfigOptions) { '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.observability.unsafe.thresholdRule.enabled=true', '--server.publicBaseUrl=https://localhost:5601', + `--xpack.alerting.useDataStreamForAlerts=true`, ], }, testFiles: options.testFiles,