diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index beb9c67bf9eb0..1dd182a9e5859 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -43480,7 +43480,7 @@ "xpack.securitySolution.siemMigrations.rules.panel.progress.description": "Traitement de la migration de {totalRules} règles.", "xpack.securitySolution.siemMigrations.rules.panel.progress.preparing": "Préparation de l'environnement pour la traduction alimentée par l'IA.", "xpack.securitySolution.siemMigrations.rules.panel.progress.translating": "Traduction des règles", - "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "La migration de {totalRules} règles est créée, mais la traduction n'a pas encore commencé. {missingResourcesText}", + "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "La migration de {totalRules} règles est créée, mais la traduction n'a pas encore commencé.", "xpack.securitySolution.siemMigrations.rules.panel.ready.missingResources": "Chargez les macros et les consultations et débutez le processus de traduction", "xpack.securitySolution.siemMigrations.rules.panel.result.badge": "Traduction terminée", "xpack.securitySolution.siemMigrations.rules.panel.result.description": "L'exportation a été téléchargée à {createdAt} et la traduction s'est terminée à {finishedAt}.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index a9d2a3583eb2d..626247c08eb8d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -43450,7 +43450,7 @@ "xpack.securitySolution.siemMigrations.rules.panel.progress.description": "{totalRules}ルールの移行を処理しています。", "xpack.securitySolution.siemMigrations.rules.panel.progress.preparing": "AIを活用した変換の環境を準備しています。", "xpack.securitySolution.siemMigrations.rules.panel.progress.translating": "ルールを変換中", - "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "{totalRules}のルールの移行は作成されていますが、変換はまだ開始されていません。{missingResourcesText}", + "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "{totalRules}のルールの移行は作成されていますが、変換はまだ開始されていません。", "xpack.securitySolution.siemMigrations.rules.panel.ready.missingResources": "マクロとルックアップをアップロードし、変換プロセスを開始", "xpack.securitySolution.siemMigrations.rules.panel.result.badge": "変換完了", "xpack.securitySolution.siemMigrations.rules.panel.result.description": "{createdAt}にアップロードされたエクスポートと変換が{finishedAt}に完了しました。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index b7c628b02ec29..ac95931e8d574 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -43522,7 +43522,7 @@ "xpack.securitySolution.siemMigrations.rules.panel.progress.description": "正在处理 {totalRules} 个规则的迁移。", "xpack.securitySolution.siemMigrations.rules.panel.progress.preparing": "正在准备环境以进行 AI 驱动式转换。", "xpack.securitySolution.siemMigrations.rules.panel.progress.translating": "正在转换规则", - "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "已创建 {totalRules} 个规则的迁移,但转换尚未开始。{missingResourcesText}", + "xpack.securitySolution.siemMigrations.rules.panel.ready.description": "已创建 {totalRules} 个规则的迁移,但转换尚未开始。", "xpack.securitySolution.siemMigrations.rules.panel.ready.missingResources": "上传宏和查找,然后开始转换过程", "xpack.securitySolution.siemMigrations.rules.panel.result.badge": "转换完成", "xpack.securitySolution.siemMigrations.rules.panel.result.description": "导出已于 {createdAt} 上传,且转换已于 {finishedAt} 完成。", diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts index ae4c796dd104a..71c9c6185bbcd 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/constants.ts @@ -37,10 +37,16 @@ export const SIEM_RULE_MIGRATION_EVALUATE_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/e export const LOOKUPS_INDEX_PREFIX = 'lookup_'; export enum SiemMigrationTaskStatus { + /** The Migration is not yet started */ READY = 'ready', + /** The Migration is in progress */ RUNNING = 'running', + /** The Migration process has been stopped for some reason unrelated to the user, usually a server restart. */ STOPPED = 'stopped', + /** The Migration is completed without any issues */ FINISHED = 'finished', + /** The Migration is explicitly aborted by user */ + ABORTED = 'aborted', } export enum SiemMigrationStatus { diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index a747c4d1236dc..dbd095815091d 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -141,6 +141,33 @@ export const PrebuiltRuleVersion = z.object({ current: RuleResponse.optional(), }); +/** + * The last execution of the rule migration task. + */ +export type RuleMigrationLastExecution = z.infer; +export const RuleMigrationLastExecution = z.object({ + /** + * The moment the last execution started. + */ + started_at: z.string().optional(), + /** + * The moment the last execution ended. + */ + ended_at: z.string().nullable().optional(), + /** + * The connector ID used for the last execution. + */ + connector_id: z.string().optional(), + /** + * The error message if the last execution failed. + */ + error: z.string().nullable().optional(), + /** + * Indicates if the last execution was aborted by the user. + */ + is_aborted: z.boolean().optional(), +}); + /** * The rule migration object ( without Id ) with its settings. */ @@ -154,6 +181,10 @@ export const RuleMigrationData = z.object({ * The moment migration was created */ created_at: NonEmptyString, + /** + * The last execution of the rule migration task. + */ + last_execution: RuleMigrationLastExecution.optional(), }); /** @@ -274,7 +305,13 @@ export const RuleMigrationRule = z * The status of the migration task. */ export type RuleMigrationTaskStatus = z.infer; -export const RuleMigrationTaskStatus = z.enum(['ready', 'running', 'stopped', 'finished']); +export const RuleMigrationTaskStatus = z.enum([ + 'ready', + 'running', + 'stopped', + 'finished', + 'aborted', +]); export type RuleMigrationTaskStatusEnum = typeof RuleMigrationTaskStatus.enum; export const RuleMigrationTaskStatusEnum = RuleMigrationTaskStatus.enum; diff --git a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index aaefd13041465..eca97ca87d88c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -141,6 +141,9 @@ components: created_at: description: The moment migration was created $ref: '../../../common/api/model/primitives.schema.yaml#/components/schemas/NonEmptyString' + last_execution: + description: The last execution of the rule migration task. + $ref: '#/components/schemas/RuleMigrationLastExecution' RuleMigrationRule: @@ -256,6 +259,7 @@ components: - running - stopped - finished + - aborted RuleMigrationTranslationStats: type: object @@ -464,3 +468,25 @@ components: updated_by: type: string description: The user who last updated the resource + + RuleMigrationLastExecution: + type: object + description: The last execution of the rule migration task. + properties: + started_at: + type: string + description: The moment the last execution started. + ended_at: + type: string + nullable: true + description: The moment the last execution ended. + connector_id: + type: string + description: The connector ID used for the last execution. + error: + type: string + nullable: true + description: The error message if the last execution failed. + is_aborted: + type: boolean + description: Indicates if the last execution was aborted by the user. diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/rule_migrations_panels.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/rule_migrations_panels.tsx index 9be2f0cbfeb59..da2093564d485 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/rule_migrations_panels.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/siem_migrations/start_migration/rule_migrations_panels.tsx @@ -73,8 +73,11 @@ export const RuleMigrationsPanels = React.memo( grow={false} key={migrationStats.id} > - {(migrationStats.status === SiemMigrationTaskStatus.READY || - migrationStats.status === SiemMigrationTaskStatus.STOPPED) && ( + {[ + SiemMigrationTaskStatus.READY, + SiemMigrationTaskStatus.STOPPED, + SiemMigrationTaskStatus.ABORTED, + ].includes(migrationStats.status) && ( )} {migrationStats.status === SiemMigrationTaskStatus.RUNNING && ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/last_error.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/last_error.tsx index 9cf73aa8d7ac9..d0ced052610bf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/last_error.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/last_error.tsx @@ -14,7 +14,13 @@ interface RuleMigrationsLastErrorProps { } export const RuleMigrationsLastError = React.memo(({ message }) => ( - + {message} )); diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx new file mode 100644 index 0000000000000..55e45caee5830 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MigrationReadyPanel } from './migration_ready_panel'; +import { useGetMissingResources } from '../../service/hooks/use_get_missing_resources'; +import { useStartMigration } from '../../service/hooks/use_start_migration'; +import { SiemMigrationTaskStatus } from '../../../../../common/siem_migrations/constants'; +import type { RuleMigrationResourceBase } from '../../../../../common/siem_migrations/model/rule_migration.gen'; + +jest.mock('../data_input_flyout/context', () => ({ + useRuleMigrationDataInputContext: () => ({ + openFlyout: jest.fn(), + }), +})); + +jest.mock('../../../../common/lib/kibana/kibana_react', () => ({ + useKibana: jest.fn(() => ({ + services: { + siemMigrations: { + rules: { + telemetry: jest.fn(), + }, + }, + }, + })), +})); + +jest.mock('../../service/hooks/use_start_migration'); +const useStartMigrationMock = useStartMigration as jest.Mock; +const mockStartMigration = jest.fn(); + +const mockMigrationStateWithError = { + status: SiemMigrationTaskStatus.READY, + last_error: + 'Failed to populate ELSER indices. Make sure the ELSER model is deployed and running at Machine Learning > Trained Models. Error: Exception when running inference id [.elser-2-elasticsearch] on field [elser_embedding]', + id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', + rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + created_at: '2025-05-27T12:12:17.563Z', + last_updated_at: '2025-05-27T12:12:17.563Z', + number: 1, +}; + +const mockMigrationStatsAborted = { + status: SiemMigrationTaskStatus.ABORTED, + id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', + rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + created_at: '2025-05-27T12:12:17.563Z', + last_updated_at: '2025-05-27T12:12:17.563Z', + number: 1, +}; + +const mockMigrationStatsReady = { + status: SiemMigrationTaskStatus.READY, + id: 'c44d2c7d-0de1-4231-8b82-0dcfd67a9fe3', + rules: { total: 6, pending: 6, processing: 0, completed: 0, failed: 0 }, + created_at: '2025-05-27T12:12:17.563Z', + last_updated_at: '2025-05-27T12:12:17.563Z', + number: 1, +}; + +const missingMacro: RuleMigrationResourceBase = { + type: 'macro', + name: 'macro1', +}; +const missingLookup: RuleMigrationResourceBase = { + type: 'lookup', + name: 'lookup1', +}; + +jest.mock('../../service/hooks/use_get_missing_resources'); +const useGetMissingResourcesMock = useGetMissingResources as jest.Mock; + +describe('MigrationReadyPanel', () => { + beforeEach(() => { + useGetMissingResourcesMock.mockReturnValue({ + getMissingResources: jest.fn().mockResolvedValue([]), + isLoading: false, + }); + + useStartMigrationMock.mockReturnValue({ + startMigration: mockStartMigration, + isLoading: false, + error: null, + }); + }); + + describe('Ready Migration', () => { + it('should render description text correctly', () => { + render(); + expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent( + `Migration of 6 rules is created but the translation has not started yet.` + ); + }); + + it('should render start migration button', () => { + render(); + expect(screen.getByTestId('startMigrationButton')).toBeVisible(); + expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Start translation'); + }); + }); + + describe('Migration with Error', () => { + it('should render error message when migration has an error', () => { + render(); + expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent( + 'Migration of 6 rules failed. Please correct the below error and try again.' + ); + expect(screen.getByTestId('ruleMigrationLastError')).toHaveTextContent( + 'Failed to populate ELSER indices. Make sure the ELSER model is deployed and running at Machine Learning > Trained Models. Error: Exception when running inference id [.elser-2-elasticsearch] on field [elser_embedding]' + ); + }); + + it('should render start migration button when there is an error', () => { + render(); + expect(screen.queryByTestId('startMigrationButton')).toHaveTextContent('Start translation'); + }); + }); + + describe('Aborted Migration', () => { + it('should render aborted migration message', () => { + render(); + expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent( + 'Migration of 6 rules was stopped. You can resume it any time.' + ); + }); + + it('should render correct start migration button for aborted migration', () => { + render(); + expect(screen.getByTestId('startMigrationButton')).toHaveTextContent('Resume translation'); + }); + }); + + describe('Missing Resources', () => { + const missingResources = [missingMacro, missingLookup]; + + beforeEach(() => { + useGetMissingResourcesMock.mockImplementation((setterFn: Function) => { + return { + getMissingResources: jest.fn().mockImplementation(() => { + setterFn(missingResources); + }), + isLoading: false, + }; + }); + }); + + it('should render missing resources warning when there are missing resources', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('ruleMigrationDescription')).toHaveTextContent( + 'Migration of 6 rules is created but the translation has not started yet. Upload macros & lookups and start the translation process.' + ); + }); + }); + + it('should render missing resources button', async () => { + render(); + expect(screen.getByTestId('ruleMigrationMissingResourcesButton')).toBeVisible(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx index bbcf5f8c1f930..d9b1766770351 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/migration_ready_panel.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { SiemMigrationTaskStatus } from '../../../../../common/siem_migrations/constants'; import { CenteredLoadingSpinner } from '../../../../common/components/centered_loading_spinner'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import type { RuleMigrationResourceBase } from '../../../../../common/siem_migrations/model/rule_migration.gen'; @@ -39,6 +40,23 @@ export const MigrationReadyPanel = React.memo(({ migra }); }, [openFlyout, migrationStats, telemetry, missingResources.length]); + const isAborted = useMemo( + () => migrationStats.status === SiemMigrationTaskStatus.ABORTED, + [migrationStats.status] + ); + + const migrationPanelDescription = useMemo(() => { + if (migrationStats.last_error) { + return i18n.RULE_MIGRATION_ERROR_DESCRIPTION(migrationStats.rules.total); + } + + if (isAborted) { + return i18n.RULE_MIGRATION_ABORTED_DESCRIPTION(migrationStats.rules.total); + } + + return i18n.RULE_MIGRATION_READY_DESCRIPTION(migrationStats.rules.total); + }, [migrationStats.last_error, migrationStats.rules.total, isAborted]); + return ( @@ -50,13 +68,13 @@ export const MigrationReadyPanel = React.memo(({ migra - - {i18n.RULE_MIGRATION_READY_DESCRIPTION( - migrationStats.rules.total, - !isLoading && missingResources.length > 0 - ? i18n.RULE_MIGRATION_READY_MISSING_RESOURCES - : '' - )} + + {migrationPanelDescription} + + {!isLoading && missingResources.length > 0 + ? ` ${i18n.RULE_MIGRATION_READY_MISSING_RESOURCES}` + : ''} + @@ -66,11 +84,18 @@ export const MigrationReadyPanel = React.memo(({ migra ) : ( {missingResources.length > 0 ? ( - + {i18n.RULE_MIGRATION_UPLOAD_BUTTON} ) : ( - + )} )} @@ -86,16 +111,26 @@ export const MigrationReadyPanel = React.memo(({ migra }); MigrationReadyPanel.displayName = 'MigrationReadyPanel'; -const StartTranslationButton = React.memo<{ migrationId: string }>(({ migrationId }) => { - const { startMigration, isLoading } = useStartMigration(); - const onStartMigration = useCallback(() => { - startMigration(migrationId); - }, [migrationId, startMigration]); +const StartTranslationButton = React.memo<{ migrationId: string; isAborted: boolean }>( + ({ migrationId, isAborted }) => { + const { startMigration, isLoading } = useStartMigration(); + const onStartMigration = useCallback(() => { + startMigration(migrationId); + }, [migrationId, startMigration]); - return ( - - {i18n.RULE_MIGRATION_START_TRANSLATION_BUTTON} - - ); -}); + return ( + + {isAborted + ? i18n.RULE_MIGRATION_RESTART_TRANSLATION_BUTTON + : i18n.RULE_MIGRATION_START_TRANSLATION_BUTTON} + + ); + } +); StartTranslationButton.displayName = 'StartTranslationButton'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/translations.ts index 57141e779a770..36849962d5a51 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/components/migration_status_panels/translations.ts @@ -7,24 +7,43 @@ import { i18n } from '@kbn/i18n'; -export const RULE_MIGRATION_READY_DESCRIPTION = ( - totalRules: number, - missingResourcesText: string -) => +export const RULE_MIGRATION_READY_DESCRIPTION = (totalRules: number) => i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.ready.description', { defaultMessage: - 'Migration of {totalRules} rules is created but the translation has not started yet. {missingResourcesText}', - values: { totalRules, missingResourcesText }, + 'Migration of {totalRules} rules is created but the translation has not started yet.', + values: { totalRules }, + }); + +export const RULE_MIGRATION_ERROR_DESCRIPTION = (totalRules: number) => { + return i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.error.description', { + defaultMessage: + 'Migration of {totalRules} rules failed. Please correct the below error and try again.', + values: { totalRules }, + }); +}; + +export const RULE_MIGRATION_ABORTED_DESCRIPTION = (totalRules: number) => { + return i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.aborted.description', { + defaultMessage: 'Migration of {totalRules} rules was stopped. You can resume it any time.', + values: { totalRules }, }); +}; + export const RULE_MIGRATION_READY_MISSING_RESOURCES = i18n.translate( 'xpack.securitySolution.siemMigrations.rules.panel.ready.missingResources', - { defaultMessage: 'Upload macros & lookups and start the translation process' } + { defaultMessage: 'Upload macros & lookups and start the translation process.' } ); export const RULE_MIGRATION_START_TRANSLATION_BUTTON = i18n.translate( 'xpack.securitySolution.siemMigrations.rules.panel.translate.button', { defaultMessage: 'Start translation' } ); + +export const RULE_MIGRATION_RESTART_TRANSLATION_BUTTON = i18n.translate( + 'xpack.securitySolution.siemMigrations.rules.panel.translate.restartButton', + { defaultMessage: 'Resume translation' } +); + export const RULE_MIGRATION_TITLE = (number: number) => i18n.translate('xpack.securitySolution.siemMigrations.rules.panel.migrationTitle', { defaultMessage: 'SIEM rules migration #{number}', diff --git a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx index af48c73a93c6b..a88058e091d51 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/siem_migrations/rules/pages/index.tsx @@ -116,7 +116,11 @@ export const MigrationRulesPage: React.FC = React.memo( /> )} - {migrationStats.status === SiemMigrationTaskStatus.READY && ( + {[ + SiemMigrationTaskStatus.READY, + SiemMigrationTaskStatus.STOPPED, + SiemMigrationTaskStatus.ABORTED, + ].includes(migrationStats.status) && ( )} {migrationStats.status === SiemMigrationTaskStatus.RUNNING && ( diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts index a00bf3a2e65bd..662b2382ac320 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts @@ -95,7 +95,7 @@ export const registerSiemRuleMigrationsStartRoute = ( } catch (error) { logger.error(error); await siemMigrationAuditLogger.logStart({ migrationId, error }); - return res.badRequest({ body: error.message }); + return res.customError({ statusCode: 500, body: error.message }); } } ) diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/constants.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/constants.ts index 8ede8e1c610bd..df1882e9424b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/constants.ts @@ -23,3 +23,6 @@ export const DEFAULT_TRANSLATION_SEVERITY: Severity = 'low'; export const DEFAULT_TRANSLATION_RISK_SCORE = ELASTIC_SEVERITY_TO_RISK_SCORE_MAP[DEFAULT_TRANSLATION_SEVERITY]; + +/** Maximum size for searches, aggregations and terms queries */ +export const MAX_ES_SEARCH_SIZE = 10_000 as const; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts index 087f040e4fca8..f79ff1dcd5d98 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts @@ -62,6 +62,12 @@ export const mockRuleMigrationsDataLookupsClient = { export const mockRuleMigrationsDataMigrationsClient = { create: jest.fn().mockResolvedValue(undefined), get: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue([]), + saveAsEnded: jest.fn().mockResolvedValue(undefined), + saveAsFailed: jest.fn().mockResolvedValue(undefined), + setIsAborted: jest.fn().mockResolvedValue(undefined), + saveAsStarted: jest.fn().mockResolvedValue(undefined), + updateLastExecution: jest.fn().mockResolvedValue(undefined), } as unknown as jest.Mocked; export const mockDeleteMigration = jest.fn().mockResolvedValue(undefined); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts index 694b6044c0c27..fc26001a6e3e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.test.ts @@ -12,6 +12,7 @@ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mo import type { AuthenticatedUser } from '@kbn/security-plugin-types-common'; import type IndexApi from '@elastic/elasticsearch/lib/api/api'; import type GetApi from '@elastic/elasticsearch/lib/api/api/get'; +import type SearchApi from '@elastic/elasticsearch/lib/api/api/search'; describe('RuleMigrationsDataMigrationClient', () => { let ruleMigrationsDataMigrationClient: RuleMigrationsDataMigrationClient; @@ -148,4 +149,138 @@ describe('RuleMigrationsDataMigrationClient', () => { ]); }); }); + + describe('getAll', () => { + it('should return all migrations', async () => { + const response = { + hits: { + hits: [ + { + _index: '.kibana-siem-rule-migrations', + _id: '1', + _source: { + created_by: currentUser.profile_uid, + created_at: new Date().toISOString(), + }, + }, + { + _index: '.kibana-siem-rule-migrations', + _id: '2', + _source: { + created_by: currentUser.profile_uid, + created_at: new Date().toISOString(), + }, + }, + ], + }, + } as unknown as ReturnType; + + ( + esClient.asInternalUser.search as unknown as jest.MockedFn + ).mockResolvedValueOnce(response); + + await ruleMigrationsDataMigrationClient.getAll(); + expect(esClient.asInternalUser.search).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + size: 10000, + query: { + match_all: {}, + }, + _source: true, + }); + }); + }); + + describe('updateLastExecution', () => { + const lastExecutionParams = { + started_at: new Date().toISOString(), + is_aborted: false, + error: '', + ended_at: new Date().toISOString(), + connector_id: 'testConnector', + }; + + it('should update `started_at` & `connector_id` when called saveAsStarted', async () => { + const migrationId = 'testId'; + + await ruleMigrationsDataMigrationClient.saveAsStarted({ + id: migrationId, + connectorId: lastExecutionParams.connector_id, + }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + started_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + is_aborted: false, + error: null, + ended_at: null, + connector_id: 'testConnector', + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `ended_at` when called saveAsEnded', async () => { + const migrationId = 'testId'; + + await ruleMigrationsDataMigrationClient.saveAsEnded({ id: migrationId }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + ended_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `is_aborted` & `ended_at` correctly when called setIsAborted', async () => { + const migrationId = 'testId'; + + await ruleMigrationsDataMigrationClient.setIsAborted({ id: migrationId }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + is_aborted: true, + }, + }, + retry_on_conflict: 1, + }); + }); + + it('should update `error` params correctly when called saveAsFailed', async () => { + const migrationId = 'testId'; + + await ruleMigrationsDataMigrationClient.saveAsFailed({ + id: migrationId, + error: 'Test error', + }); + + expect(esClient.asInternalUser.update).toHaveBeenCalledWith({ + index: '.kibana-siem-rule-migrations', + id: migrationId, + refresh: 'wait_for', + doc: { + last_execution: { + error: 'Test error', + ended_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }, + retry_on_conflict: 1, + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts index 564b4c5c96e53..d67f9551ec8c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_migration_client.ts @@ -7,9 +7,11 @@ import { v4 as uuidV4 } from 'uuid'; import type { BulkOperationContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { RuleMigrationLastExecution } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { StoredSiemMigration } from '../types'; import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; import { isNotFoundError } from './utils'; +import { MAX_ES_SEARCH_SIZE } from '../constants'; export class RuleMigrationsDataMigrationClient extends RuleMigrationsDataBaseClient { async create(): Promise { @@ -58,6 +60,28 @@ export class RuleMigrationsDataMigrationClient extends RuleMigrationsDataBaseCli }); } + /** + * Gets all migrations from the index. + */ + async getAll(): Promise { + this.logger.info('Getting all migrations'); + const index = await this.getIndexName(); + return this.esClient + .search({ + index, + size: MAX_ES_SEARCH_SIZE, // Adjust size as needed + query: { + match_all: {}, + }, + _source: true, + }) + .then((result) => this.processResponseHits(result)) + .catch((error) => { + this.logger.error(`Error getting all migrations:- ${error}`); + throw error; + }); + } + /** * * Prepares bulk ES delete operation for a migration document based on its id. @@ -74,4 +98,99 @@ export class RuleMigrationsDataMigrationClient extends RuleMigrationsDataBaseCli return [migrationDeleteOperation]; } + + /** + * Sets `is_aborted` flag for migration document + */ + async setIsAborted({ id }: { id: string }): Promise { + this.logger.info(`Saving migration ${id} as aborted`); + + await this.updateLastExecution({ + id, + lastExecutionParams: { + is_aborted: true, + }, + }); + } + + /** + * Saves a migration as failed, updating the last execution parameters with the provided error message. + */ + async saveAsFailed({ id, error }: { id: string; error: string }): Promise { + this.logger.info(`Saving migration ${id} as failed with error: ${error}`); + + await this.updateLastExecution({ + id, + lastExecutionParams: { + error, + ended_at: new Date().toISOString(), + }, + }); + } + + /** + * Saves a migration as started, updating the last execution parameters with the current timestamp. + */ + async saveAsStarted({ + id, + connectorId, + }: { + id: string; + connectorId: RuleMigrationLastExecution['connector_id']; + }): Promise { + this.logger.info(`Saving migration ${id} as started`); + + await this.updateLastExecution({ + id, + lastExecutionParams: { + started_at: new Date().toISOString(), + connector_id: connectorId, + is_aborted: false, + error: null, + ended_at: null, + }, + }); + } + + /** + * Saves a migration as ended, updating the last execution parameters with the current timestamp. + */ + async saveAsEnded({ id }: { id: string }): Promise { + this.logger.info(`Saving migration ${id} as ended`); + await this.updateLastExecution({ + id, + lastExecutionParams: { + ended_at: new Date().toISOString(), + }, + }); + } + + /** + * Updates the last execution parameters for a migration document. + */ + private async updateLastExecution({ + id, + lastExecutionParams, + }: { + id: string; + lastExecutionParams: RuleMigrationLastExecution; + }): Promise { + this.logger.info(`Updating last execution params for migration ${id}`); + const index = await this.getIndexName(); + + await this.esClient + .update({ + index, + id, + refresh: 'wait_for', + doc: { + last_execution: lastExecutionParams, + }, + retry_on_conflict: 1, + }) + .catch((error) => { + this.logger.error(`Error updating last execution for migration ${id}: ${error}`); + throw error; + }); + } } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts index af6fb90e87297..54c5a2de32475 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts @@ -17,6 +17,7 @@ import type { } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import type { StoredRuleMigrationResource } from '../types'; import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; +import { MAX_ES_SEARCH_SIZE } from '../constants'; export type CreateRuleMigrationResourceInput = Pick< RuleMigrationResource, @@ -168,7 +169,7 @@ export class RuleMigrationsDataResourcesClient extends RuleMigrationsDataBaseCli */ async prepareDelete(migrationId: string): Promise { const index = await this.getIndexName(); - const resourcesToBeDeleted = await this.get(migrationId, { size: 10000 }); + const resourcesToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); const resourcesToBeDeletedDocIds = resourcesToBeDeleted.map((resource) => resource.id); return resourcesToBeDeletedDocIds.map((docId) => ({ delete: { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index e2b0ec9ade6d0..73d596e236f14 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -33,6 +33,7 @@ import { import { getSortingOptions, type RuleMigrationSort } from './sort'; import { conditions as searchConditions } from './search'; import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; +import { MAX_ES_SEARCH_SIZE } from '../constants'; export type AddRuleMigrationRulesInput = Omit< RuleMigrationRule, @@ -48,8 +49,6 @@ export interface RuleMigrationGetRulesOptions { size?: number; } -/** Maximum size for searches, aggregations and terms queries */ -const QUERY_MAX_SIZE = 10_000 as const; /* BULK_MAX_SIZE defines the number to break down the bulk operations by. * The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. */ const BULK_MAX_SIZE = 500 as const; @@ -306,7 +305,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient const index = await this.getIndexName(); const aggregations: { migrationIds: AggregationsAggregationContainer } = { migrationIds: { - terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: QUERY_MAX_SIZE }, + terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: MAX_ES_SEARCH_SIZE }, aggregations: { status: { terms: { field: 'status' } }, createdAt: { min: { field: '@timestamp' } }, @@ -342,7 +341,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient terms: { field: 'elastic_rule.integration_ids', // aggregate by integration ids exclude: '', // excluding empty string integration ids - size: QUERY_MAX_SIZE, + size: MAX_ES_SEARCH_SIZE, }, }, }; @@ -454,7 +453,7 @@ export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient * */ async prepareDelete(migrationId: string): Promise { const index = await this.getIndexName(); - const rulesToBeDeleted = await this.get(migrationId, { size: QUERY_MAX_SIZE }); + const rulesToBeDeleted = await this.get(migrationId, { size: MAX_ES_SEARCH_SIZE }); const rulesToBeDeletedDocIds = rulesToBeDeleted.data.map((rule) => rule.id); return rulesToBeDeletedDocIds.map((docId) => ({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts index a7ad78edc600c..e22bc73a351b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts @@ -94,7 +94,9 @@ export const getPrebuiltRulesFieldMap: ({ mitre_attack_ids: { type: 'keyword', array: true, required: false }, }); -export const migrationsFieldMaps: FieldMap>> = { +export const migrationsFieldMaps: FieldMap< + SchemaFieldMapKeys> +> = { created_at: { type: 'date', required: true }, created_by: { type: 'keyword', required: true }, }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts index b3a0c5aa5b5c9..93bc0fefc0d9d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/index_migrators/rule_migrations_per_space_index_migrator.ts @@ -13,8 +13,7 @@ import type { AggregationsStringTermsBucket, } from '@elastic/elasticsearch/lib/api/types'; import type { Adapters, StoredSiemMigration } from '../types'; - -const MAX_ES_SIZE = 10000; +import { MAX_ES_SEARCH_SIZE } from '../constants'; export class RuleMigrationSpaceIndexMigrator { constructor( @@ -28,7 +27,7 @@ export class RuleMigrationSpaceIndexMigrator { const index = this.ruleMigrationIndexAdapters.rules.getIndexName(this.spaceId); const aggregations: Record = { migrationIds: { - terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: MAX_ES_SIZE }, + terms: { field: 'migration_id', order: { createdAt: 'asc' }, size: MAX_ES_SEARCH_SIZE }, aggregations: { createdAt: { min: { field: '@timestamp' } }, createdBy: { terms: { field: 'created_by' } }, @@ -59,7 +58,7 @@ export class RuleMigrationSpaceIndexMigrator { const index = this.ruleMigrationIndexAdapters.migrations.getIndexName(this.spaceId); const result = await this.esClient.search({ index, - size: MAX_ES_SIZE, + size: MAX_ES_SEARCH_SIZE, query: { match_all: {}, }, diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts index 324cf7d6b3aaf..84cb9e07a86fe 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.test.ts @@ -15,7 +15,7 @@ import { import { RuleMigrationTaskRunner } from './rule_migrations_task_runner'; import type { MockedLogger } from '@kbn/logging-mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import type { SiemRuleMigrationsClientDependencies } from '../types'; +import type { SiemRuleMigrationsClientDependencies, StoredSiemMigration } from '../types'; import type { RuleMigrationTaskStartParams } from './types'; import { createRuleMigrationsDataClientMock } from '../data/__mocks__/mocks'; import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client'; @@ -41,6 +41,11 @@ describe('RuleMigrationsTaskClient', () => { let migrationsRunning: MigrationsRunning; let logger: MockedLogger; let data: ReturnType; + const params: RuleMigrationTaskStartParams = { + migrationId, + connectorId: 'connector1', + invocationConfig: {}, + }; beforeEach(() => { migrationsRunning = new Map(); @@ -53,12 +58,6 @@ describe('RuleMigrationsTaskClient', () => { }); describe('start', () => { - const params: RuleMigrationTaskStartParams = { - migrationId, - connectorId: 'connector1', - invocationConfig: {}, - }; - it('should not start if migration is already running', async () => { // Pre-populate with the migration id. migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); @@ -120,7 +119,7 @@ describe('RuleMigrationsTaskClient', () => { abortController: { abort: jest.fn() }, }; // Use our custom mock for this test. - (RuleMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + (RuleMigrationTaskRunner as jest.Mock).mockImplementationOnce(() => mockedRunnerInstance); const client = new RuleMigrationsTaskClient( migrationsRunning, @@ -148,7 +147,7 @@ describe('RuleMigrationsTaskClient', () => { rules: { total: 10, pending: 5, completed: 0, failed: 0 }, } as RuleMigrationDataStats); const mockedRunnerInstance = { - setup: jest.fn().mockImplementation(() => { + setup: jest.fn().mockImplementationOnce(() => { // Simulate a race condition by setting the migration as running during setup. migrationsRunning.set(migrationId, {} as RuleMigrationTaskRunner); return Promise.resolve(); @@ -167,6 +166,46 @@ describe('RuleMigrationsTaskClient', () => { ); await expect(client.start(params)).rejects.toThrow('Task already running for this migration'); }); + + it('should mark migration as started by calling saveAsStarted', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as RuleMigrationDataStats); + + const client = new RuleMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + expect(data.migrations.saveAsStarted).toHaveBeenCalledWith({ + id: migrationId, + connectorId: params.connectorId, + }); + }); + + it('should mark migration as ended by calling saveAsEnded if run completes successfully', async () => { + migrationsRunning = new Map(); + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 5, completed: 0, failed: 0 }, + } as RuleMigrationDataStats); + + const client = new RuleMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + await client.start(params); + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + expect(data.migrations.saveAsEnded).toHaveBeenCalledWith({ id: migrationId }); + }); }); describe('updateToRetry', () => { @@ -212,6 +251,11 @@ describe('RuleMigrationsTaskClient', () => { data.rules.getStats.mockResolvedValue({ rules: { total: 10, pending: 5, completed: 3, failed: 2 }, } as RuleMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); + const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -227,6 +271,10 @@ describe('RuleMigrationsTaskClient', () => { data.rules.getStats.mockResolvedValue({ rules: { total: 10, pending: 10, completed: 0, failed: 0 }, } as RuleMigrationDataStats); + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); + const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -242,6 +290,10 @@ describe('RuleMigrationsTaskClient', () => { data.rules.getStats.mockResolvedValue({ rules: { total: 10, pending: 0, completed: 5, failed: 5 }, } as RuleMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + } as unknown as StoredSiemMigration); const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -275,6 +327,14 @@ describe('RuleMigrationsTaskClient', () => { data.rules.getStats.mockResolvedValue({ rules: { total: 10, pending: 2, completed: 3, failed: 2 }, } as RuleMigrationDataStats); + + data.migrations.get.mockResolvedValue({ + id: migrationId, + last_execution: { + error: 'Test error', + }, + } as unknown as StoredSiemMigration); + const client = new RuleMigrationsTaskClient( migrationsRunning, logger, @@ -299,7 +359,9 @@ describe('RuleMigrationsTaskClient', () => { rules: { total: 10, pending: 2, completed: 3, failed: 2 }, } as RuleMigrationDataStats, ]; + const migrations = [{ id: 'm1' }, { id: 'm2' }] as unknown as StoredSiemMigration[]; data.rules.getAllStats.mockResolvedValue(statsArray); + data.migrations.getAll.mockResolvedValue(migrations); // Mark migration m1 as running. migrationsRunning.set('m1', {} as RuleMigrationTaskRunner); const client = new RuleMigrationsTaskClient( @@ -383,5 +445,59 @@ describe('RuleMigrationsTaskClient', () => { error ); }); + + it('should mark migration task as aborted when manually stopping a running migration', async () => { + const abortMock = jest.fn(); + const migrationRunner = { + abortController: { abort: abortMock }, + } as unknown as RuleMigrationTaskRunner; + migrationsRunning.set(migrationId, migrationRunner); + data.migrations.setIsAborted.mockResolvedValue(undefined); + + const client = new RuleMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + await client.stop(migrationId); + expect(data.migrations.setIsAborted).toHaveBeenCalledWith({ id: migrationId }); + }); + }); + describe('task error', () => { + it('should call saveAsFailed when there has been an error during the migration', async () => { + data.rules.getStats.mockResolvedValue({ + rules: { total: 10, pending: 10, completed: 0, failed: 0 }, + } as RuleMigrationDataStats); + const error = new Error('Migration error'); + + const mockedRunnerInstance = { + setup: jest.fn().mockResolvedValue(undefined), + run: jest.fn().mockRejectedValue(error), + } as unknown as RuleMigrationTaskRunner; + + (RuleMigrationTaskRunner as jest.Mock).mockImplementation(() => mockedRunnerInstance); + + const client = new RuleMigrationsTaskClient( + migrationsRunning, + logger, + data, + currentUser, + dependencies + ); + + const response = await client.start(params); + + // Allow the asynchronous run() call to complete its finally callback. + await new Promise(process.nextTick); + + expect(response).toEqual({ exists: true, started: true }); + + expect(data.migrations.saveAsFailed).toHaveBeenCalledWith({ + id: migrationId, + error: error.message, + }); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index a6afce48f097a..ea7a591a75660 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -14,7 +14,7 @@ import type { RuleMigrationTaskStats } from '../../../../../common/siem_migratio import type { RuleMigrationFilters } from '../../../../../common/siem_migrations/types'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; import type { RuleMigrationDataStats } from '../data/rule_migrations_data_rules_client'; -import type { SiemRuleMigrationsClientDependencies } from '../types'; +import type { SiemRuleMigrationsClientDependencies, StoredSiemMigration } from '../types'; import type { RuleMigrationTaskEvaluateParams, RuleMigrationTaskStartParams, @@ -27,8 +27,6 @@ import { RuleMigrationTaskEvaluator } from './rule_migrations_task_evaluator'; export type MigrationsRunning = Map; export class RuleMigrationsTaskClient { - private static migrationsLastError = new Map(); - constructor( private migrationsRunning: MigrationsRunning, private logger: Logger, @@ -80,15 +78,36 @@ export class RuleMigrationsTaskClient { migrationLogger.info('Starting migration'); this.migrationsRunning.set(migrationId, migrationTaskRunner); - RuleMigrationsTaskClient.migrationsLastError.delete(migrationId); + + await this.data.migrations.saveAsStarted({ + id: migrationId, + connectorId, + }); // run the migration in the background without awaiting and resolve the `start` promise migrationTaskRunner .run(invocationConfig) + .then(() => { + /** + * Handles + * - successful completion of this execution + * - Manual Abort of the execution + */ + migrationLogger.info('Migration Execution task completed successfully'); + // Save the migration execution details on completion + this.data.migrations.saveAsEnded({ id: migrationId }).catch((error) => { + migrationLogger.error(`Error saving migration as ended: ${error}`); + }); + }) .catch((error) => { // no use in throwing the error, the `start` promise is long gone. Just store and log the error - RuleMigrationsTaskClient.migrationsLastError.set(migrationId, error); - migrationLogger.error(`Error executing migration: ${error}`); + this.data.migrations + .saveAsFailed({ id: migrationId, error: error.message }) + .catch((saveError) => { + migrationLogger.error(`Error saving migration as failed: ${saveError}`); + }); + + void migrationLogger.error(`Error executing migration task: ${error}`); }) .finally(() => { this.migrationsRunning.delete(migrationId); @@ -115,44 +134,63 @@ export class RuleMigrationsTaskClient { /** Returns the stats of a migration */ public async getStats(migrationId: string): Promise { + const migration = await this.data.migrations.get({ id: migrationId }); + if (!migration) { + throw new Error(`Migration with ID ${migrationId} not found`); + } const dataStats = await this.data.rules.getStats(migrationId); - const taskStats = this.getTaskStats(migrationId, dataStats.rules); + const taskStats = this.getTaskStats(migration, dataStats.rules); return { ...taskStats, ...dataStats }; } /** Returns the stats of all migrations */ async getAllStats(): Promise { const allDataStats = await this.data.rules.getAllStats(); - return allDataStats.map((dataStats) => { - const taskStats = this.getTaskStats(dataStats.id, dataStats.rules); - return { ...taskStats, ...dataStats }; - }); + const allMigrations = await this.data.migrations.getAll(); + const allMigrationsMap = new Map( + allMigrations.map((migration) => [migration.id, migration]) + ); + + const allStats: RuleMigrationTaskStats[] = []; + + for (const dataStats of allDataStats) { + const migration = allMigrationsMap.get(dataStats.id); + if (migration) { + const taksStats = this.getTaskStats(migration, dataStats.rules); + allStats.push({ ...taksStats, ...dataStats }); + } + } + return allStats; } private getTaskStats( - migrationId: string, + migration: StoredSiemMigration, dataStats: RuleMigrationDataStats['rules'] ): Pick { - const lastError = RuleMigrationsTaskClient.migrationsLastError.get(migrationId); + const lastError = migration?.last_execution?.error; return { - status: this.getTaskStatus(migrationId, dataStats), - ...(lastError && { last_error: lastError.message }), + status: this.getTaskStatus(migration, dataStats), + ...(lastError && { last_error: lastError }), }; } private getTaskStatus( - migrationId: string, + migration: StoredSiemMigration, dataStats: RuleMigrationDataStats['rules'] ): SiemMigrationTaskStatus { + const { id: migrationId, last_execution: lastExecution } = migration; if (this.migrationsRunning.has(migrationId)) { return SiemMigrationTaskStatus.RUNNING; } - if (dataStats.pending === dataStats.total) { - return SiemMigrationTaskStatus.READY; - } if (dataStats.completed + dataStats.failed === dataStats.total) { return SiemMigrationTaskStatus.FINISHED; } + if (lastExecution?.is_aborted) { + return SiemMigrationTaskStatus.ABORTED; + } + if (dataStats.pending === dataStats.total) { + return SiemMigrationTaskStatus.READY; + } return SiemMigrationTaskStatus.STOPPED; } @@ -162,6 +200,7 @@ export class RuleMigrationsTaskClient { const migrationRunning = this.migrationsRunning.get(migrationId); if (migrationRunning) { migrationRunning.abortController.abort(); + await this.data.migrations.setIsAborted({ id: migrationId }); return { exists: true, stopped: true }; } diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts index 8c90c4ea6fb61..0e203b5a82d9d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts @@ -9,7 +9,7 @@ import assert from 'assert'; import type { AuthenticatedUser, Logger } from '@kbn/core/server'; import { abortSignalToPromise, AbortError } from '@kbn/kibana-utils-plugin/server'; import type { RunnableConfig } from '@langchain/core/runnables'; -import type { ElasticRule } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { type ElasticRule } from '../../../../../common/siem_migrations/model/rule_migration.gen'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import { initPromisePool } from '../../../../utils/promise_pool'; import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts index bf29f931760b3..e373e78b427bb 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/siem_migrations/rules/types.ts @@ -23,6 +23,7 @@ import type { RuleVersions } from './data/rule_migrations_data_prebuilt_rules_cl import type { Stored } from '../types'; export type StoredSiemMigration = Stored; + export type StoredRuleMigration = Stored; export type StoredRuleMigrationResource = Stored; diff --git a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/start.ts b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/start.ts index ec0b4603bbb29..5bc4535b95957 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/start.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/start.ts @@ -39,6 +39,11 @@ export default ({ getService }: FtrProviderContext) => { }); expect(response.body).to.eql({ started: true }); + + // Make sure the started_at is populated + const migrationResponse = await migrationRulesRoutes.get({ migrationId }); + expect(migrationResponse.body?.last_execution?.started_at).to.be.ok(); + expect(migrationResponse.body?.last_execution?.connector_id).to.eql('preconfigured-bedrock'); }); it('should return status of running migration correctly ', async () => { @@ -88,7 +93,7 @@ export default ({ getService }: FtrProviderContext) => { it('should reject if connector_id is incorrect', async () => { const response = await migrationRulesRoutes.start({ migrationId, - expectStatusCode: 400, + expectStatusCode: 500, payload: { connector_id: 'preconfigured_bedrock', }, diff --git a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/stop.ts b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/stop.ts index b4b3c0d6d0382..27ec28f86b1de 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/stop.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/siem_migrations/rules/trial_license_complete_tier/stop.ts @@ -5,6 +5,7 @@ * 2.0. */ import expect from '@kbn/expect'; +import pRetry from 'p-retry'; import { FtrProviderContext } from '../../../../ftr_provider_context'; import { defaultOriginalRule, ruleMigrationRouteHelpersFactory } from '../../utils'; @@ -37,17 +38,31 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.eql({ started: true }); // check if it running correctly - let statsResponse = await migrationRulesRoutes.stats({ migrationId }); + const statsResponse = await migrationRulesRoutes.stats({ migrationId }); expect(statsResponse.body.status).to.eql('running'); // Stop Migration const response = await migrationRulesRoutes.stop({ migrationId }); expect(response.body).to.eql({ stopped: true }); - // check if the migration is stopped - statsResponse = await migrationRulesRoutes.stats({ migrationId }); - expect(statsResponse.body.status).to.eql('ready'); + await pRetry( + async () => { + const currentStatsResponse = await migrationRulesRoutes.stats({ migrationId }); + if (currentStatsResponse.body.status !== 'aborted') { + throw new Error('Retry until migration is aborted'); + } + return currentStatsResponse; + }, + { + retries: 3, + } + ); + + const migrationResponse = await migrationRulesRoutes.get({ migrationId }); + expect(migrationResponse.body?.last_execution?.is_aborted).to.eql(true); + expect(migrationResponse.body?.last_execution?.ended_at).to.be.ok(); }); + describe('error scenarios', () => { it('should return 404 if migration id is invalid and non-existent', async () => { await migrationRulesRoutes.start({